Compare commits
74 Commits
9f7367ceeb
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 08c8dd3913 | |||
| 68fc34d0a5 | |||
| 82ef36b439 | |||
| b3e6cfba84 | |||
| d4acef8d90 | |||
|
|
21338f1541 | ||
|
|
da366272b4 | ||
|
|
af27190e29 | ||
|
|
235156a9ee | ||
|
|
68ee22c124 | ||
|
|
5d085153f6 | ||
|
|
01a3c38085 | ||
|
|
7152382dca | ||
|
|
529d3d4697 | ||
|
|
7eef62560e | ||
|
|
f70a133e18 | ||
|
|
d5152609b6 | ||
|
|
de719d9d69 | ||
|
|
27d5c48805 | ||
|
|
64d631d890 | ||
|
|
4423576fa2 | ||
|
|
a77fbc47e3 | ||
|
|
d638afcdb2 | ||
|
|
a247ac5219 | ||
| a1c68eedc2 | |||
|
|
01722edad9 | ||
|
|
378b909398 | ||
|
|
4ad1997bd5 | ||
|
|
0d7e3a1286 | ||
|
|
0c3d4a4edd | ||
|
|
97aa7860a9 | ||
|
|
467f34b42a | ||
|
|
85070cf77b | ||
| 9dcb8e3185 | |||
|
|
a88388ed5d | ||
|
|
91e264bf7a | ||
|
|
333ce62710 | ||
|
|
3c0b1396bc | ||
|
|
1ae82fc4e4 | ||
|
|
ac8d40112e | ||
|
|
7ee5cb0d9c | ||
|
|
4548d959d7 | ||
|
|
002ef5c897 | ||
|
|
f2dd053320 | ||
|
|
554b599599 | ||
|
|
ac879b69b4 | ||
|
|
6c844c0e19 | ||
| 7d0c207f1b | |||
|
|
8ea486a4f4 | ||
|
|
264ee860df | ||
|
|
65c3e3b92d | ||
|
|
819062d697 | ||
|
|
c73ebfb6c0 | ||
|
|
3822ba8ffb | ||
|
|
c79db66739 | ||
|
|
57cd18ad9d | ||
| 56dd9eeb77 | |||
|
|
e831835e6d | ||
|
|
9287adec01 | ||
|
|
0c1202d89a | ||
|
|
0ebfe71361 | ||
|
|
a935c0f4dd | ||
|
|
4ccaca192d | ||
|
|
4cdccc0323 | ||
|
|
21a269998d | ||
|
|
e04d39fe17 | ||
|
|
30d32d8823 | ||
|
|
9c2d516f9d | ||
|
|
7694532d53 | ||
|
|
ee0ad176b0 | ||
|
|
a93e2aa863 | ||
|
|
180eb1ce85 | ||
|
|
5377a0f465 | ||
|
|
c54eb23689 |
75
.dockerignore
Normal file
75
.dockerignore
Normal file
@@ -0,0 +1,75 @@
|
||||
# Git
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Dependencies
|
||||
node_modules/
|
||||
|
||||
# Build output (we build inside Docker)
|
||||
build/
|
||||
dist/
|
||||
|
||||
# IDE
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
.nyc_output/
|
||||
|
||||
# Environment files (will be passed as build args)
|
||||
.env
|
||||
.env.local
|
||||
.env.development
|
||||
.env.development.local
|
||||
.env.test
|
||||
.env.test.local
|
||||
.env.production
|
||||
.env.production.local
|
||||
*.env
|
||||
|
||||
# Logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.docker/
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Temporary files
|
||||
tmp/
|
||||
temp/
|
||||
*.tmp
|
||||
|
||||
# ESLint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Yarn
|
||||
.yarn-integrity
|
||||
.pnp.*
|
||||
|
||||
# Storybook
|
||||
storybook-static/
|
||||
|
||||
# Design files (if any)
|
||||
.superdesign/
|
||||
@@ -1,3 +1,3 @@
|
||||
REACT_APP_BACKEND_URL=http://localhost:8000
|
||||
REACT_APP_BASENAME=/membership
|
||||
PUBLIC_URL=/membership
|
||||
REACT_APP_BASENAME=/
|
||||
PUBLIC_URL=/
|
||||
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Frontend Dockerfile - React with multi-stage build
|
||||
|
||||
# Stage 1: Build
|
||||
FROM node:20-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Copy package files
|
||||
COPY package.json yarn.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Copy source code
|
||||
COPY . .
|
||||
|
||||
# Build arguments for environment variables
|
||||
ARG REACT_APP_BACKEND_URL
|
||||
ENV REACT_APP_BACKEND_URL=$REACT_APP_BACKEND_URL
|
||||
|
||||
# Build the application
|
||||
RUN yarn build
|
||||
|
||||
# Stage 2: Production with Nginx
|
||||
FROM nginx:alpine AS production
|
||||
|
||||
# Copy custom nginx config
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
# Copy built assets from builder stage
|
||||
COPY --from=builder /app/build /usr/share/nginx/html
|
||||
|
||||
# Create non-root user for security
|
||||
RUN adduser -D -g '' appuser && \
|
||||
chown -R appuser:appuser /usr/share/nginx/html && \
|
||||
chown -R appuser:appuser /var/cache/nginx && \
|
||||
chown -R appuser:appuser /var/log/nginx && \
|
||||
touch /var/run/nginx.pid && \
|
||||
chown -R appuser:appuser /var/run/nginx.pid
|
||||
|
||||
# Expose port
|
||||
EXPOSE 80
|
||||
|
||||
# Health check
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:80/ || exit 1
|
||||
|
||||
# Start nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
422
README.md
422
README.md
@@ -33,6 +33,7 @@ npm install
|
||||
```
|
||||
|
||||
**Key Dependencies:**
|
||||
|
||||
- `react@19.0.0` - UI library
|
||||
- `react-router-dom@7.5.1` - Routing
|
||||
- `axios@1.8.4` - HTTP client
|
||||
@@ -57,6 +58,7 @@ REACT_APP_BACKEND_URL=http://localhost:8000
|
||||
```
|
||||
|
||||
**Important:**
|
||||
|
||||
- All environment variables must start with `REACT_APP_`
|
||||
- Restart development server after changing `.env`
|
||||
|
||||
@@ -71,6 +73,7 @@ npm start
|
||||
```
|
||||
|
||||
**Development server will be available at:**
|
||||
|
||||
- Frontend: http://localhost:3000
|
||||
- Auto-reloads on file changes
|
||||
|
||||
@@ -154,31 +157,33 @@ frontend/
|
||||
|
||||
### 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 |
|
||||
| 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';
|
||||
import { useAuth } from "../context/AuthContext";
|
||||
|
||||
function MyComponent() {
|
||||
const { user, login, logout, isAuthenticated } = useAuth();
|
||||
@@ -190,6 +195,7 @@ function MyComponent() {
|
||||
#### Protected Routes
|
||||
|
||||
**PrivateRoute Wrapper:**
|
||||
|
||||
```jsx
|
||||
<Route path="/dashboard" element={
|
||||
<PrivateRoute>
|
||||
@@ -208,61 +214,68 @@ function MyComponent() {
|
||||
#### 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';
|
||||
import api from "../utils/api";
|
||||
|
||||
// GET request
|
||||
const response = await api.get('/members/profile');
|
||||
const response = await api.get("/members/profile");
|
||||
|
||||
// POST request with data
|
||||
const response = await api.post('/admin/users/123/reject', {
|
||||
reason: 'Incomplete application'
|
||||
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' }
|
||||
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';
|
||||
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')
|
||||
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 {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: zodResolver(schema),
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
try {
|
||||
await api.post('/auth/login', data);
|
||||
toast.success('Login successful!');
|
||||
await api.post("/auth/login", data);
|
||||
toast.success("Login successful!");
|
||||
} catch (error) {
|
||||
toast.error(error.response?.data?.detail || 'Login failed');
|
||||
toast.error(error.response?.data?.detail || "Login failed");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<Input {...register('email')} />
|
||||
<Input {...register("email")} />
|
||||
{errors.email && <span>{errors.email.message}</span>}
|
||||
{/* ... */}
|
||||
</form>
|
||||
@@ -273,18 +286,19 @@ function LoginForm() {
|
||||
#### Toast Notifications
|
||||
|
||||
**Using Sonner:**
|
||||
|
||||
```jsx
|
||||
import { toast } from 'sonner';
|
||||
import { toast } from "sonner";
|
||||
|
||||
// Success
|
||||
toast.success('Profile updated successfully!');
|
||||
toast.success("Profile updated successfully!");
|
||||
|
||||
// Error
|
||||
toast.error('Failed to upload photo');
|
||||
toast.error("Failed to upload photo");
|
||||
|
||||
// Custom
|
||||
toast('Processing...', {
|
||||
description: 'Please wait while we process your request'
|
||||
toast("Processing...", {
|
||||
description: "Please wait while we process your request",
|
||||
});
|
||||
```
|
||||
|
||||
@@ -293,11 +307,13 @@ toast('Processing...', {
|
||||
#### 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)
|
||||
@@ -308,6 +324,7 @@ toast('Processing...', {
|
||||
- Email verification trigger
|
||||
|
||||
**Login.js**
|
||||
|
||||
- Email/password authentication
|
||||
- JWT token storage
|
||||
- Remember me functionality
|
||||
@@ -316,12 +333,14 @@ toast('Processing...', {
|
||||
#### 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
|
||||
@@ -329,12 +348,14 @@ toast('Processing...', {
|
||||
- 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
|
||||
@@ -343,36 +364,42 @@ toast('Processing...', {
|
||||
#### 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
|
||||
@@ -385,40 +412,50 @@ toast('Processing...', {
|
||||
**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>
|
||||
```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-var(--purple-lavender) 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
|
||||
@@ -433,6 +470,7 @@ import { Select, SelectTrigger, SelectContent, SelectItem } from './components/u
|
||||
### Color Palette
|
||||
|
||||
**LOAF Brand Colors:**
|
||||
|
||||
```css
|
||||
--primary: #422268 /* Deep Purple - Primary brand */
|
||||
--secondary: #664fa3 /* Light Purple - Secondary elements */
|
||||
@@ -443,21 +481,24 @@ import { Select, SelectTrigger, SelectContent, SelectItem } from './components/u
|
||||
```
|
||||
|
||||
**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 className="bg-var(--purple-ink) text-white">
|
||||
<h1 className="text-var(--orange-light)">Accent Text</h1>
|
||||
<p className="text-var(--purple-lavender)">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)
|
||||
@@ -469,11 +510,12 @@ text-3xl → 1.875rem (30px)
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```jsx
|
||||
<h1 className="text-3xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl font-semibold text-var(--purple-ink)" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Page Title
|
||||
</h1>
|
||||
<p className="text-base text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-base text-var(--purple-lavender)" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Body text
|
||||
</p>
|
||||
```
|
||||
@@ -481,6 +523,7 @@ text-3xl → 1.875rem (30px)
|
||||
### Spacing System
|
||||
|
||||
**Tailwind Spacing Scale:**
|
||||
|
||||
```css
|
||||
p-2 → 0.5rem (8px)
|
||||
p-4 → 1rem (16px)
|
||||
@@ -493,21 +536,23 @@ 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]">
|
||||
<Card className="p-6 bg-background rounded-2xl border-2 border-var(--neutral-800)">
|
||||
{/* Content */}
|
||||
</Card>
|
||||
```
|
||||
|
||||
**Buttons:**
|
||||
|
||||
```jsx
|
||||
// Primary
|
||||
<Button className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-6 py-3">
|
||||
<Button className="bg-var(--purple-lavender) text-white hover:bg-var(--purple-ink) 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">
|
||||
<Button variant="outline" className="border-2 border-var(--neutral-800) text-var(--purple-lavender) hover:bg-var(--lavender-300) rounded-full">
|
||||
Secondary Action
|
||||
</Button>
|
||||
|
||||
@@ -518,17 +563,19 @@ gap-2, gap-4, gap-6, gap-8 (same scale for flex/grid gaps)
|
||||
```
|
||||
|
||||
**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]" />
|
||||
<Input className="rounded-xl border-2 border-var(--neutral-800) focus:border-var(--purple-lavender)" />
|
||||
<Textarea className="rounded-xl border-2 border-var(--neutral-800) min-h-[120px]" />
|
||||
<Select>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-var(--neutral-800)">
|
||||
<SelectValue placeholder="Select..." />
|
||||
</SelectTrigger>
|
||||
</Select>
|
||||
```
|
||||
|
||||
**Badges:**
|
||||
|
||||
```jsx
|
||||
// Status badges
|
||||
<Badge className="bg-green-100 text-green-800">Active</Badge>
|
||||
@@ -539,22 +586,38 @@ gap-2, gap-4, gap-6, gap-8 (same scale for flex/grid gaps)
|
||||
### 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,
|
||||
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]" />
|
||||
<User className="h-5 w-5 text-var(--purple-lavender)" />;
|
||||
```
|
||||
|
||||
### Responsive Design
|
||||
|
||||
**Breakpoints:**
|
||||
|
||||
```css
|
||||
sm: 640px → @media (min-width: 640px)
|
||||
md: 768px → @media (min-width: 768px)
|
||||
@@ -564,6 +627,7 @@ xl: 1280px → @media (min-width: 1280px)
|
||||
```
|
||||
|
||||
**Mobile-First Approach:**
|
||||
|
||||
```jsx
|
||||
<div className="flex flex-col md:flex-row gap-4">
|
||||
{/* Stacks vertically on mobile, horizontal on tablet+ */}
|
||||
@@ -581,6 +645,7 @@ xl: 1280px → @media (min-width: 1280px)
|
||||
### Animations
|
||||
|
||||
**Tailwind Transitions:**
|
||||
|
||||
```jsx
|
||||
<Button className="transition-all duration-200 hover:scale-105">
|
||||
Hover me
|
||||
@@ -600,6 +665,7 @@ xl: 1280px → @media (min-width: 1280px)
|
||||
#### 1. Environment Variables
|
||||
|
||||
Create `.env.production`:
|
||||
|
||||
```bash
|
||||
REACT_APP_BACKEND_URL=https://api.loaf.org
|
||||
REACT_APP_SENTRY_DSN=your-production-sentry-dsn
|
||||
@@ -622,6 +688,7 @@ Build output will be in `/build` directory.
|
||||
### Option A: Netlify (Recommended)
|
||||
|
||||
**Via Netlify CLI:**
|
||||
|
||||
```bash
|
||||
# Install Netlify CLI
|
||||
npm install -g netlify-cli
|
||||
@@ -634,6 +701,7 @@ netlify deploy --prod --dir=build
|
||||
```
|
||||
|
||||
**Via Git Integration:**
|
||||
|
||||
1. Push code to GitHub/GitLab
|
||||
2. Connect repository in Netlify dashboard
|
||||
3. Configure:
|
||||
@@ -643,9 +711,11 @@ netlify deploy --prod --dir=build
|
||||
4. Deploy automatically on push
|
||||
|
||||
**Configure Redirects** (`public/_redirects`):
|
||||
|
||||
```
|
||||
/* /index.html 200
|
||||
```
|
||||
|
||||
This enables client-side routing.
|
||||
|
||||
### Option B: Vercel
|
||||
@@ -659,11 +729,10 @@ vercel --prod
|
||||
```
|
||||
|
||||
**Configure** (`vercel.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"rewrites": [
|
||||
{ "source": "/(.*)", "destination": "/index.html" }
|
||||
],
|
||||
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }],
|
||||
"env": {
|
||||
"REACT_APP_BACKEND_URL": "https://api.loaf.org"
|
||||
}
|
||||
@@ -673,11 +742,13 @@ vercel --prod
|
||||
### 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;
|
||||
@@ -702,6 +773,7 @@ server {
|
||||
```
|
||||
|
||||
**3. Enable site and restart:**
|
||||
|
||||
```bash
|
||||
sudo ln -s /etc/nginx/sites-available/loaf-frontend /etc/nginx/sites-enabled/
|
||||
sudo nginx -t
|
||||
@@ -709,6 +781,7 @@ sudo systemctl restart nginx
|
||||
```
|
||||
|
||||
**4. SSL Certificate:**
|
||||
|
||||
```bash
|
||||
sudo certbot --nginx -d app.loaf.org
|
||||
```
|
||||
@@ -716,12 +789,14 @@ 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
|
||||
```
|
||||
@@ -731,25 +806,31 @@ aws s3 website s3://loaf-frontend --index-document index.html --error-document i
|
||||
### Performance Optimization
|
||||
|
||||
**1. Code Splitting:**
|
||||
|
||||
```jsx
|
||||
// Lazy load routes
|
||||
import { lazy, Suspense } from 'react';
|
||||
import { lazy, Suspense } from "react";
|
||||
|
||||
const AdminDonations = lazy(() => import('./pages/admin/AdminDonations'));
|
||||
const AdminDonations = lazy(() => import("./pages/admin/AdminDonations"));
|
||||
|
||||
<Route path="/admin/donations" element={
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<AdminDonations />
|
||||
</Suspense>
|
||||
} />
|
||||
<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
|
||||
@@ -762,6 +843,7 @@ npx webpack-bundle-analyzer build/static/js/*.js
|
||||
### CI/CD Pipeline
|
||||
|
||||
**GitHub Actions** (`.github/workflows/deploy.yml`):
|
||||
|
||||
```yaml
|
||||
name: Deploy Frontend
|
||||
|
||||
@@ -776,7 +858,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: '18'
|
||||
node-version: "18"
|
||||
- run: yarn install
|
||||
- run: yarn build
|
||||
env:
|
||||
@@ -800,6 +882,7 @@ jobs:
|
||||
**Error:** `Module not found: Can't resolve 'package-name'`
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Clear node_modules and reinstall
|
||||
rm -rf node_modules yarn.lock
|
||||
@@ -815,6 +898,7 @@ npm install
|
||||
**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`
|
||||
@@ -824,6 +908,7 @@ npm install
|
||||
**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
|
||||
@@ -833,6 +918,7 @@ npm install
|
||||
**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)
|
||||
@@ -842,6 +928,7 @@ npm install
|
||||
**Error:** `npm run build` fails with memory error
|
||||
|
||||
**Solution:**
|
||||
|
||||
```bash
|
||||
# Increase Node memory limit
|
||||
NODE_OPTIONS=--max_old_space_size=4096 yarn build
|
||||
@@ -852,6 +939,7 @@ NODE_OPTIONS=--max_old_space_size=4096 yarn build
|
||||
**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`
|
||||
@@ -862,6 +950,7 @@ NODE_OPTIONS=--max_old_space_size=4096 yarn build
|
||||
**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
|
||||
@@ -870,16 +959,16 @@ NODE_OPTIONS=--max_old_space_size=4096 yarn build
|
||||
|
||||
```jsx
|
||||
// Add to any component for debugging
|
||||
console.log('Component rendered', { user, props });
|
||||
console.log("Component rendered", { user, props });
|
||||
|
||||
// Check API responses
|
||||
api.interceptors.response.use(
|
||||
response => {
|
||||
console.log('API Response:', response);
|
||||
(response) => {
|
||||
console.log("API Response:", response);
|
||||
return response;
|
||||
},
|
||||
error => {
|
||||
console.error('API Error:', error.response);
|
||||
(error) => {
|
||||
console.error("API Error:", error.response);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
@@ -910,3 +999,182 @@ api.interceptors.response.use(
|
||||
**Last Updated**: December 18, 2024
|
||||
**Version**: 1.0.0
|
||||
**Maintainer**: LOAF Development Team
|
||||
|
||||
**Backend API**
|
||||
|
||||
**Auth**
|
||||
- POST `/api/auth/register`
|
||||
- GET `/api/auth/verify-email`
|
||||
- POST `/api/auth/resend-verification-email`
|
||||
- POST `/api/auth/login`
|
||||
- POST `/api/auth/forgot-password`
|
||||
- POST `/api/auth/reset-password`
|
||||
- GET `/api/auth/me`
|
||||
- GET `/api/auth/permissions`
|
||||
|
||||
**Users**
|
||||
- PUT `/api/users/change-password`
|
||||
- GET `/api/users/profile`
|
||||
- PUT `/api/users/profile`
|
||||
|
||||
**Members**
|
||||
- GET `/api/members/directory` (defined twice in code)
|
||||
- GET `/api/members/directory/{user_id}`
|
||||
- GET `/api/members/profile`
|
||||
- PUT `/api/members/profile`
|
||||
- POST `/api/members/profile/upload-photo`
|
||||
- DELETE `/api/members/profile/delete-photo`
|
||||
- GET `/api/members/calendar/events`
|
||||
- GET `/api/members/gallery`
|
||||
- GET `/api/members/event-activity`
|
||||
|
||||
**Events (public/member)**
|
||||
- GET `/api/events`
|
||||
- GET `/api/events/{event_id}`
|
||||
- GET `/api/events/{event_id}/gallery`
|
||||
- POST `/api/events/{event_id}/rsvp`
|
||||
- GET `/api/events/{event_id}/download.ics`
|
||||
|
||||
**Calendars**
|
||||
- GET `/api/calendars/subscribe.ics`
|
||||
- GET `/api/calendars/all-events.ics`
|
||||
|
||||
**Newsletters (public)**
|
||||
- GET `/api/newsletters`
|
||||
- GET `/api/newsletters/years`
|
||||
|
||||
**Financials (public)**
|
||||
- GET `/api/financials`
|
||||
|
||||
**Bylaws (public)**
|
||||
- GET `/api/bylaws/current`
|
||||
- GET `/api/bylaws/history`
|
||||
|
||||
**Config/Diagnostics**
|
||||
- GET `/api/config`
|
||||
- GET `/api/config/limits`
|
||||
- GET `/api/diagnostics/cors`
|
||||
|
||||
**Invitations**
|
||||
- GET `/api/invitations/verify/{token}`
|
||||
- POST `/api/invitations/accept`
|
||||
|
||||
**Subscriptions**
|
||||
- GET `/api/subscriptions/plans`
|
||||
- POST `/api/subscriptions/checkout`
|
||||
|
||||
**Donations**
|
||||
- POST `/api/donations/checkout`
|
||||
|
||||
**Contact**
|
||||
- POST `/api/contact`
|
||||
|
||||
**Admin – Calendar**
|
||||
- POST `/api/admin/calendar/sync/{event_id}`
|
||||
- DELETE `/api/admin/calendar/unsync/{event_id}`
|
||||
|
||||
**Admin – Event Gallery**
|
||||
- POST `/api/admin/events/{event_id}/gallery`
|
||||
- DELETE `/api/admin/event-gallery/{image_id}`
|
||||
- PUT `/api/admin/event-gallery/{image_id}`
|
||||
|
||||
**Admin – Events**
|
||||
- POST `/api/admin/events`
|
||||
- PUT `/api/admin/events/{event_id}`
|
||||
- GET `/api/admin/events/{event_id}`
|
||||
- GET `/api/admin/events/{event_id}/rsvps`
|
||||
- PUT `/api/admin/events/{event_id}/attendance`
|
||||
- GET `/api/admin/events`
|
||||
- DELETE `/api/admin/events/{event_id}`
|
||||
|
||||
**Admin – Storage**
|
||||
- GET `/api/admin/storage/usage`
|
||||
- GET `/api/admin/storage/breakdown`
|
||||
|
||||
**Admin – Users & Invitations**
|
||||
- GET `/api/admin/users`
|
||||
- GET `/api/admin/users/invitations`
|
||||
- GET `/api/admin/users/export`
|
||||
- GET `/api/admin/users/{user_id}`
|
||||
- PUT `/api/admin/users/{user_id}`
|
||||
- PUT `/api/admin/users/{user_id}/validate`
|
||||
- PUT `/api/admin/users/{user_id}/status`
|
||||
- POST `/api/admin/users/{user_id}/reject`
|
||||
- POST `/api/admin/users/{user_id}/activate-payment`
|
||||
- PUT `/api/admin/users/{user_id}/reset-password`
|
||||
- PUT `/api/admin/users/{user_id}/role`
|
||||
- POST `/api/admin/users/{user_id}/resend-verification`
|
||||
- POST `/api/admin/users/{user_id}/upload-photo`
|
||||
- DELETE `/api/admin/users/{user_id}/delete-photo`
|
||||
- POST `/api/admin/users/create`
|
||||
- POST `/api/admin/users/invite`
|
||||
- POST `/api/admin/users/invitations/{invitation_id}/resend`
|
||||
- DELETE `/api/admin/users/invitations/{invitation_id}`
|
||||
- POST `/api/admin/users/import`
|
||||
- GET `/api/admin/users/import-jobs`
|
||||
- GET `/api/admin/users/import-jobs/{job_id}`
|
||||
|
||||
**Admin – Imports**
|
||||
- POST `/api/admin/import/upload-csv`
|
||||
- GET `/api/admin/import/{job_id}/preview`
|
||||
- POST `/api/admin/import/{job_id}/execute`
|
||||
- POST `/api/admin/import/{job_id}/rollback`
|
||||
- GET `/api/admin/import/{job_id}/status`
|
||||
- GET `/api/admin/import/{job_id}/errors/download`
|
||||
|
||||
**Admin – Subscriptions**
|
||||
- GET `/api/admin/subscriptions/plans`
|
||||
- GET `/api/admin/subscriptions/plans/{plan_id}`
|
||||
- POST `/api/admin/subscriptions/plans`
|
||||
- PUT `/api/admin/subscriptions/plans/{plan_id}`
|
||||
- DELETE `/api/admin/subscriptions/plans/{plan_id}`
|
||||
- GET `/api/admin/subscriptions`
|
||||
- GET `/api/admin/subscriptions/stats`
|
||||
- PUT `/api/admin/subscriptions/{subscription_id}`
|
||||
- POST `/api/admin/subscriptions/{subscription_id}/cancel`
|
||||
- GET `/api/admin/subscriptions/export`
|
||||
|
||||
**Admin – Donations**
|
||||
- GET `/api/admin/donations`
|
||||
- GET `/api/admin/donations/stats`
|
||||
- GET `/api/admin/donations/export`
|
||||
|
||||
**Admin – Newsletters**
|
||||
- POST `/api/admin/newsletters`
|
||||
- PUT `/api/admin/newsletters/{newsletter_id}`
|
||||
- DELETE `/api/admin/newsletters/{newsletter_id}`
|
||||
|
||||
**Admin – Financials**
|
||||
- POST `/api/admin/financials`
|
||||
- PUT `/api/admin/financials/{report_id}`
|
||||
- DELETE `/api/admin/financials/{report_id}`
|
||||
|
||||
**Admin – Bylaws**
|
||||
- POST `/api/admin/bylaws`
|
||||
- PUT `/api/admin/bylaws/{bylaws_id}`
|
||||
- DELETE `/api/admin/bylaws/{bylaws_id}`
|
||||
|
||||
**Admin – Roles**
|
||||
- GET `/api/admin/roles`
|
||||
- GET `/api/admin/roles/assignable`
|
||||
- POST `/api/admin/roles`
|
||||
- GET `/api/admin/roles/{role_id}`
|
||||
- PUT `/api/admin/roles/{role_id}`
|
||||
- DELETE `/api/admin/roles/{role_id}`
|
||||
- GET `/api/admin/roles/{role_id}/permissions`
|
||||
- PUT `/api/admin/roles/{role_id}/permissions`
|
||||
|
||||
**Admin – Permissions**
|
||||
- GET `/api/admin/permissions`
|
||||
- GET `/api/admin/permissions/modules`
|
||||
- GET `/api/admin/permissions/roles/{role}`
|
||||
- PUT `/api/admin/permissions/roles/{role}`
|
||||
- POST `/api/admin/permissions/seed`
|
||||
|
||||
**Admin – Stripe Settings**
|
||||
- GET `/api/admin/settings/stripe/status`
|
||||
- POST `/api/admin/settings/stripe/test-connection`
|
||||
- PUT `/api/admin/settings/stripe`
|
||||
|
||||
**Webhooks**
|
||||
- POST `/api/webhooks/stripe`
|
||||
44
nginx.conf
Normal file
44
nginx.conf
Normal file
@@ -0,0 +1,44 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied expired no-cache no-store private auth;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml application/javascript application/json;
|
||||
|
||||
# Security headers
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-XSS-Protection "1; mode=block" always;
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
try_files $uri =404;
|
||||
}
|
||||
|
||||
# Handle React Router - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Health check endpoint
|
||||
location /health {
|
||||
access_log off;
|
||||
return 200 "healthy\n";
|
||||
add_header Content-Type text/plain;
|
||||
}
|
||||
|
||||
# Disable access to hidden files
|
||||
location ~ /\. {
|
||||
deny all;
|
||||
access_log off;
|
||||
log_not_found off;
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.4",
|
||||
"@stripe/react-stripe-js": "^2.0.0",
|
||||
"@stripe/stripe-js": "^2.0.0",
|
||||
"@tailwindcss/line-clamp": "^0.4.4",
|
||||
"axios": "^1.8.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
||||
5
public/health.json
Normal file
5
public/health.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"status": "healthy",
|
||||
"mode": "production",
|
||||
"build": "optimized"
|
||||
}
|
||||
32
src/App.css
32
src/App.css
@@ -1,32 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Nunito Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', sans-serif;
|
||||
background-color: #FFFFFF;
|
||||
color: #422268;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.inter {
|
||||
font-family: 'Inter', sans-serif;
|
||||
}
|
||||
|
||||
.nunito-sans {
|
||||
font-family: 'Nunito Sans', sans-serif;
|
||||
}
|
||||
|
||||
.bg-purple-gradient {
|
||||
background: linear-gradient(135deg, rgba(100, 76, 159, 0.2) 0%, rgba(72, 40, 110, 0.2) 100%);
|
||||
}
|
||||
|
||||
.bg-soft-mesh {
|
||||
background: radial-gradient(ellipse at top right, rgba(221, 216, 235, 0.4) 0%, #FFFFFF 50%, #FFFFFF 100%);
|
||||
}
|
||||
49
src/App.js
49
src/App.js
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { Toaster } from './components/ui/sonner';
|
||||
import IdleSessionWarning from './components/IdleSessionWarning';
|
||||
import Landing from './pages/Landing';
|
||||
import Register from './pages/Register';
|
||||
import Login from './pages/Login';
|
||||
@@ -21,7 +22,10 @@ import AdminUserView from './pages/admin/AdminUserView';
|
||||
import AdminStaff from './pages/admin/AdminStaff';
|
||||
import AdminMembers from './pages/admin/AdminMembers';
|
||||
import AdminPermissions from './pages/admin/AdminPermissions';
|
||||
import AdminSettings from './pages/admin/AdminSettings';
|
||||
import AdminMemberTiers from './pages/admin/AdminMemberTiers';
|
||||
import AdminRoles from './pages/admin/AdminRoles';
|
||||
import AdminTheme from './pages/admin/AdminTheme';
|
||||
import AdminEvents from './pages/admin/AdminEvents';
|
||||
import AdminEventAttendance from './pages/admin/AdminEventAttendance';
|
||||
import AdminValidations from './pages/admin/AdminValidations';
|
||||
@@ -29,6 +33,7 @@ import AdminPlans from './pages/admin/AdminPlans';
|
||||
import AdminSubscriptions from './pages/admin/AdminSubscriptions';
|
||||
import AdminDonations from './pages/admin/AdminDonations';
|
||||
import AdminLayout from './layouts/AdminLayout';
|
||||
import SettingsLayout from './layouts/SettingsLayout';
|
||||
import { AuthProvider, useAuth } from './context/AuthContext';
|
||||
import MemberRoute from './components/MemberRoute';
|
||||
import MemberCalendar from './pages/members/MemberCalendar';
|
||||
@@ -42,6 +47,8 @@ import AdminGallery from './pages/admin/AdminGallery';
|
||||
import AdminNewsletters from './pages/admin/AdminNewsletters';
|
||||
import AdminFinancials from './pages/admin/AdminFinancials';
|
||||
import AdminBylaws from './pages/admin/AdminBylaws';
|
||||
import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder';
|
||||
import AdminDirectorySettings from './pages/admin/AdminDirectorySettings';
|
||||
import History from './pages/History';
|
||||
import MissionValues from './pages/MissionValues';
|
||||
import BoardOfDirectors from './pages/BoardOfDirectors';
|
||||
@@ -56,19 +63,19 @@ import NotFound from './pages/NotFound';
|
||||
|
||||
const PrivateRoute = ({ children, adminOnly = false }) => {
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
|
||||
if (loading) {
|
||||
return <div className="min-h-screen flex items-center justify-center">Loading...</div>;
|
||||
}
|
||||
|
||||
|
||||
if (!user) {
|
||||
return <Navigate to="/login" />;
|
||||
}
|
||||
|
||||
|
||||
if (adminOnly && !['admin', 'superadmin'].includes(user.role)) {
|
||||
return <Navigate to="/dashboard" />;
|
||||
}
|
||||
|
||||
|
||||
return children;
|
||||
};
|
||||
|
||||
@@ -233,6 +240,20 @@ function App() {
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/registration" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminRegistrationBuilder />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/member-tiers" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminMemberTiers />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
} />
|
||||
<Route path="/admin/plans" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
@@ -284,16 +305,30 @@ function App() {
|
||||
} />
|
||||
<Route path="/admin/permissions" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<AdminRoles />
|
||||
</AdminLayout>
|
||||
<Navigate to="/admin/settings/permissions" replace />
|
||||
</PrivateRoute>
|
||||
} />
|
||||
|
||||
<Route path="/admin/settings" element={
|
||||
<PrivateRoute adminOnly>
|
||||
<AdminLayout>
|
||||
<SettingsLayout />
|
||||
</AdminLayout>
|
||||
</PrivateRoute>
|
||||
}>
|
||||
<Route index element={<Navigate to="stripe" replace />} />
|
||||
<Route path="stripe" element={<AdminSettings />} />
|
||||
<Route path="permissions" element={<AdminRoles />} />
|
||||
<Route path="theme" element={<AdminTheme />} />
|
||||
<Route path="directory" element={<AdminDirectorySettings />} />
|
||||
<Route path="registration" element={<AdminRegistrationBuilder />} />
|
||||
</Route>
|
||||
|
||||
{/* 404 - Catch all undefined routes */}
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
<Toaster position="top-right" />
|
||||
<IdleSessionWarning />
|
||||
</BrowserRouter>
|
||||
</AuthProvider>
|
||||
);
|
||||
|
||||
222
src/components/AddPaymentMethodDialog.js
Normal file
222
src/components/AddPaymentMethodDialog.js
Normal file
@@ -0,0 +1,222 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Label } from './ui/label';
|
||||
import { CreditCard, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../utils/api';
|
||||
|
||||
/**
|
||||
* AddPaymentMethodDialog - Dialog for adding a new payment method using Stripe Elements
|
||||
*
|
||||
* This dialog should be wrapped in an Elements provider with a clientSecret
|
||||
*
|
||||
* @param {string} saveEndpoint - Optional custom API endpoint for saving (default: '/payment-methods')
|
||||
*/
|
||||
const AddPaymentMethodDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
clientSecret,
|
||||
saveEndpoint = '/payment-methods',
|
||||
}) => {
|
||||
const stripe = useStripe();
|
||||
const elements = useElements();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [setAsDefault, setSetAsDefault] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!stripe || !elements) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Get the CardElement
|
||||
const cardElement = elements.getElement(CardElement);
|
||||
|
||||
if (!cardElement) {
|
||||
setError('Card element not found');
|
||||
toast.error('Card element not found');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm the SetupIntent with the card element
|
||||
const { error: stripeError, setupIntent } = await stripe.confirmCardSetup(
|
||||
clientSecret,
|
||||
{
|
||||
payment_method: {
|
||||
card: cardElement,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (stripeError) {
|
||||
setError(stripeError.message);
|
||||
toast.error(stripeError.message);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (setupIntent.status === 'succeeded') {
|
||||
// Save the payment method to our backend using the specified endpoint
|
||||
await api.post(saveEndpoint, {
|
||||
stripe_payment_method_id: setupIntent.payment_method,
|
||||
set_as_default: setAsDefault,
|
||||
});
|
||||
|
||||
toast.success('Payment method added successfully');
|
||||
onSuccess?.();
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setError(`Setup failed with status: ${setupIntent.status}`);
|
||||
toast.error('Failed to set up payment method');
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to save payment method';
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<CreditCard className="h-6 w-6" />
|
||||
<div>
|
||||
<DialogTitle
|
||||
className="text-lg font-semibold text-white"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Add Payment Method
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
className="text-white/80 text-sm"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Enter your card details securely
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-6">
|
||||
{/* Stripe Card Element */}
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
className="text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Card Information
|
||||
</Label>
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl p-4 bg-white">
|
||||
<CardElement
|
||||
options={{
|
||||
style: {
|
||||
base: {
|
||||
fontSize: '16px',
|
||||
color: '#2d2a4a',
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
'::placeholder': {
|
||||
color: '#9ca3af',
|
||||
},
|
||||
},
|
||||
invalid: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
},
|
||||
hidePostalCode: false,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Set as Default Checkbox */}
|
||||
<div className="flex items-center space-x-3">
|
||||
<Checkbox
|
||||
id="setAsDefault"
|
||||
checked={setAsDefault}
|
||||
onCheckedChange={setSetAsDefault}
|
||||
className="border-brand-purple data-[state=checked]:bg-brand-purple"
|
||||
/>
|
||||
<Label
|
||||
htmlFor="setAsDefault"
|
||||
className="text-sm text-brand-purple cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Set as default payment method for future payments
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p
|
||||
className="text-sm text-red-600"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Security Note */}
|
||||
<p
|
||||
className="text-xs text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Your card information is securely processed by Stripe. We never store your full card number.
|
||||
</p>
|
||||
|
||||
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!stripe || loading}
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Add Card'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddPaymentMethodDialog;
|
||||
@@ -117,7 +117,7 @@ export default function AddToCalendarButton({
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant={variant} size={size} className="gap-2">
|
||||
<Button variant={variant} size={size} className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full gap-2 dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender">
|
||||
<Calendar className="h-4 w-4" />
|
||||
Add to Calendar
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
@@ -128,7 +128,7 @@ export default function AddToCalendarButton({
|
||||
{event && (
|
||||
<>
|
||||
{/* Single Event Export Options */}
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[#422268]">
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[var(--purple-ink)]">
|
||||
Add This Event
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function AddToCalendarButton({
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z"/>
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z" />
|
||||
</svg>
|
||||
Google Calendar
|
||||
</DropdownMenuItem>
|
||||
@@ -147,7 +147,7 @@ export default function AddToCalendarButton({
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M7 2h14v20H7V2zm7 11c0 2.761-2.239 5-5 5H2c-.552 0-1-.448-1-1s.448-1 1-1h7c1.657 0 3-1.343 3-3V9c0-1.657-1.343-3-3-3H2c-.552 0-1-.448-1-1s.448-1 1-1h7c2.761 0 5 2.239 5 5v4z"/>
|
||||
<path d="M7 2h14v20H7V2zm7 11c0 2.761-2.239 5-5 5H2c-.552 0-1-.448-1-1s.448-1 1-1h7c1.657 0 3-1.343 3-3V9c0-1.657-1.343-3-3-3H2c-.552 0-1-.448-1-1s.448-1 1-1h7c2.761 0 5 2.239 5 5v4z" />
|
||||
</svg>
|
||||
Outlook Web
|
||||
</DropdownMenuItem>
|
||||
@@ -157,7 +157,7 @@ export default function AddToCalendarButton({
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<svg className="h-4 w-4 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"/>
|
||||
<path d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z" />
|
||||
</svg>
|
||||
Apple Calendar
|
||||
</DropdownMenuItem>
|
||||
@@ -177,7 +177,7 @@ export default function AddToCalendarButton({
|
||||
{showSubscribe && (
|
||||
<>
|
||||
{/* Subscription Options */}
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[#422268]">
|
||||
<div className="px-2 py-1.5 text-sm font-semibold text-[var(--purple-ink)]">
|
||||
Calendar Feeds
|
||||
</div>
|
||||
|
||||
@@ -187,7 +187,7 @@ export default function AddToCalendarButton({
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Subscribe to My Events
|
||||
<div className="text-xs text-[#664fa3] mt-0.5">
|
||||
<div className="text-xs text-brand-purple mt-0.5">
|
||||
Auto-syncs your RSVP'd events
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@@ -198,7 +198,7 @@ export default function AddToCalendarButton({
|
||||
>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download All Events
|
||||
<div className="text-xs text-[#664fa3] mt-0.5">
|
||||
<div className="text-xs text-brand-purple mt-0.5">
|
||||
One-time import of all upcoming events
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
@@ -206,7 +206,7 @@ export default function AddToCalendarButton({
|
||||
)}
|
||||
|
||||
{!event && !showSubscribe && (
|
||||
<div className="px-2 py-6 text-center text-sm text-[#664fa3]">
|
||||
<div className="px-2 py-6 text-center text-sm text-brand-purple ">
|
||||
No event selected
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||
import api from '../utils/api';
|
||||
import { Badge } from './ui/badge';
|
||||
import {
|
||||
@@ -23,16 +25,23 @@ import {
|
||||
HardDrive,
|
||||
Repeat,
|
||||
Heart,
|
||||
Sun,
|
||||
Moon,
|
||||
Star,
|
||||
FileEdit
|
||||
} from 'lucide-react';
|
||||
|
||||
const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { user, logout } = useAuth();
|
||||
const { getLogoUrl } = useThemeConfig();
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [storageUsed, setStorageUsed] = useState(0);
|
||||
const [storageLimit, setStorageLimit] = useState(0);
|
||||
const [storagePercentage, setStoragePercentage] = useState(0);
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Fetch pending approvals count
|
||||
useEffect(() => {
|
||||
@@ -86,6 +95,10 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
setTheme(isDark ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
const navItems = [
|
||||
{
|
||||
name: 'Dashboard',
|
||||
@@ -93,18 +106,31 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
path: '/admin',
|
||||
disabled: false
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Staff',
|
||||
name: 'Staff & Admins',
|
||||
icon: UserCog,
|
||||
path: '/admin/staff',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Members',
|
||||
name: 'Member Roster',
|
||||
icon: Users,
|
||||
path: '/admin/members',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Member Tiers',
|
||||
icon: Star,
|
||||
path: '/admin/member-tiers',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Registration',
|
||||
icon: FileEdit,
|
||||
path: '/admin/registration',
|
||||
disabled: false
|
||||
},
|
||||
{
|
||||
name: 'Validations',
|
||||
icon: CheckCircle,
|
||||
@@ -160,10 +186,11 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
path: '/admin/bylaws',
|
||||
disabled: false
|
||||
},
|
||||
|
||||
{
|
||||
name: 'Permissions',
|
||||
icon: Shield,
|
||||
path: '/admin/permissions',
|
||||
name: 'Settings',
|
||||
icon: Settings,
|
||||
path: '/admin/settings',
|
||||
disabled: false,
|
||||
superadminOnly: true
|
||||
}
|
||||
@@ -172,11 +199,15 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
// Filter nav items based on user role
|
||||
const filteredNavItems = navItems.filter(item => {
|
||||
if (item.superadminOnly && user?.role !== 'superadmin') {
|
||||
console.log('Filtering out superadmin-only item:', item.name, 'User role:', user?.role);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Debug: Log filtered items count
|
||||
console.log('Total nav items:', navItems.length, 'Filtered items:', filteredNavItems.length, 'User role:', user?.role);
|
||||
|
||||
const isActive = (path) => {
|
||||
if (path === '/admin') {
|
||||
return location.pathname === '/admin';
|
||||
@@ -202,16 +233,16 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg transition-all relative
|
||||
${item.disabled
|
||||
? 'opacity-50 cursor-not-allowed text-[#664fa3]'
|
||||
? 'opacity-50 cursor-not-allowed text-brand-purple '
|
||||
: active
|
||||
? 'bg-[#ff9e77]/10 text-[#ff9e77]'
|
||||
: 'text-[#422268] hover:bg-[#DDD8EB]/20'
|
||||
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]'
|
||||
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/20'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Active border */}
|
||||
{active && !item.disabled && (
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-[#ff9e77] rounded-r" />
|
||||
<div className="absolute left-0 top-0 bottom-0 w-1 bg-accent rounded-r" />
|
||||
)}
|
||||
|
||||
<Icon className="h-5 w-5 flex-shrink-0" />
|
||||
@@ -220,12 +251,12 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
<>
|
||||
<span className="flex-1">{item.name}</span>
|
||||
{item.disabled && (
|
||||
<Badge className="bg-[#DDD8EB] text-[#422268] text-xs px-2 py-0.5">
|
||||
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)] text-xs px-2 py-0.5">
|
||||
Soon
|
||||
</Badge>
|
||||
)}
|
||||
{item.badge > 0 && !item.disabled && (
|
||||
<Badge className="bg-[#ff9e77] text-white text-xs px-2 py-0.5">
|
||||
<Badge className="bg-accent foreground text-xs px-2 py-0.5">
|
||||
{item.badge}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -234,7 +265,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
|
||||
{/* Badge when collapsed */}
|
||||
{!isOpen && item.badge > 0 && !item.disabled && (
|
||||
<div className="absolute -top-1 -right-1 bg-[#ff9e77] text-white text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
<div className="absolute -top-1 -right-1 bg-accent text-white foreground text-xs rounded-full h-5 w-5 flex items-center justify-center font-medium">
|
||||
{item.badge}
|
||||
</div>
|
||||
)}
|
||||
@@ -242,7 +273,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
|
||||
{/* Tooltip when collapsed */}
|
||||
{!isOpen && (
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
{item.name}
|
||||
{item.badge > 0 && ` (${item.badge})`}
|
||||
</div>
|
||||
@@ -256,7 +287,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* Sidebar */}
|
||||
<aside
|
||||
className={`
|
||||
bg-white border-r border-[#ddd8eb] transition-all duration-300 ease-out
|
||||
bg-background border-r border-[var(--neutral-800)] 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'}
|
||||
@@ -264,36 +295,33 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-[#ddd8eb]">
|
||||
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]">
|
||||
<Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
|
||||
<img
|
||||
src={`${process.env.PUBLIC_URL}/loaf-logo.png`}
|
||||
src={getLogoUrl()}
|
||||
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" }}>
|
||||
<h2 className="text-xl font-semibold text-primary dark:text-brand-light-lavender " 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"
|
||||
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
|
||||
aria-label={isOpen ? 'Collapse sidebar' : 'Expand sidebar'}
|
||||
>
|
||||
{isMobile ? (
|
||||
<Menu className="h-5 w-5 text-[#422268]" />
|
||||
<Menu className="h-5 w-5 text-primary" />
|
||||
) : isOpen ? (
|
||||
<ChevronLeft className="h-5 w-5 text-[#422268]" />
|
||||
<ChevronLeft className="h-5 w-5 text-primary" />
|
||||
) : (
|
||||
<ChevronRight className="h-5 w-5 text-[#422268]" />
|
||||
<ChevronRight className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
@@ -303,24 +331,36 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* Dashboard - Standalone */}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))}
|
||||
|
||||
{/* Onboarding Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Onboarding
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Registration'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))}
|
||||
</div>
|
||||
{/* MEMBERSHIP Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground 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'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Roster'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Member Tiers'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Staff & Admins'))}
|
||||
</div>
|
||||
|
||||
{/* FINANCIALS Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Financials
|
||||
</h3>
|
||||
</div>
|
||||
@@ -334,7 +374,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* EVENTS & MEDIA Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Events & Media
|
||||
</h3>
|
||||
</div>
|
||||
@@ -347,7 +387,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* DOCUMENTATION Section */}
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-[#664fa3] uppercase tracking-wider">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
Documentation
|
||||
</h3>
|
||||
</div>
|
||||
@@ -358,54 +398,91 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Bylaws'))}
|
||||
</div>
|
||||
|
||||
{/* Permissions - Superadmin only (no header) */}
|
||||
{/* SYSTEM Section - Superadmin only */}
|
||||
{user?.role === 'superadmin' && (
|
||||
<div className="mt-6">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
|
||||
</div>
|
||||
<>
|
||||
{isOpen && (
|
||||
<div className="px-4 py-2 mt-6">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
System
|
||||
</h3>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-1">
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Permissions'))}
|
||||
{renderNavItem(filteredNavItems.find(item => item.name === 'Settings'))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</nav>
|
||||
|
||||
{/* User Section */}
|
||||
<div className="border-t border-[#ddd8eb] p-4 space-y-2">
|
||||
<div className="border-t border-[var(--neutral-800)] p-4 space-y-2">
|
||||
{isOpen && user && (
|
||||
<div className="px-4 py-3 mb-2 flex justify-between items-center">
|
||||
<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-[var(--neutral-800)] flex items-center justify-center text-[var(--purple-ink)] font-semibold">
|
||||
{user.first_name?.[0]}{user.last_name?.[0]}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-[#422268] truncate" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-sm font-medium text-primary dark:text-brand-light-lavender truncate" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-xs text-[#664fa3] capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-muted-foreground capitalize truncate" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link to='/profile'><Settings size={16} />
|
||||
<Link className='dark:text-brand-lavender ' to='/profile'><Settings size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Theme Toggle */}
|
||||
<div className="relative group">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleThemeToggle}
|
||||
aria-pressed={isDark}
|
||||
aria-label={isDark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg w-full
|
||||
text-primary dark:text-brand-lavender hover:bg-muted/20 transition-colors
|
||||
${!isOpen && 'justify-center'}
|
||||
`}
|
||||
>
|
||||
{isDark ? (
|
||||
<Sun className="h-5 w-5 flex-shrink-0 " />
|
||||
) : (
|
||||
<Moon className="h-5 w-5 flex-shrink-0" />
|
||||
)}
|
||||
{isOpen && <span >{isDark ? 'Light mode' : 'Dark mode'}</span>}
|
||||
</button>
|
||||
{!isOpen && (
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
{isDark ? 'Light mode' : 'Dark mode'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Storage Usage Widget */}
|
||||
<div className="mb-2">
|
||||
{isOpen ? (
|
||||
<div className="px-4 py-3 bg-[#F8F7FB] rounded-lg">
|
||||
<div className="px-4 py-3 bg-[var(--lavender-500)] rounded-lg">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-[#422268]">Storage Usage</span>
|
||||
<span className="text-xs text-[#664fa3]">{storagePercentage}%</span>
|
||||
<span className="text-sm font-medium text-primary dark:text-brand-light-lavender ">Storage Usage</span>
|
||||
<span className="text-xs text-muted-foreground">{storagePercentage}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-[#ddd8eb] rounded-full h-2">
|
||||
<div className="w-full bg-[var(--neutral-800)] rounded-full h-2">
|
||||
<div
|
||||
className={`h-2 rounded-full transition-all ${storagePercentage > 90 ? 'bg-red-500' :
|
||||
storagePercentage > 75 ? 'bg-yellow-500' :
|
||||
'bg-[#81B29A]'
|
||||
'bg-[var(--green-light)]'
|
||||
}`}
|
||||
style={{ width: `${storagePercentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-[#664fa3] mt-1">
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{formatBytes(storageUsed)} / {formatBytes(storageLimit)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -414,13 +491,13 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
<div className="relative group">
|
||||
<HardDrive className={`h-5 w-5 ${storagePercentage > 90 ? 'text-red-500' :
|
||||
storagePercentage > 75 ? 'text-yellow-500' :
|
||||
'text-[#664fa3]'
|
||||
'text-muted-foreground'
|
||||
}`} />
|
||||
{storagePercentage > 75 && (
|
||||
<div className="absolute -top-1 -right-1 bg-red-500 h-2 w-2 rounded-full" />
|
||||
)}
|
||||
{/* Tooltip */}
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 top-1/2 -translate-y-1/2 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
Storage: {storagePercentage}%
|
||||
</div>
|
||||
</div>
|
||||
@@ -433,7 +510,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
onClick={handleLogout}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg w-full
|
||||
text-[#ff9e77] hover:bg-[#ff9e77]/10 transition-colors
|
||||
text-accent hover:bg-accent/10 transition-colors
|
||||
${!isOpen && 'justify-center'}
|
||||
`}
|
||||
>
|
||||
@@ -444,7 +521,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
|
||||
{/* Logout tooltip when collapsed */}
|
||||
{!isOpen && (
|
||||
<div className="relative group">
|
||||
<div className="absolute left-full ml-2 bottom-0 px-3 py-2 bg-[#422268] text-white text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
<div className="absolute left-full ml-2 bottom-0 px-3 py-2 bg-primary foreground text-sm rounded-lg opacity-0 group-hover:opacity-100 pointer-events-none transition-opacity whitespace-nowrap z-50">
|
||||
Logout
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,21 +55,21 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-white">
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto bg-background scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Mark Attendance: {event?.title}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 mt-4">
|
||||
{rsvps.length === 0 ? (
|
||||
<p className="text-center text-[#664fa3] py-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No RSVPs yet</p>
|
||||
<p className="text-center text-brand-purple py-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No RSVPs yet</p>
|
||||
) : (
|
||||
rsvps.map((rsvp) => (
|
||||
<div
|
||||
key={rsvp.user_id}
|
||||
className="flex items-center gap-3 p-4 border-2 border-[#ddd8eb] rounded-xl hover:border-[#664fa3] transition-colors"
|
||||
className="flex items-center gap-3 p-4 border-2 border-[var(--neutral-800)] rounded-xl hover:border-brand-purple transition-colors"
|
||||
>
|
||||
<Checkbox
|
||||
checked={attendance[rsvp.user_id] || false}
|
||||
@@ -79,11 +79,11 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
|
||||
className="w-5 h-5"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>{rsvp.user_name}</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{rsvp.user_email}</p>
|
||||
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>{rsvp.user_name}</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{rsvp.user_email}</p>
|
||||
</div>
|
||||
{rsvp.attended && (
|
||||
<span className="text-sm text-[#81B29A] font-medium">
|
||||
<span className="text-sm text-[var(--green-light)] font-medium">
|
||||
✓ Attended
|
||||
</span>
|
||||
)}
|
||||
@@ -96,14 +96,14 @@ export const AttendanceDialog = ({ event, open, onOpenChange, onSuccess }) => {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={loading}
|
||||
className="flex-1 bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full"
|
||||
className="flex-1 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full"
|
||||
>
|
||||
{loading ? 'Saving...' : 'Save Attendance'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => onOpenChange(false)}
|
||||
variant="outline"
|
||||
className="flex-1 border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-white hover:text-[#422268] rounded-full"
|
||||
className="flex-1 border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-background hover:text-[var(--purple-ink)] rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
|
||||
@@ -66,17 +66,17 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md bg-white">
|
||||
<DialogContent className="sm:max-w-md bg-background overflow-y-auto max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[#f1eef9]">
|
||||
<Lock className="h-5 w-5 text-[#ff9e77]" />
|
||||
<div className="inline-flex items-center justify-center w-10 h-10 rounded-full bg-[var(--lavender-300)]">
|
||||
<Lock className="h-5 w-5 text-[var(--orange-light)]" />
|
||||
</div>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Change Password
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Update your password to keep your account secure.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -92,7 +92,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter current password"
|
||||
className="h-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -106,7 +106,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter new password (min. 6 characters)"
|
||||
className="h-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -120,23 +120,22 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter new password"
|
||||
className="h-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="mt-6">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-full px-6"
|
||||
className="btn-outline mr-33 text-white"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6 disabled:opacity-50"
|
||||
className=" btn-primary"
|
||||
>
|
||||
{loading ? 'Changing...' : 'Change Password'}
|
||||
</Button>
|
||||
|
||||
149
src/components/ChangeRoleDialog.js
Normal file
149
src/components/ChangeRoleDialog.js
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { Label } from './ui/label';
|
||||
import { AlertCircle, Shield } from 'lucide-react';
|
||||
import api from '../utils/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function ChangeRoleDialog({ open, onClose, user, onSuccess }) {
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [selectedRole, setSelectedRole] = useState('');
|
||||
const [selectedRoleId, setSelectedRoleId] = useState(null);
|
||||
const [loadingRoles, setLoadingRoles] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRoles();
|
||||
// Pre-select current role
|
||||
setSelectedRole(user.role);
|
||||
setSelectedRoleId(user.role_id);
|
||||
}
|
||||
}, [open, user]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoadingRoles(true);
|
||||
try {
|
||||
// Reuse existing endpoint that returns assignable roles based on privilege
|
||||
const response = await api.get('/admin/roles/assignable');
|
||||
// Map API response to format expected by Select component
|
||||
const mappedRoles = response.data.map(role => ({
|
||||
value: role.code,
|
||||
label: role.name,
|
||||
id: role.id,
|
||||
description: role.description
|
||||
}));
|
||||
setRoles(mappedRoles);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch assignable roles:', error);
|
||||
toast.error('Failed to load roles. Please try again.');
|
||||
} finally {
|
||||
setLoadingRoles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedRole) {
|
||||
toast.error('Please select a role');
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't submit if role hasn't changed
|
||||
if (selectedRole === user.role && selectedRoleId === user.role_id) {
|
||||
toast.info('The selected role is the same as current role');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.put(`/admin/users/${user.id}/role`, {
|
||||
role: selectedRole,
|
||||
role_id: selectedRoleId
|
||||
});
|
||||
|
||||
toast.success(`Role changed to ${selectedRole}`);
|
||||
|
||||
onSuccess();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.detail || 'Failed to change role';
|
||||
toast.error(message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent className="sm:max-w-[500px] overflow-y-auto max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Shield className="h-5 w-5 text-[#664fa3]" />
|
||||
Change User Role
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Change role for {user.first_name} {user.last_name} ({user.email})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
{/* Current Role Display */}
|
||||
<div className="p-3 bg-[#f1eef9] rounded-lg border border-[#DDD8EB]">
|
||||
<p className="text-sm text-gray-600">Current Role</p>
|
||||
<p className="font-semibold text-[#664fa3] capitalize">{user.role}</p>
|
||||
</div>
|
||||
|
||||
{/* Role Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="role">New Role</Label>
|
||||
<Select value={selectedRole} onValueChange={setSelectedRole} disabled={loadingRoles}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select role"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.map((role) => (
|
||||
<SelectItem key={role.value} value={role.value}>
|
||||
<span className="capitalize">{role.label}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Warning for privileged roles */}
|
||||
{(selectedRole === 'admin' || selectedRole === 'superadmin') && (
|
||||
<div className="flex items-start gap-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm">
|
||||
<p className="font-semibold text-amber-900">Admin Access Warning</p>
|
||||
<p className="text-amber-700">
|
||||
This user will gain full administrative access to the system.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={submitting}
|
||||
className="border-2 border-gray-300 rounded-full"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={submitting || loadingRoles}
|
||||
className="bg-[#664fa3] hover:bg-[#7d5ec2] text-white rounded-full"
|
||||
>
|
||||
{submitting ? 'Changing Role...' : 'Change Role'}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1138
src/components/ComprehensiveImportWizard.js
Normal file
1138
src/components/ComprehensiveImportWizard.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -38,8 +38,8 @@ const ConfirmationDialog = ({
|
||||
const variants = {
|
||||
warning: {
|
||||
icon: AlertTriangle,
|
||||
iconColor: 'text-[#ff9e77]',
|
||||
confirmButtonClass: 'bg-[#ff9e77] text-white hover:bg-[#e88d66] rounded-full px-6',
|
||||
iconColor: 'text-[var(--orange-light)]',
|
||||
confirmButtonClass: 'bg-[var(--orange-light)] text-white hover:bg-[var(--orange-sand)] rounded-full px-6',
|
||||
},
|
||||
danger: {
|
||||
icon: AlertTriangle,
|
||||
@@ -48,13 +48,13 @@ const ConfirmationDialog = ({
|
||||
},
|
||||
info: {
|
||||
icon: Info,
|
||||
iconColor: 'text-[#664fa3]',
|
||||
confirmButtonClass: 'bg-[#664fa3] text-white hover:bg-[#553d8a] rounded-full px-6',
|
||||
iconColor: 'text-brand-purple ',
|
||||
confirmButtonClass: 'bg-brand-purple text-white hover:bg-[var(--purple-plum)] rounded-full px-6',
|
||||
},
|
||||
success: {
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-[#81B29A]',
|
||||
confirmButtonClass: 'bg-[#81B29A] text-white hover:bg-[#6fa188] rounded-full px-6',
|
||||
iconColor: 'text-[var(--green-light)]',
|
||||
confirmButtonClass: 'bg-[var(--green-light)] text-white hover:bg-[var(--green-pastel)] rounded-full px-6',
|
||||
},
|
||||
};
|
||||
|
||||
@@ -63,21 +63,21 @@ const ConfirmationDialog = ({
|
||||
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent className="bg-white rounded-2xl border border-[#ddd8eb] p-0 overflow-hidden max-w-md">
|
||||
<AlertDialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||
<AlertDialogHeader className="p-6 pb-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={`p-3 rounded-full bg-[#F8F7FB] ${config.iconColor}`}>
|
||||
<div className={`p-3 rounded-full bg-[var(--lavender-500)] ${config.iconColor}`}>
|
||||
<Icon className="h-6 w-6" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<AlertDialogTitle
|
||||
className="text-xl font-semibold text-[#422268] mb-2"
|
||||
className="text-xl font-semibold text-[var(--purple-ink)] mb-2"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription
|
||||
className="text-[#664fa3] text-sm leading-relaxed"
|
||||
className="text-brand-purple text-sm leading-relaxed"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{description}
|
||||
@@ -85,9 +85,9 @@ const ConfirmationDialog = ({
|
||||
</div>
|
||||
</div>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="p-6 pt-4 bg-[#F8F7FB] flex-row gap-3 justify-end">
|
||||
<AlertDialogFooter className="p-6 pt-4 bg-[var(--lavender-500)] flex-row gap-3 justify-end">
|
||||
<AlertDialogCancel
|
||||
className="border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-white rounded-full px-6"
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-background rounded-full px-6"
|
||||
disabled={loading}
|
||||
>
|
||||
{cancelText}
|
||||
|
||||
@@ -31,6 +31,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const getTodayDate = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
@@ -84,8 +85,8 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
if (payload.date_of_birth === '') {
|
||||
delete payload.date_of_birth;
|
||||
}
|
||||
if (payload.member_since === '') {
|
||||
delete payload.member_since;
|
||||
if (!payload.member_since) {
|
||||
payload.member_since = getTodayDate();
|
||||
}
|
||||
|
||||
await api.post('/admin/users/create', payload);
|
||||
@@ -119,13 +120,13 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<UserPlus className="h-6 w-6" />
|
||||
Create Member
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create a new member account with direct login access. Member will be created immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -135,7 +136,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
{/* Email & Password Row */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[#422268]">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -143,7 +144,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="member@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
@@ -152,7 +153,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-[#422268]">
|
||||
<Label htmlFor="password" className="text-[var(--purple-ink)]">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -160,7 +161,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
{errors.password && (
|
||||
@@ -172,15 +173,15 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
{/* Name Row */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[#422268]">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
placeholder="John"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Jane"
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="text-sm text-red-500">{errors.first_name}</p>
|
||||
@@ -188,14 +189,14 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[#422268]">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{errors.last_name && (
|
||||
@@ -206,7 +207,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* Phone */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[#422268]">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -214,7 +215,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
{errors.phone && (
|
||||
@@ -224,14 +225,14 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* Address */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address" className="text-[#422268]">
|
||||
<Label htmlFor="address" className="text-[var(--purple-ink)]">
|
||||
Address
|
||||
</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
@@ -239,35 +240,35 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
{/* City, State, Zipcode Row */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="city" className="text-[#422268]">City</Label>
|
||||
<Label htmlFor="city" className="text-[var(--purple-ink)]">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="state" className="text-[#422268]">State</Label>
|
||||
<Label htmlFor="state" className="text-[var(--purple-ink)]">State</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="CA"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
|
||||
<Label htmlFor="zipcode" className="text-[var(--purple-ink)]">Zipcode</Label>
|
||||
<Input
|
||||
id="zipcode"
|
||||
value={formData.zipcode}
|
||||
onChange={(e) => handleChange('zipcode', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="94102"
|
||||
/>
|
||||
</div>
|
||||
@@ -276,24 +277,24 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
{/* Dates Row */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
|
||||
<Label htmlFor="date_of_birth" className="text-[var(--purple-ink)]">Date of Birth</Label>
|
||||
<Input
|
||||
id="date_of_birth"
|
||||
type="date"
|
||||
value={formData.date_of_birth}
|
||||
onChange={(e) => handleChange('date_of_birth', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="member_since" className="text-[#422268]">Member Since</Label>
|
||||
<Label htmlFor="member_since" className="text-[var(--purple-ink)]">Member Since</Label>
|
||||
<Input
|
||||
id="member_since"
|
||||
type="date"
|
||||
value={formData.member_since}
|
||||
onChange={(e) => handleChange('member_since', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -311,7 +312,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
@@ -22,10 +22,12 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
member_since: '',
|
||||
role: 'admin'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const getTodayDate = () => new Date().toISOString().slice(0, 10);
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
@@ -74,7 +76,11 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await api.post('/admin/users/create', formData);
|
||||
const payload = { ...formData };
|
||||
if (!payload.member_since) {
|
||||
payload.member_since = getTodayDate();
|
||||
}
|
||||
await api.post('/admin/users/create', payload);
|
||||
toast.success('Staff member created successfully');
|
||||
|
||||
// Reset form
|
||||
@@ -84,6 +90,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
member_since: '',
|
||||
role: 'admin'
|
||||
});
|
||||
|
||||
@@ -99,13 +106,13 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl">
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<UserPlus className="h-6 w-6" />
|
||||
Create Staff Member
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Create a new staff account with direct login access. User will be created immediately.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -114,7 +121,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Email */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[#422268]">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -122,7 +129,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="staff@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
@@ -132,7 +139,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* Password */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-[#422268]">
|
||||
<Label htmlFor="password" className="text-[var(--purple-ink)]">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -140,7 +147,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
{errors.password && (
|
||||
@@ -150,15 +157,15 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* First Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[#422268]">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
placeholder="John"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Jane"
|
||||
/>
|
||||
{errors.first_name && (
|
||||
<p className="text-sm text-red-500">{errors.first_name}</p>
|
||||
@@ -167,14 +174,14 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* Last Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[#422268]">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{errors.last_name && (
|
||||
@@ -184,7 +191,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* Phone */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[#422268]">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -192,7 +199,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
{errors.phone && (
|
||||
@@ -200,13 +207,27 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Member Since */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="member_since" className="text-[var(--purple-ink)]">
|
||||
Member Since
|
||||
</Label>
|
||||
<Input
|
||||
id="member_since"
|
||||
type="date"
|
||||
value={formData.member_since}
|
||||
onChange={(e) => handleChange('member_since', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role" className="text-[#422268]">
|
||||
<Label htmlFor="role" className="text-[var(--purple-ink)]">
|
||||
Role <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select value={formData.role} onValueChange={(value) => handleChange('role', value)}>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -229,7 +250,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
|
||||
576
src/components/CreateSubscriptionDialog.js
Normal file
576
src/components/CreateSubscriptionDialog.js
Normal file
@@ -0,0 +1,576 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from './ui/select';
|
||||
import { Card } from './ui/card';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Repeat, Search, Calendar, Heart, X, User } from 'lucide-react';
|
||||
|
||||
const CreateSubscriptionDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [searchResults, setSearchResults] = useState([]);
|
||||
const [selectedUser, setSelectedUser] = useState(null);
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [allUsers, setAllUsers] = useState([]);
|
||||
|
||||
// Plan state
|
||||
const [plans, setPlans] = useState([]);
|
||||
const [selectedPlan, setSelectedPlan] = useState(null);
|
||||
const [useCustomPeriod, setUseCustomPeriod] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState({
|
||||
plan_id: '',
|
||||
amount: '',
|
||||
payment_date: new Date().toISOString().split('T')[0],
|
||||
payment_method: 'cash',
|
||||
custom_period_start: new Date().toISOString().split('T')[0],
|
||||
custom_period_end: '',
|
||||
notes: ''
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fetch users and plans when dialog opens
|
||||
useEffect(() => {
|
||||
const fetchData = async () => {
|
||||
if (!open) return;
|
||||
|
||||
try {
|
||||
const [usersResponse, plansResponse] = await Promise.all([
|
||||
api.get('/admin/users'),
|
||||
api.get('/admin/subscriptions/plans')
|
||||
]);
|
||||
setAllUsers(usersResponse.data);
|
||||
setPlans(plansResponse.data.filter(p => p.active));
|
||||
} catch (error) {
|
||||
toast.error('Failed to load data');
|
||||
}
|
||||
};
|
||||
|
||||
fetchData();
|
||||
}, [open]);
|
||||
|
||||
// Filter users based on search query
|
||||
useEffect(() => {
|
||||
if (!searchQuery.trim()) {
|
||||
setSearchResults([]);
|
||||
return;
|
||||
}
|
||||
|
||||
setSearchLoading(true);
|
||||
const query = searchQuery.toLowerCase();
|
||||
const filtered = allUsers.filter(user =>
|
||||
user.first_name?.toLowerCase().includes(query) ||
|
||||
user.last_name?.toLowerCase().includes(query) ||
|
||||
user.email?.toLowerCase().includes(query)
|
||||
).slice(0, 10); // Limit to 10 results
|
||||
|
||||
setSearchResults(filtered);
|
||||
setSearchLoading(false);
|
||||
}, [searchQuery, allUsers]);
|
||||
|
||||
// Update amount when plan changes
|
||||
useEffect(() => {
|
||||
if (selectedPlan && !formData.amount) {
|
||||
const suggestedAmount = (selectedPlan.suggested_price_cents || selectedPlan.minimum_price_cents || selectedPlan.price_cents) / 100;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
amount: suggestedAmount.toFixed(2)
|
||||
}));
|
||||
}
|
||||
}, [selectedPlan]);
|
||||
|
||||
// Calculate donation breakdown
|
||||
const getAmountBreakdown = () => {
|
||||
if (!selectedPlan || !formData.amount) return null;
|
||||
|
||||
const totalCents = Math.round(parseFloat(formData.amount) * 100);
|
||||
const minimumCents = selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000;
|
||||
const donationCents = Math.max(0, totalCents - minimumCents);
|
||||
|
||||
return {
|
||||
total: totalCents,
|
||||
base: minimumCents,
|
||||
donation: donationCents
|
||||
};
|
||||
};
|
||||
|
||||
const formatPrice = (cents) => {
|
||||
return `$${(cents / 100).toFixed(2)}`;
|
||||
};
|
||||
|
||||
const breakdown = getAmountBreakdown();
|
||||
|
||||
const handleSelectUser = (user) => {
|
||||
setSelectedUser(user);
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
};
|
||||
|
||||
const handleClearUser = () => {
|
||||
setSelectedUser(null);
|
||||
setFormData({
|
||||
plan_id: '',
|
||||
amount: '',
|
||||
payment_date: new Date().toISOString().split('T')[0],
|
||||
payment_method: 'cash',
|
||||
custom_period_start: new Date().toISOString().split('T')[0],
|
||||
custom_period_end: '',
|
||||
notes: ''
|
||||
});
|
||||
setSelectedPlan(null);
|
||||
setUseCustomPeriod(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!selectedUser) {
|
||||
toast.error('Please select a user');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.plan_id) {
|
||||
toast.error('Please select a subscription plan');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.amount || parseFloat(formData.amount) <= 0) {
|
||||
toast.error('Please enter a valid payment amount');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate minimum amount
|
||||
const amountCents = Math.round(parseFloat(formData.amount) * 100);
|
||||
const minimumCents = selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000;
|
||||
if (amountCents < minimumCents) {
|
||||
toast.error(`Amount must be at least ${formatPrice(minimumCents)}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (useCustomPeriod && (!formData.custom_period_start || !formData.custom_period_end)) {
|
||||
toast.error('Please specify both start and end dates for custom period');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
plan_id: formData.plan_id,
|
||||
amount_cents: amountCents,
|
||||
payment_date: new Date(formData.payment_date).toISOString(),
|
||||
payment_method: formData.payment_method,
|
||||
override_plan_dates: useCustomPeriod,
|
||||
notes: formData.notes || null
|
||||
};
|
||||
|
||||
if (useCustomPeriod) {
|
||||
payload.custom_period_start = new Date(formData.custom_period_start).toISOString();
|
||||
payload.custom_period_end = new Date(formData.custom_period_end).toISOString();
|
||||
}
|
||||
|
||||
await api.post(`/admin/users/${selectedUser.id}/activate-payment`, payload);
|
||||
toast.success(`Subscription created for ${selectedUser.first_name} ${selectedUser.last_name}!`);
|
||||
|
||||
// Reset form
|
||||
handleClearUser();
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to create subscription';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
handleClearUser();
|
||||
setSearchQuery('');
|
||||
setSearchResults([]);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[700px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Repeat className="h-6 w-6" />
|
||||
Create Subscription
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Search for an existing member and create a subscription with manual payment processing.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* User Search Section */}
|
||||
{!selectedUser ? (
|
||||
<div className="space-y-3">
|
||||
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Search Member
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple" />
|
||||
<Input
|
||||
placeholder="Search by name or email..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
/>
|
||||
{searchLoading && (
|
||||
<Loader2 className="absolute right-3 top-1/2 transform -translate-y-1/2 h-4 w-4 animate-spin text-brand-purple" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search Results */}
|
||||
{searchResults.length > 0 && (
|
||||
<Card className="border-2 border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{searchResults.map((user) => (
|
||||
<button
|
||||
key={user.id}
|
||||
type="button"
|
||||
onClick={() => handleSelectUser(user)}
|
||||
className="w-full p-3 text-left hover:bg-[var(--lavender-400)] transition-colors border-b border-[var(--neutral-800)] last:border-b-0"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
|
||||
<User className="h-5 w-5 text-brand-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{searchQuery && !searchLoading && searchResults.length === 0 && (
|
||||
<p className="text-sm text-brand-purple text-center py-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No members found matching "{searchQuery}"
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Selected User Card */
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border-2 border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-12 w-12 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
|
||||
<User className="h-6 w-6 text-brand-purple" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{selectedUser.first_name} {selectedUser.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedUser.email}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleClearUser}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]/20"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Payment Form - Only show when user is selected */}
|
||||
{selectedUser && (
|
||||
<>
|
||||
{/* Plan Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan_id" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Plan
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.plan_id}
|
||||
onValueChange={(value) => {
|
||||
const plan = plans.find(p => p.id === value);
|
||||
setSelectedPlan(plan);
|
||||
const suggestedAmount = plan ? (plan.suggested_price_cents || plan.minimum_price_cents || plan.price_cents) / 100 : '';
|
||||
setFormData({
|
||||
...formData,
|
||||
plan_id: value,
|
||||
amount: suggestedAmount ? suggestedAmount.toFixed(2) : ''
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select subscription plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{plans.map(plan => {
|
||||
const minPrice = (plan.minimum_price_cents || plan.price_cents) / 100;
|
||||
const sugPrice = plan.suggested_price_cents ? (plan.suggested_price_cents / 100) : null;
|
||||
return (
|
||||
<SelectItem key={plan.id} value={plan.id}>
|
||||
{plan.name} - ${minPrice.toFixed(2)}{sugPrice && sugPrice > minPrice ? ` (Suggested: $${sugPrice.toFixed(2)})` : ''}/{plan.billing_cycle}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedPlan && (
|
||||
<p className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan.description || `${selectedPlan.billing_cycle} subscription`}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Payment Amount */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Amount ($)
|
||||
</Label>
|
||||
<Input
|
||||
id="amount"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="Enter amount"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
required
|
||||
/>
|
||||
{selectedPlan && (
|
||||
<p className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Amount Breakdown */}
|
||||
{breakdown && breakdown.total >= breakdown.base && (
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
|
||||
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex justify-between text-[var(--purple-ink)]">
|
||||
<span>Membership Fee:</span>
|
||||
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
|
||||
</div>
|
||||
{breakdown.donation > 0 && (
|
||||
<div className="flex justify-between text-[var(--orange-light)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
Additional Donation:
|
||||
</span>
|
||||
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
|
||||
<span>Total:</span>
|
||||
<span>{formatPrice(breakdown.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Payment Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Date
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple" />
|
||||
<Input
|
||||
id="payment_date"
|
||||
type="date"
|
||||
value={formData.payment_date}
|
||||
onChange={(e) => setFormData({ ...formData, payment_date: e.target.value })}
|
||||
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_method" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Method
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.payment_method}
|
||||
onValueChange={(value) => setFormData({ ...formData, payment_method: value })}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select payment method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cash">Cash</SelectItem>
|
||||
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
|
||||
<SelectItem value="check">Check</SelectItem>
|
||||
<SelectItem value="other">Other</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Subscription Period */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Period
|
||||
</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="use_custom_period"
|
||||
checked={useCustomPeriod}
|
||||
onChange={(e) => setUseCustomPeriod(e.target.checked)}
|
||||
className="rounded border-[var(--neutral-800)]"
|
||||
/>
|
||||
<Label htmlFor="use_custom_period" className="text-sm text-brand-purple font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Use custom dates instead of plan's billing cycle
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
{useCustomPeriod ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_period_start" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Start Date
|
||||
</Label>
|
||||
<Input
|
||||
id="custom_period_start"
|
||||
type="date"
|
||||
value={formData.custom_period_start}
|
||||
onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
required={useCustomPeriod}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_period_end" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
End Date
|
||||
</Label>
|
||||
<Input
|
||||
id="custom_period_end"
|
||||
type="date"
|
||||
value={formData.custom_period_end}
|
||||
onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
required={useCustomPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
selectedPlan && (
|
||||
<div className="text-sm text-brand-purple bg-[var(--lavender-300)] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan.custom_cycle_enabled ? (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Plan uses custom billing cycle:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const startMonth = months[(selectedPlan.custom_cycle_start_month || 1) - 1];
|
||||
const endMonth = months[(selectedPlan.custom_cycle_end_month || 12) - 1];
|
||||
return `${startMonth} ${selectedPlan.custom_cycle_start_day} - ${endMonth} ${selectedPlan.custom_cycle_end_day} (recurring annually)`;
|
||||
})()}
|
||||
</p>
|
||||
<p className="text-xs">
|
||||
Subscription will end on the upcoming cycle end date based on today's date.
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p>
|
||||
Will use plan's billing cycle: <span className="font-medium">{selectedPlan.billing_cycle}</span>
|
||||
<br />
|
||||
Starts today, ends {selectedPlan.billing_cycle === 'monthly' ? '30 days' :
|
||||
selectedPlan.billing_cycle === 'quarterly' ? '90 days' :
|
||||
selectedPlan.billing_cycle === 'yearly' ? '1 year' :
|
||||
selectedPlan.billing_cycle === 'lifetime' ? 'lifetime' : '1 year'} from now
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Notes (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Additional notes about the payment..."
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="rounded-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading || !selectedUser}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Repeat className="h-4 w-4 mr-2" />
|
||||
Create Subscription
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default CreateSubscriptionDialog;
|
||||
313
src/components/IdleSessionWarning.js
Normal file
313
src/components/IdleSessionWarning.js
Normal file
@@ -0,0 +1,313 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import api from '../utils/api';
|
||||
import logger from '../utils/logger';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* IdleSessionWarning Component
|
||||
*
|
||||
* Monitors user activity and warns before session expiration
|
||||
* - Warns 1 minute before JWT expiry (at 29 minutes if JWT is 30 min)
|
||||
* - Auto-logout on expiration
|
||||
* - "Stay Logged In" extends session
|
||||
* - Checks session validity when tab becomes visible after being hidden
|
||||
*/
|
||||
const IdleSessionWarning = () => {
|
||||
const { user, logout, refreshUser } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Configuration
|
||||
const SESSION_DURATION = 30 * 60 * 1000; // 30 minutes in milliseconds
|
||||
const WARNING_BEFORE_EXPIRY = 1 * 60 * 1000; // Warn 1 minute before expiry
|
||||
const WARNING_TIME = SESSION_DURATION - WARNING_BEFORE_EXPIRY; // 29 minutes
|
||||
|
||||
const [showWarning, setShowWarning] = useState(false);
|
||||
const [timeRemaining, setTimeRemaining] = useState(60); // seconds
|
||||
const [isExtending, setIsExtending] = useState(false);
|
||||
const [isCheckingSession, setIsCheckingSession] = useState(false);
|
||||
|
||||
const activityTimeoutRef = useRef(null);
|
||||
const warningTimeoutRef = useRef(null);
|
||||
const countdownIntervalRef = useRef(null);
|
||||
const lastActivityRef = useRef(Date.now());
|
||||
const lastVisibilityCheckRef = useRef(Date.now());
|
||||
|
||||
// Reset activity timer
|
||||
const resetActivityTimer = useCallback(() => {
|
||||
lastActivityRef.current = Date.now();
|
||||
|
||||
// Clear existing timers
|
||||
if (activityTimeoutRef.current) {
|
||||
clearTimeout(activityTimeoutRef.current);
|
||||
}
|
||||
if (warningTimeoutRef.current) {
|
||||
clearTimeout(warningTimeoutRef.current);
|
||||
}
|
||||
if (countdownIntervalRef.current) {
|
||||
clearInterval(countdownIntervalRef.current);
|
||||
}
|
||||
|
||||
// Hide warning if showing
|
||||
if (showWarning) {
|
||||
setShowWarning(false);
|
||||
}
|
||||
|
||||
// Set new warning timer
|
||||
warningTimeoutRef.current = setTimeout(() => {
|
||||
// Show warning
|
||||
setShowWarning(true);
|
||||
setTimeRemaining(60); // 60 seconds until logout
|
||||
|
||||
// Start countdown
|
||||
countdownIntervalRef.current = setInterval(() => {
|
||||
setTimeRemaining((prev) => {
|
||||
if (prev <= 1) {
|
||||
// Time's up - logout
|
||||
handleSessionExpired();
|
||||
return 0;
|
||||
}
|
||||
return prev - 1;
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Set auto-logout timer
|
||||
activityTimeoutRef.current = setTimeout(() => {
|
||||
handleSessionExpired();
|
||||
}, WARNING_BEFORE_EXPIRY);
|
||||
|
||||
}, WARNING_TIME);
|
||||
}, [showWarning]);
|
||||
|
||||
// Handle session expiration
|
||||
const handleSessionExpired = useCallback(() => {
|
||||
// Clear all timers
|
||||
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
|
||||
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
|
||||
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||
|
||||
setShowWarning(false);
|
||||
logout();
|
||||
navigate('/login', {
|
||||
state: { message: 'Your session has expired due to inactivity. Please log in again.' }
|
||||
});
|
||||
}, [logout, navigate]);
|
||||
|
||||
// Handle "Stay Logged In" button
|
||||
const handleExtendSession = async () => {
|
||||
setIsExtending(true);
|
||||
try {
|
||||
// Refresh user data to get new token
|
||||
await refreshUser();
|
||||
|
||||
// Reset activity timer
|
||||
resetActivityTimer();
|
||||
|
||||
logger.log('[IdleSessionWarning] Session extended successfully');
|
||||
} catch (error) {
|
||||
logger.error('[IdleSessionWarning] Failed to extend session:', error);
|
||||
|
||||
// If refresh fails, logout
|
||||
handleSessionExpired();
|
||||
} finally {
|
||||
setIsExtending(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if session is still valid (called when tab becomes visible)
|
||||
const checkSessionValidity = useCallback(async () => {
|
||||
if (!user || isCheckingSession) return;
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) {
|
||||
logger.log('[IdleSessionWarning] No token found on visibility change');
|
||||
handleSessionExpired();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsCheckingSession(true);
|
||||
try {
|
||||
// Make a lightweight API call to verify token is still valid
|
||||
await api.get('/auth/me');
|
||||
logger.log('[IdleSessionWarning] Session still valid after visibility change');
|
||||
|
||||
// Reset the activity timer since user is back
|
||||
resetActivityTimer();
|
||||
} catch (error) {
|
||||
logger.error('[IdleSessionWarning] Session invalid on visibility change:', error);
|
||||
|
||||
// If 401, the interceptor will handle redirect
|
||||
// For other errors, still redirect to be safe
|
||||
if (error.response?.status !== 401) {
|
||||
handleSessionExpired();
|
||||
}
|
||||
} finally {
|
||||
setIsCheckingSession(false);
|
||||
}
|
||||
}, [user, isCheckingSession, handleSessionExpired, resetActivityTimer]);
|
||||
|
||||
// Handle visibility change (when user returns to tab after being away)
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
const now = Date.now();
|
||||
const timeSinceLastCheck = now - lastVisibilityCheckRef.current;
|
||||
|
||||
// Only check if tab was hidden for more than 1 minute
|
||||
// This prevents unnecessary API calls for quick tab switches
|
||||
if (timeSinceLastCheck > 60 * 1000) {
|
||||
logger.log('[IdleSessionWarning] Tab became visible after', Math.round(timeSinceLastCheck / 1000), 'seconds');
|
||||
checkSessionValidity();
|
||||
}
|
||||
|
||||
lastVisibilityCheckRef.current = now;
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
};
|
||||
}, [user, checkSessionValidity]);
|
||||
|
||||
// Listen for session expired event from API interceptor
|
||||
useEffect(() => {
|
||||
const handleAuthSessionExpired = () => {
|
||||
logger.log('[IdleSessionWarning] Received auth:session-expired event');
|
||||
// Clear all timers
|
||||
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
|
||||
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
|
||||
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||
setShowWarning(false);
|
||||
};
|
||||
|
||||
window.addEventListener('auth:session-expired', handleAuthSessionExpired);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('auth:session-expired', handleAuthSessionExpired);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Track user activity
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
|
||||
const activityEvents = [
|
||||
'mousedown',
|
||||
'mousemove',
|
||||
'keypress',
|
||||
'scroll',
|
||||
'touchstart',
|
||||
'click'
|
||||
];
|
||||
|
||||
// Throttle activity detection to avoid too many resets
|
||||
let throttleTimeout = null;
|
||||
const handleActivity = () => {
|
||||
if (throttleTimeout) return;
|
||||
|
||||
throttleTimeout = setTimeout(() => {
|
||||
resetActivityTimer();
|
||||
throttleTimeout = null;
|
||||
}, 1000); // Throttle to once per second
|
||||
};
|
||||
|
||||
// Add event listeners
|
||||
activityEvents.forEach(event => {
|
||||
document.addEventListener(event, handleActivity, { passive: true });
|
||||
});
|
||||
|
||||
// Initialize timer
|
||||
resetActivityTimer();
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
activityEvents.forEach(event => {
|
||||
document.removeEventListener(event, handleActivity);
|
||||
});
|
||||
|
||||
if (activityTimeoutRef.current) clearTimeout(activityTimeoutRef.current);
|
||||
if (warningTimeoutRef.current) clearTimeout(warningTimeoutRef.current);
|
||||
if (countdownIntervalRef.current) clearInterval(countdownIntervalRef.current);
|
||||
if (throttleTimeout) clearTimeout(throttleTimeout);
|
||||
};
|
||||
}, [user, resetActivityTimer]);
|
||||
|
||||
// Don't render if user is not logged in
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={showWarning} onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
// Prevent closing dialog by clicking outside
|
||||
// User must click a button
|
||||
return;
|
||||
}
|
||||
}}>
|
||||
<DialogContent
|
||||
className="max-w-md"
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="bg-[#ff9e77]/10 p-3 rounded-full">
|
||||
<AlertTriangle className="h-6 w-6 text-[#ff9e77]" />
|
||||
</div>
|
||||
<DialogTitle className="text-[#422268]">
|
||||
Session About to Expire
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]">
|
||||
Your session will expire in <strong className="text-[#422268] text-lg">{timeRemaining}</strong> seconds due to inactivity.
|
||||
|
||||
<div className="mt-4 p-4 bg-[#f1eef9] rounded-lg border border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#422268]">
|
||||
Click <strong>"Stay Logged In"</strong> to continue your session, or you will be automatically logged out.
|
||||
</p>
|
||||
</div>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="flex-col sm:flex-row gap-3 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleSessionExpired}
|
||||
className="border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9]"
|
||||
>
|
||||
Log Out Now
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExtendSession}
|
||||
disabled={isExtending}
|
||||
className="bg-[#664fa3] hover:bg-[#422268] text-white"
|
||||
>
|
||||
{isExtending ? (
|
||||
<>
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin" />
|
||||
Extending...
|
||||
</>
|
||||
) : (
|
||||
'Stay Logged In'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default IdleSessionWarning;
|
||||
@@ -138,13 +138,13 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[800px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Upload className="h-6 w-6" />
|
||||
{importResult ? 'Import Results' : 'Import Members from CSV'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{importResult
|
||||
? 'Review the import results below'
|
||||
: 'Upload a CSV file to bulk import members. Ensure the CSV has the required columns.'}
|
||||
@@ -155,8 +155,8 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
// Upload Form
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* CSV Format Instructions */}
|
||||
<Alert className="border-[#664fa3] bg-[#F9F8FB]">
|
||||
<AlertDescription className="text-sm text-[#422268]">
|
||||
<Alert className="border-brand-purple bg-[var(--lavender-700)]">
|
||||
<AlertDescription className="text-sm text-[var(--purple-ink)]">
|
||||
<strong>Required columns:</strong> Email, First Name, Last Name, Phone, Role
|
||||
<br />
|
||||
<strong>Optional columns:</strong> Status, Address, City, State, Zipcode, Date of Birth, Member Since
|
||||
@@ -167,11 +167,10 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* File Upload Area */}
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${
|
||||
dragActive
|
||||
? 'border-[#664fa3] bg-[#F9F8FB]'
|
||||
: 'border-[#ddd8eb] hover:border-[#664fa3] hover:bg-[#F9F8FB]'
|
||||
}`}
|
||||
className={`border-2 border-dashed rounded-2xl p-12 text-center transition-colors ${dragActive
|
||||
? 'border-brand-purple bg-[var(--lavender-700)]'
|
||||
: 'border-[var(--neutral-800)] hover:border-brand-purple hover:bg-[var(--lavender-700)]'
|
||||
}`}
|
||||
onDragEnter={handleDrag}
|
||||
onDragLeave={handleDrag}
|
||||
onDragOver={handleDrag}
|
||||
@@ -179,12 +178,12 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
>
|
||||
{file ? (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<FileUp className="h-16 w-16 text-[#81B29A]" />
|
||||
<FileUp className="h-16 w-16 text-[var(--green-light)]" />
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
<p className="text-sm text-brand-purple ">
|
||||
{(file.size / 1024).toFixed(2)} KB
|
||||
</p>
|
||||
</div>
|
||||
@@ -199,12 +198,12 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Upload className="h-16 w-16 text-[#ddd8eb]" />
|
||||
<Upload className="h-16 w-16 text-[var(--neutral-800)]" />
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Drag and drop your CSV file here
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3] mb-4">or</p>
|
||||
<p className="text-sm text-brand-purple mb-4">or</p>
|
||||
<Label htmlFor="file-upload">
|
||||
<Button variant="outline" className="rounded-xl cursor-pointer" asChild>
|
||||
<span>Browse Files</span>
|
||||
@@ -223,14 +222,14 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="flex items-center gap-3 p-4 bg-[#F9F8FB] rounded-xl">
|
||||
<div className="flex items-center gap-3 p-4 bg-[var(--lavender-700)] rounded-xl">
|
||||
<Checkbox
|
||||
checked={updateExisting}
|
||||
onCheckedChange={setUpdateExisting}
|
||||
id="update-existing"
|
||||
className="h-5 w-5 border-2 border-[#664fa3] data-[state=checked]:bg-[#664fa3]"
|
||||
className="h-5 w-5 border-2 border-brand-purple data-[state=checked]:bg-brand-purple "
|
||||
/>
|
||||
<Label htmlFor="update-existing" className="text-[#422268] cursor-pointer">
|
||||
<Label htmlFor="update-existing" className="text-[var(--purple-ink)] cursor-pointer">
|
||||
Update existing members (if email already exists)
|
||||
</Label>
|
||||
</div>
|
||||
@@ -240,9 +239,9 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Summary Cards */}
|
||||
<div className="grid md:grid-cols-4 gap-4">
|
||||
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] text-center">
|
||||
<p className="text-sm text-[#664fa3] mb-1">Total Rows</p>
|
||||
<p className="text-2xl font-semibold text-[#422268]">{importResult.total_rows}</p>
|
||||
<div className="p-4 bg-background rounded-xl border border-[var(--neutral-800)] text-center">
|
||||
<p className="text-sm text-brand-purple mb-1">Total Rows</p>
|
||||
<p className="text-2xl font-semibold text-[var(--purple-ink)]">{importResult.total_rows}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-green-50 rounded-xl border border-green-200 text-center">
|
||||
<p className="text-sm text-green-700 mb-1">Successful</p>
|
||||
@@ -252,7 +251,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
<p className="text-sm text-red-700 mb-1">Failed</p>
|
||||
<p className="text-2xl font-semibold text-red-600">{importResult.failed_rows}</p>
|
||||
</div>
|
||||
<div className="p-4 bg-white rounded-xl border border-[#ddd8eb] flex items-center justify-center gap-2">
|
||||
<div className="p-4 bg-background rounded-xl border border-[var(--neutral-800)] flex items-center justify-center gap-2">
|
||||
{getStatusIcon(importResult.status)}
|
||||
{getStatusBadge(importResult.status)}
|
||||
</div>
|
||||
@@ -261,23 +260,23 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
{/* Errors Table */}
|
||||
{importResult.errors && importResult.errors.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Errors ({importResult.errors.length} {importResult.errors.length === 10 ? '- showing first 10' : ''})
|
||||
</h3>
|
||||
<div className="border border-[#ddd8eb] rounded-xl overflow-hidden">
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
|
||||
<TableHead className="text-[#422268] font-semibold">Row</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold">Error</TableHead>
|
||||
<TableRow className="bg-[var(--neutral-800)] hover:bg-[var(--neutral-800)]">
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Row</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Email</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Error</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{importResult.errors.map((error, idx) => (
|
||||
<TableRow key={idx} className="hover:bg-[#F9F8FB]">
|
||||
<TableCell className="font-medium text-[#422268]">{error.row}</TableCell>
|
||||
<TableCell className="text-[#664fa3]">{error.email}</TableCell>
|
||||
<TableRow key={idx} className="hover:bg-[var(--lavender-700)]">
|
||||
<TableCell className="font-medium text-[var(--purple-ink)]">{error.row}</TableCell>
|
||||
<TableCell className="text-brand-purple ">{error.email}</TableCell>
|
||||
<TableCell className="text-red-600 text-sm">{error.error}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -302,7 +301,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading || !file}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -321,7 +320,7 @@ const ImportMembersDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
) : (
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
|
||||
281
src/components/InviteMemberDialog.js
Normal file
281
src/components/InviteMemberDialog.js
Normal file
@@ -0,0 +1,281 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import api from '../utils/api';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2, Mail, Copy, Check } from 'lucide-react';
|
||||
|
||||
const InviteMemberDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
role: 'admin'
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [errors, setErrors] = useState({});
|
||||
const [invitationUrl, setInvitationUrl] = useState(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [loadingRoles, setLoadingRoles] = useState(false);
|
||||
|
||||
// Fetch roles when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
fetchRoles();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
const fetchRoles = async () => {
|
||||
setLoadingRoles(true);
|
||||
try {
|
||||
// New endpoint returns roles based on user's permission level
|
||||
// Superadmin: all roles
|
||||
// Admin: admin, finance, and non-elevated custom roles
|
||||
const response = await api.get('/admin/roles/assignable');
|
||||
setRoles(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch assignable roles:', error);
|
||||
toast.error('Failed to load roles. Please try again.');
|
||||
} finally {
|
||||
setLoadingRoles(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = (field, value) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
// Clear error when user starts typing
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({ ...prev, [field]: null }));
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.email) {
|
||||
newErrors.email = 'Email is required';
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = 'Invalid email format';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const response = await api.post('/admin/users/invite', formData);
|
||||
toast.success('Invitation sent successfully');
|
||||
|
||||
// Show invitation URL
|
||||
setInvitationUrl(response.data.invitation_url);
|
||||
|
||||
// Don't close dialog yet - show invitation URL first
|
||||
if (onSuccess) onSuccess();
|
||||
} catch (error) {
|
||||
const errorMessage = error.response?.data?.detail || 'Failed to send invitation';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = () => {
|
||||
navigator.clipboard.writeText(invitationUrl);
|
||||
setCopied(true);
|
||||
toast.success('Invitation link copied to clipboard');
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset form
|
||||
setFormData({
|
||||
email: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
role: 'admin'
|
||||
});
|
||||
setInvitationUrl(null);
|
||||
setCopied(false);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Mail className="h-6 w-6" />
|
||||
{invitationUrl ? 'Invitation Sent' : 'Invite Member'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{invitationUrl
|
||||
? 'The invitation has been sent via email. You can also copy the link below.'
|
||||
: 'Send an email invitation to join as member. They will set their own password.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{invitationUrl ? (
|
||||
// Show invitation URL after successful send
|
||||
<div className="py-4">
|
||||
<Label className="text-[var(--purple-ink)] mb-2 block">Invitation Link (expires in 7 days)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={invitationUrl}
|
||||
readOnly
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
onClick={copyToClipboard}
|
||||
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white flex-shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
Copied
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Show invitation form
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Email */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="member@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-500">{errors.email}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* First Name (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Jane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="phone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
className="rounded-xl"
|
||||
disabled={loading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Sending...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
Send Invitation
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{invitationUrl && (
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default InviteMemberDialog;
|
||||
@@ -123,13 +123,13 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl">
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl overflow-y-auto max-h-[90vh]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Mail className="h-6 w-6" />
|
||||
{invitationUrl ? 'Invitation Sent' : 'Invite Staff Member'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{invitationUrl
|
||||
? 'The invitation has been sent via email. You can also copy the link below.'
|
||||
: 'Send an email invitation to join as staff. They will set their own password.'}
|
||||
@@ -139,16 +139,16 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
{invitationUrl ? (
|
||||
// Show invitation URL after successful send
|
||||
<div className="py-4">
|
||||
<Label className="text-[#422268] mb-2 block">Invitation Link (expires in 7 days)</Label>
|
||||
<Label className="text-[var(--purple-ink)] mb-2 block">Invitation Link (expires in 7 days)</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={invitationUrl}
|
||||
readOnly
|
||||
className="rounded-xl border-2 border-[#ddd8eb] bg-gray-50"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] bg-gray-50"
|
||||
/>
|
||||
<Button
|
||||
onClick={copyToClipboard}
|
||||
className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white flex-shrink-0"
|
||||
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white flex-shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
@@ -170,7 +170,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
<div className="grid gap-6 py-4">
|
||||
{/* Email */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="email" className="text-[#422268]">
|
||||
<Label htmlFor="email" className="text-[var(--purple-ink)]">
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -178,7 +178,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="staff@example.com"
|
||||
/>
|
||||
{errors.email && (
|
||||
@@ -188,35 +188,35 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
|
||||
{/* First Name (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[#422268]">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
placeholder="John"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Jane"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Last Name (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[#422268]">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Phone (Optional) */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[#422268]">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-gray-400">(Optional)</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -224,14 +224,14 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="role" className="text-[#422268]">
|
||||
<Label htmlFor="role" className="text-[var(--purple-ink)]">
|
||||
Role <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
@@ -239,7 +239,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
onValueChange={(value) => handleChange('role', value)}
|
||||
disabled={loadingRoles}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder={loadingRoles ? "Loading roles..." : "Select a role"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -275,7 +275,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -298,7 +298,7 @@ const InviteStaffDialog = ({ open, onOpenChange, onSuccess }) => {
|
||||
<DialogFooter>
|
||||
<Button
|
||||
onClick={handleClose}
|
||||
className="rounded-xl bg-[#81B29A] hover:bg-[#6DA085] text-white"
|
||||
className="rounded-xl bg-[var(--green-light)] hover:bg-[var(--green-fern)] text-white"
|
||||
>
|
||||
Done
|
||||
</Button>
|
||||
|
||||
19
src/components/MemberBadge.js
Normal file
19
src/components/MemberBadge.js
Normal file
@@ -0,0 +1,19 @@
|
||||
// src/components/MemberBadge.js
|
||||
import React from 'react';
|
||||
import { Badge } from './ui/badge';
|
||||
import { getTierForMember } from '../utils/member-tiers';
|
||||
import { getTierIcon } from '../config/memberTierIcons';
|
||||
|
||||
const MemberBadge = ({ memberSince, tiers }) => {
|
||||
const tier = getTierForMember(memberSince, tiers);
|
||||
const Icon = getTierIcon(tier.iconKey);
|
||||
|
||||
return (
|
||||
<Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
|
||||
<Icon className="size-6" />
|
||||
{tier.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberBadge;
|
||||
189
src/components/MemberCard.js
Normal file
189
src/components/MemberCard.js
Normal file
@@ -0,0 +1,189 @@
|
||||
import React from 'react'
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
|
||||
import MemberBadge from './MemberBadge';
|
||||
import useDirectoryConfig from '../hooks/use-directory-config';
|
||||
|
||||
// Helper function to get initials
|
||||
const getInitials = (firstName, lastName) => {
|
||||
return `${firstName.charAt(0)}${lastName.charAt(0)}`.toUpperCase();
|
||||
};
|
||||
|
||||
// Helper function to ensure social media URLs have proper protocol
|
||||
const getSocialMediaLink = (url) => {
|
||||
if (!url) return null;
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
return url;
|
||||
};
|
||||
|
||||
const MemberCard = ({ member, onViewProfile, tiers }) => {
|
||||
const memberSince = member.member_since || member.created_at;
|
||||
const { isFieldEnabled } = useDirectoryConfig();
|
||||
return (
|
||||
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
|
||||
{/* Member Tier Badge */}
|
||||
<div className='flex justify-end items-center mb-2'>
|
||||
<MemberBadge memberSince={memberSince} tiers={tiers} />
|
||||
</div>
|
||||
<div className="flex justify-center mb-4">
|
||||
{member.profile_photo_url ? (
|
||||
<img
|
||||
src={member.profile_photo_url}
|
||||
alt={`${member.first_name} ${member.last_name}`}
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-[var(--neutral-800)]"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full bg-[var(--neutral-800)] border-4 border-[var(--neutral-800)] flex items-center justify-center">
|
||||
<span className="text-4xl font-semibold text-brand-purple " style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{getInitials(member.first_name, member.last_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] text-center mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{member.first_name} {member.last_name}
|
||||
</h3>
|
||||
|
||||
{/* Partner Name */}
|
||||
{isFieldEnabled('directory_partner_name') && member.directory_partner_name && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Heart className="h-4 w-4 text-[var(--orange-light)]" />
|
||||
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Partner: {member.directory_partner_name}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bio */}
|
||||
{isFieldEnabled('directory_bio') && member.directory_bio && (
|
||||
<p className="text-brand-purple text-center mb-4 line-clamp-3" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{member.directory_bio}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Member Since */}
|
||||
{memberSince && (
|
||||
<div className="flex items-center justify-center gap-2 mb-4">
|
||||
<Calendar className="h-4 w-4 text-brand-purple " />
|
||||
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Member since {new Date(memberSince).toLocaleDateString('en-US', {
|
||||
month: 'long',
|
||||
year: 'numeric'
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-3 mb-4">
|
||||
{isFieldEnabled('directory_email') && member.directory_email && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Mail className="h-4 w-4 text-brand-purple flex-shrink-0" />
|
||||
<a
|
||||
href={`mailto:${member.directory_email}`}
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] truncate"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{member.directory_email}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFieldEnabled('directory_phone') && member.directory_phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-brand-purple flex-shrink-0" />
|
||||
<a
|
||||
href={`tel:${member.directory_phone}`}
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{member.directory_phone}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isFieldEnabled('directory_address') && member.directory_address && (
|
||||
<div className="flex items-start gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-brand-purple flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{member.directory_address}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Social Media Links */}
|
||||
{isFieldEnabled('social_media') && (member.social_media_facebook || member.social_media_instagram || member.social_media_twitter || member.social_media_linkedin) && (
|
||||
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
||||
<div className="flex justify-center gap-3">
|
||||
{member.social_media_facebook && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_facebook)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||||
title="Facebook"
|
||||
>
|
||||
<Facebook className="h-5 w-5 text-[var(--blue-facebook)]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.social_media_instagram && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_instagram)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||||
title="Instagram"
|
||||
>
|
||||
<Instagram className="h-5 w-5 text-[var(--red-instagram)]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.social_media_twitter && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_twitter)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||||
title="Twitter/X"
|
||||
>
|
||||
<Twitter className="h-5 w-5 text-[var(--blue-twitter)]" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
{member.social_media_linkedin && (
|
||||
<a
|
||||
href={getSocialMediaLink(member.social_media_linkedin)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 rounded-lg bg-[var(--lavender-500)] hover:bg-[var(--neutral-800)] transition-colors"
|
||||
title="LinkedIn"
|
||||
>
|
||||
<Linkedin className="h-5 w-5 text-[var(--blue-linkedin)]" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* View Profile Button */}
|
||||
<div className="pt-4 mt-4 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
onClick={() => onViewProfile?.(member.id)}
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-purple hover:text-white rounded-full py-5"
|
||||
>
|
||||
<UserCircle className="h-4 w-4 mr-2" />
|
||||
View Full Profile
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default MemberCard
|
||||
@@ -4,7 +4,7 @@ import { Calendar, Users, User, BookOpen, FileText, DollarSign, Scale } from 'lu
|
||||
|
||||
const MemberFooter = () => {
|
||||
return (
|
||||
<footer className="bg-[#422268] text-white mt-auto">
|
||||
<footer className="bg-brand-dark-lavender text-white mt-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="grid md:grid-cols-4 gap-8">
|
||||
{/* Logo & About */}
|
||||
@@ -89,12 +89,12 @@ const MemberFooter = () => {
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#contact" className="text-gray-300 hover:text-white transition-colors">
|
||||
<a href="/membership/contact-us" className="text-gray-300 hover:text-white transition-colors">
|
||||
Contact Us
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="/#donate" className="text-gray-300 hover:text-white transition-colors">
|
||||
<a href="/membership/donate" className="text-gray-300 hover:text-white transition-colors">
|
||||
Donate
|
||||
</a>
|
||||
</li>
|
||||
@@ -104,14 +104,14 @@ const MemberFooter = () => {
|
||||
</div>
|
||||
|
||||
{/* Bottom Bar */}
|
||||
<div className="border-t border-[#664fa3]">
|
||||
<div className="border-t border-[var(--purple-lavender)]">
|
||||
<div className="max-w-7xl mx-auto px-6 py-4">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-400" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4 text-sm text-gray-300" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex gap-6">
|
||||
<a href="/#terms" className="hover:text-white transition-colors">Terms of Service</a>
|
||||
<a href="/#privacy" className="hover:text-white transition-colors">Privacy Policy</a>
|
||||
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</a>
|
||||
<a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
|
||||
</div>
|
||||
<p>© 2025 LOAF. All rights reserved.</p>
|
||||
<p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,8 +19,8 @@ const MemberRoute = ({ children }) => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-[#FDFCF8]">
|
||||
<p className="text-[#6B708D]">Loading...</p>
|
||||
<div className="min-h-screen flex items-center justify-center bg-[var(--neutral-200:)]">
|
||||
<p className="text-[var(--slate-muted)]">Loading...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||
import { Button } from './ui/button';
|
||||
import { ChevronDown, Menu, X } from 'lucide-react';
|
||||
import {
|
||||
@@ -12,11 +13,12 @@ import {
|
||||
|
||||
const Navbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { getLogoUrl } = useThemeConfig();
|
||||
const navigate = useNavigate();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
|
||||
// LOAF logo (local)
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
// Get logo URL from theme config (with fallback to default)
|
||||
const loafLogo = getLogoUrl();
|
||||
|
||||
const handleLogout = () => {
|
||||
logout();
|
||||
@@ -26,7 +28,7 @@ const Navbar = () => {
|
||||
return (
|
||||
<>
|
||||
{/* Top Header - Member Actions (Desktop Only) */}
|
||||
<header className="hidden lg:flex bg-gradient-to-r from-[#644c9f] to-[#48286e] px-4 sm:px-8 md:px-16 py-4 justify-end items-center gap-4 sm:gap-6">
|
||||
<header className="hidden lg:flex bg-gradient-to-r from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-4 sm:px-8 md:px-16 py-4 justify-end items-center gap-4 sm:gap-6">
|
||||
{user && (
|
||||
<span className="text-white text-base font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Welcome, {user.first_name}
|
||||
@@ -39,7 +41,7 @@ const Navbar = () => {
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="admin-nav-button"
|
||||
>
|
||||
Admin Panel
|
||||
Dashboard
|
||||
</button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -53,7 +55,7 @@ const Navbar = () => {
|
||||
</button>
|
||||
<Link to="/donate">
|
||||
<Button
|
||||
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
|
||||
className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-[25px] px-[54px] py-[10px] text-[16.5px] font-semibold h-[41px]"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
Donate
|
||||
@@ -62,7 +64,7 @@ const Navbar = () => {
|
||||
</header>
|
||||
|
||||
{/* Main Header - Member Navigation */}
|
||||
<header className="bg-[#664fa3] px-4 sm:px-8 md:px-16 py-2 flex justify-between items-center">
|
||||
<header className="bg-[var(--purple-lavender)] px-4 sm:px-8 md:px-16 py-2 flex justify-between items-center">
|
||||
<Link to="/dashboard">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
||||
</Link>
|
||||
@@ -84,21 +86,21 @@ const Navbar = () => {
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
History
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Mission and Values
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Board of Directors
|
||||
</Link>
|
||||
@@ -110,7 +112,7 @@ const Navbar = () => {
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
My Profile
|
||||
</Link>
|
||||
<Link
|
||||
to="/events"
|
||||
@@ -149,41 +151,34 @@ const Navbar = () => {
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Newsletters
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/financials" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/members/financials" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] 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"
|
||||
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Bylaws
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Link
|
||||
to="/profile"
|
||||
className="text-white text-[17.5px] font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="profile-nav-button"
|
||||
>
|
||||
Profile
|
||||
</Link>
|
||||
|
||||
</nav>
|
||||
|
||||
{/* Mobile Hamburger Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="lg:hidden p-2 text-white hover:bg-background/10 rounded-lg transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="h-6 w-6" />
|
||||
@@ -200,7 +195,7 @@ const Navbar = () => {
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-gradient-to-b from-[#664fa3] to-[#48286e] shadow-2xl flex flex-col">
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-gradient-to-b from-[var(--purple-lavender)] to-[var(--purple-deep)] shadow-2xl flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-white/20">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -211,7 +206,7 @@ const Navbar = () => {
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="p-2 text-white hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="p-2 text-white hover:bg-background/10 rounded-lg transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
@@ -231,12 +226,12 @@ const Navbar = () => {
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex-1 overflow-y-auto py-6 px-4">
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-dashboard py-6 px-4">
|
||||
<div className="space-y-2">
|
||||
<Link
|
||||
to="/"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Home
|
||||
@@ -250,7 +245,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/about/history"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
History
|
||||
@@ -258,7 +253,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/about/mission-values"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Mission and Values
|
||||
@@ -266,7 +261,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/about/board"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Board of Directors
|
||||
@@ -276,7 +271,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/dashboard"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
@@ -285,7 +280,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/events"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="mobile-events-nav-button"
|
||||
>
|
||||
@@ -295,7 +290,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/members/calendar"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Calendar
|
||||
@@ -304,7 +299,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/members/directory"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Directory
|
||||
@@ -313,7 +308,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/members/gallery"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Gallery
|
||||
@@ -327,7 +322,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/members/newsletters"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block px-6 py-2 text-white text-base hover:bg-white/10 rounded-lg transition-colors"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Newsletters
|
||||
@@ -335,7 +330,7 @@ const Navbar = () => {
|
||||
<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"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Financials
|
||||
@@ -343,7 +338,7 @@ const Navbar = () => {
|
||||
<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"
|
||||
className="block px-6 py-2 text-white text-base hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Bylaws
|
||||
@@ -353,7 +348,7 @@ const Navbar = () => {
|
||||
<Link
|
||||
to="/profile"
|
||||
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-4 py-3 text-white text-base font-medium hover:bg-background/10 rounded-lg transition-colors"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="mobile-profile-nav-button"
|
||||
>
|
||||
@@ -370,10 +365,10 @@ const Navbar = () => {
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<Button
|
||||
className="w-full bg-white/20 hover:bg-white/30 text-white rounded-lg"
|
||||
className="w-full bg-background/20 hover:bg-background/30 text-white rounded-lg"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Admin Panel
|
||||
Dashboard
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
@@ -383,7 +378,7 @@ const Navbar = () => {
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
>
|
||||
<Button
|
||||
className="w-full bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-lg font-semibold"
|
||||
className="w-full bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-lg font-semibold"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
Donate
|
||||
@@ -396,7 +391,7 @@ const Navbar = () => {
|
||||
handleLogout();
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full border-2 border-white/30 text-white hover:bg-white/10 rounded-lg"
|
||||
className="w-full border-2 border-white/30 text-white hover:bg-background/10 rounded-lg"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
data-testid="mobile-logout-button"
|
||||
>
|
||||
|
||||
151
src/components/PasswordConfirmDialog.js
Normal file
151
src/components/PasswordConfirmDialog.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Shield, Eye, EyeOff, Loader2 } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* PasswordConfirmDialog - Dialog requiring admin password re-entry for sensitive actions
|
||||
*/
|
||||
const PasswordConfirmDialog = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
title = 'Confirm Your Identity',
|
||||
description = 'Please enter your password to proceed with this action.',
|
||||
loading = false,
|
||||
}) => {
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
if (!password.trim()) {
|
||||
setError('Password is required');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await onConfirm(password);
|
||||
setPassword('');
|
||||
} catch (err) {
|
||||
setError(err.message || 'Invalid password');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (isOpen) => {
|
||||
if (!isOpen) {
|
||||
setPassword('');
|
||||
setError(null);
|
||||
}
|
||||
onOpenChange(isOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="bg-background rounded-2xl border border-[var(--neutral-800)] p-0 overflow-hidden max-w-md">
|
||||
<DialogHeader className="bg-brand-purple text-white px-6 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Shield className="h-6 w-6" />
|
||||
<div>
|
||||
<DialogTitle
|
||||
className="text-lg font-semibold text-white"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription
|
||||
className="text-white/80 text-sm"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{description}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="password"
|
||||
className="text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Your Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
className="border-[var(--neutral-800)] pr-10"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-brand-purple hover:text-[var(--purple-ink)]"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p
|
||||
className="text-sm text-red-500"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex-row gap-3 justify-end pt-4 border-t border-[var(--neutral-800)]">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => handleOpenChange(false)}
|
||||
disabled={loading}
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading || !password.trim()}
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
'Confirm'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default PasswordConfirmDialog;
|
||||
@@ -156,13 +156,13 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
if (!user) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-white rounded-2xl">
|
||||
<Dialog open={open} onOpenChange={onOpenChange} className="">
|
||||
<DialogContent className="sm:max-w-[600px] bg-background rounded-2xl overflow-y-auto max-h-[90vh] p-6">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Activate Manual Payment
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Record offline payment for {user.first_name} {user.last_name} ({user.email})
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -170,7 +170,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
<form onSubmit={handleSubmit} className="space-y-6 py-4">
|
||||
{/* Subscription Plan Selection */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="plan_id" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="plan_id" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subscription Plan
|
||||
</Label>
|
||||
<Select
|
||||
@@ -187,7 +187,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select subscription plan" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -203,7 +203,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{selectedPlan && (
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan.description || `${selectedPlan.billing_cycle} subscription`}
|
||||
</p>
|
||||
)}
|
||||
@@ -211,7 +211,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Payment Amount */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="amount" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="amount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Amount ($)
|
||||
</Label>
|
||||
<Input
|
||||
@@ -221,12 +221,12 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
min="0"
|
||||
placeholder="Enter amount"
|
||||
value={formData.amount}
|
||||
onChange={(e) => setFormData({...formData, amount: e.target.value})}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
onChange={(e) => setFormData({ ...formData, amount: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required
|
||||
/>
|
||||
{selectedPlan && (
|
||||
<p className="text-xs text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)}
|
||||
</p>
|
||||
)}
|
||||
@@ -234,14 +234,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Breakdown Display */}
|
||||
{breakdown && breakdown.total >= breakdown.base && (
|
||||
<Card className="p-4 bg-[#f9f5ff] border border-[#DDD8EB]">
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
|
||||
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex justify-between text-[#422268]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)]">
|
||||
<span>Membership Fee:</span>
|
||||
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
|
||||
</div>
|
||||
{breakdown.donation > 0 && (
|
||||
<div className="flex justify-between text-[#ff9e77]">
|
||||
<div className="flex justify-between text-[var(--orange-light)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
Additional Donation:
|
||||
@@ -249,7 +249,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-[#422268] font-bold text-base pt-2 border-t border-[#DDD8EB]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
|
||||
<span>Total:</span>
|
||||
<span>{formatPrice(breakdown.total)}</span>
|
||||
</div>
|
||||
@@ -259,17 +259,17 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Payment Date */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_date" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="payment_date" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Date
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-[#664fa3]" />
|
||||
<Calendar className="absolute left-4 top-1/2 transform -translate-y-1/2 h-5 w-5 text-brand-purple " />
|
||||
<Input
|
||||
id="payment_date"
|
||||
type="date"
|
||||
value={formData.payment_date}
|
||||
onChange={(e) => setFormData({...formData, payment_date: e.target.value})}
|
||||
className="pl-12 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
onChange={(e) => setFormData({ ...formData, payment_date: e.target.value })}
|
||||
className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
@@ -277,14 +277,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Payment Method */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="payment_method" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="payment_method" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Method
|
||||
</Label>
|
||||
<Select
|
||||
value={formData.payment_method}
|
||||
onValueChange={(value) => setFormData({...formData, payment_method: value})}
|
||||
onValueChange={(value) => setFormData({ ...formData, payment_method: value })}
|
||||
>
|
||||
<SelectTrigger className="rounded-xl border-2 border-[#ddd8eb]">
|
||||
<SelectTrigger className="rounded-xl border-2 border-[var(--neutral-800)]">
|
||||
<SelectValue placeholder="Select payment method" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
@@ -298,7 +298,7 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Subscription Period */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>Subscription Period</Label>
|
||||
<Label className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>Subscription Period</Label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
@@ -306,9 +306,9 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
id="use_custom_period"
|
||||
checked={useCustomPeriod}
|
||||
onChange={(e) => setUseCustomPeriod(e.target.checked)}
|
||||
className="rounded border-[#ddd8eb]"
|
||||
className="rounded border-[var(--neutral-800)]"
|
||||
/>
|
||||
<Label htmlFor="use_custom_period" className="text-sm text-[#664fa3] font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Label htmlFor="use_custom_period" className="text-sm text-brand-purple font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Use custom dates instead of plan's billing cycle
|
||||
</Label>
|
||||
</div>
|
||||
@@ -316,39 +316,39 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
{useCustomPeriod ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_period_start" className="text-sm text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="custom_period_start" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Start Date
|
||||
</Label>
|
||||
<Input
|
||||
id="custom_period_start"
|
||||
type="date"
|
||||
value={formData.custom_period_start}
|
||||
onChange={(e) => setFormData({...formData, custom_period_start: e.target.value})}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
onChange={(e) => setFormData({ ...formData, custom_period_start: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required={useCustomPeriod}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="custom_period_end" className="text-sm text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="custom_period_end" className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
End Date
|
||||
</Label>
|
||||
<Input
|
||||
id="custom_period_end"
|
||||
type="date"
|
||||
value={formData.custom_period_end}
|
||||
onChange={(e) => setFormData({...formData, custom_period_end: e.target.value})}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
onChange={(e) => setFormData({ ...formData, custom_period_end: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
required={useCustomPeriod}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
selectedPlan && (
|
||||
<div className="text-sm text-[#664fa3] bg-[#f1eef9] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple bg-[var(--lavender-300)] p-3 rounded-lg space-y-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan.custom_cycle_enabled ? (
|
||||
<>
|
||||
<p>
|
||||
<span className="font-medium text-[#422268]">Plan uses custom billing cycle:</span>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Plan uses custom billing cycle:</span>
|
||||
<br />
|
||||
{(() => {
|
||||
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
@@ -367,8 +367,8 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
<br />
|
||||
Starts today, ends {selectedPlan.billing_cycle === 'monthly' ? '30 days' :
|
||||
selectedPlan.billing_cycle === 'quarterly' ? '90 days' :
|
||||
selectedPlan.billing_cycle === 'yearly' ? '1 year' :
|
||||
selectedPlan.billing_cycle === 'lifetime' ? 'lifetime' : '1 year'} from now
|
||||
selectedPlan.billing_cycle === 'yearly' ? '1 year' :
|
||||
selectedPlan.billing_cycle === 'lifetime' ? 'lifetime' : '1 year'} from now
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -378,15 +378,15 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
|
||||
{/* Notes */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="notes" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="notes" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Notes (Optional)
|
||||
</Label>
|
||||
<Textarea
|
||||
id="notes"
|
||||
placeholder="Additional notes about the payment..."
|
||||
value={formData.notes}
|
||||
onChange={(e) => setFormData({...formData, notes: e.target.value})}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3] min-h-[100px]"
|
||||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple min-h-[100px]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -395,14 +395,14 @@ const PaymentActivationDialog = ({ open, onOpenChange, user, onSuccess }) => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-full border-2 border-[#ddd8eb]"
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#81B29A] text-white hover:bg-[#6FA087] rounded-full"
|
||||
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)] rounded-full"
|
||||
>
|
||||
{loading ? 'Activating...' : 'Activate Payment'}
|
||||
</Button>
|
||||
|
||||
186
src/components/PaymentMethodCard.js
Normal file
186
src/components/PaymentMethodCard.js
Normal file
@@ -0,0 +1,186 @@
|
||||
import React from 'react';
|
||||
import { CreditCard, Trash2, Star, Banknote, Building2, FileCheck } from 'lucide-react';
|
||||
import { Button } from './ui/button';
|
||||
|
||||
/**
|
||||
* Card brand icon mapping
|
||||
*/
|
||||
const getBrandIcon = (brand) => {
|
||||
const brandLower = brand?.toLowerCase();
|
||||
// Return text abbreviation for known brands
|
||||
switch (brandLower) {
|
||||
case 'visa':
|
||||
return 'VISA';
|
||||
case 'mastercard':
|
||||
return 'MC';
|
||||
case 'amex':
|
||||
case 'american_express':
|
||||
return 'AMEX';
|
||||
case 'discover':
|
||||
return 'DISC';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon for payment method type
|
||||
*/
|
||||
const getPaymentTypeIcon = (paymentType) => {
|
||||
switch (paymentType) {
|
||||
case 'cash':
|
||||
return Banknote;
|
||||
case 'bank_transfer':
|
||||
return Building2;
|
||||
case 'check':
|
||||
return FileCheck;
|
||||
default:
|
||||
return CreditCard;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format payment type for display
|
||||
*/
|
||||
const formatPaymentType = (paymentType) => {
|
||||
switch (paymentType) {
|
||||
case 'cash':
|
||||
return 'Cash';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'check':
|
||||
return 'Check';
|
||||
case 'card':
|
||||
return 'Card';
|
||||
default:
|
||||
return paymentType;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* PaymentMethodCard - Displays a single payment method
|
||||
*/
|
||||
const PaymentMethodCard = ({
|
||||
method,
|
||||
onSetDefault,
|
||||
onDelete,
|
||||
loading = false,
|
||||
showActions = true,
|
||||
}) => {
|
||||
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
|
||||
const brandAbbr = method.card_brand ? getBrandIcon(method.card_brand) : null;
|
||||
const isExpired = method.card_exp_year && method.card_exp_month &&
|
||||
new Date(method.card_exp_year, method.card_exp_month) < new Date();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between p-4 border rounded-xl ${
|
||||
method.is_default
|
||||
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||
: 'border-[var(--neutral-800)] bg-white'
|
||||
} ${isExpired ? 'opacity-70' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
{/* Payment Method Icon */}
|
||||
<div className={`p-3 rounded-full ${
|
||||
method.is_default
|
||||
? 'bg-brand-purple text-white'
|
||||
: 'bg-[var(--lavender-300)] text-brand-purple'
|
||||
}`}>
|
||||
<PaymentIcon className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
{/* Payment Method Details */}
|
||||
<div>
|
||||
{method.payment_type === 'card' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
{brandAbbr && (
|
||||
<span className="text-xs font-bold text-[var(--purple-ink)] bg-[var(--lavender-300)] px-2 py-0.5 rounded">
|
||||
{brandAbbr}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{method.card_brand ? method.card_brand.charAt(0).toUpperCase() + method.card_brand.slice(1) : 'Card'} •••• {method.card_last4 || '****'}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className={`text-sm ${isExpired ? 'text-red-500' : 'text-brand-purple'}`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{isExpired ? 'Expired' : 'Expires'} {method.card_exp_month?.toString().padStart(2, '0')}/{method.card_exp_year?.toString().slice(-2)}
|
||||
{method.card_funding && (
|
||||
<span className="ml-2 text-xs capitalize">({method.card_funding})</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPaymentType(method.payment_type)}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{method.manual_notes && (
|
||||
<p
|
||||
className="text-sm text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{method.manual_notes}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
{showActions && (
|
||||
<div className="flex items-center gap-2">
|
||||
{!method.is_default && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onSetDefault?.(method.id)}
|
||||
disabled={loading}
|
||||
className="border border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg text-xs px-3"
|
||||
>
|
||||
Set Default
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onDelete?.(method.id)}
|
||||
disabled={loading}
|
||||
className="border border-red-500 text-red-500 hover:bg-red-50 rounded-lg p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodCard;
|
||||
309
src/components/PaymentMethodsSection.js
Normal file
309
src/components/PaymentMethodsSection.js
Normal file
@@ -0,0 +1,309 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { Card } from './ui/card';
|
||||
import { Button } from './ui/button';
|
||||
import { CreditCard, Plus, Loader2, AlertCircle } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../utils/api';
|
||||
import useStripeConfig from '../hooks/use-stripe-config';
|
||||
import PaymentMethodCard from './PaymentMethodCard';
|
||||
import AddPaymentMethodDialog from './AddPaymentMethodDialog';
|
||||
import ConfirmationDialog from './ConfirmationDialog';
|
||||
|
||||
/**
|
||||
* PaymentMethodsSection - Manages user payment methods
|
||||
*
|
||||
* Features:
|
||||
* - List saved payment methods
|
||||
* - Add new payment method via Stripe SetupIntent
|
||||
* - Set default payment method
|
||||
* - Delete payment methods
|
||||
*/
|
||||
const PaymentMethodsSection = () => {
|
||||
const [paymentMethods, setPaymentMethods] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Get Stripe configuration from API
|
||||
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
|
||||
|
||||
// Dialog states
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [clientSecret, setClientSecret] = useState(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [methodToDelete, setMethodToDelete] = useState(null);
|
||||
|
||||
/**
|
||||
* Fetch payment methods from API
|
||||
*/
|
||||
const fetchPaymentMethods = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get('/payment-methods');
|
||||
setPaymentMethods(response.data);
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to fetch payment methods:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPaymentMethods();
|
||||
}, [fetchPaymentMethods]);
|
||||
|
||||
/**
|
||||
* Create SetupIntent and open add dialog
|
||||
*/
|
||||
const handleAddNew = async () => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
const response = await api.post('/payment-methods/setup-intent');
|
||||
setClientSecret(response.data.client_secret);
|
||||
setAddDialogOpen(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
|
||||
toast.error(errorMessage);
|
||||
console.error('Failed to create setup intent:', err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle successful payment method addition
|
||||
*/
|
||||
const handleAddSuccess = () => {
|
||||
setAddDialogOpen(false);
|
||||
setClientSecret(null);
|
||||
fetchPaymentMethods();
|
||||
};
|
||||
|
||||
/**
|
||||
* Set a payment method as default
|
||||
*/
|
||||
const handleSetDefault = async (methodId) => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await api.put(`/payment-methods/${methodId}/default`);
|
||||
toast.success('Default payment method updated');
|
||||
fetchPaymentMethods();
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to update default payment method';
|
||||
toast.error(errorMessage);
|
||||
console.error('Failed to set default:', err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open delete confirmation dialog
|
||||
*/
|
||||
const handleDeleteClick = (methodId) => {
|
||||
setMethodToDelete(methodId);
|
||||
setDeleteConfirmOpen(true);
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm and delete payment method
|
||||
*/
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!methodToDelete) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await api.delete(`/payment-methods/${methodToDelete}`);
|
||||
toast.success('Payment method removed');
|
||||
setDeleteConfirmOpen(false);
|
||||
setMethodToDelete(null);
|
||||
fetchPaymentMethods();
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
|
||||
toast.error(errorMessage);
|
||||
console.error('Failed to delete payment method:', err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stripe Elements options - simplified for CardElement
|
||||
const elementsOptions = {
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#6b5b95',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#2d2a4a',
|
||||
colorDanger: '#ef4444',
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
borderRadius: '12px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="space-y-4 px-6 pb-6">
|
||||
{/* Header */}
|
||||
<div className="bg-brand-purple text-white px-4 py-3 rounded-t-lg -mx-6 -mt-0 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5" />
|
||||
<h3
|
||||
className="font-semibold"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Payment Methods
|
||||
</h3>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddNew}
|
||||
disabled={actionLoading}
|
||||
size="sm"
|
||||
className="bg-white text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg px-3 py-1"
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
||||
<span
|
||||
className="ml-2 text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Loading payment methods...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||||
<p
|
||||
className="text-sm text-red-600"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchPaymentMethods}
|
||||
className="ml-auto border-red-500 text-red-500 hover:bg-red-50 rounded-lg"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Methods List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.length === 0 ? (
|
||||
<div className="text-center py-8">
|
||||
<CreditCard className="h-12 w-12 text-[var(--lavender-500)] mx-auto mb-3" />
|
||||
<p
|
||||
className="text-brand-purple mb-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
No payment methods saved
|
||||
</p>
|
||||
<p
|
||||
className="text-sm text-brand-purple/70 mb-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Add a card to make payments easier
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddNew}
|
||||
disabled={actionLoading}
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-6"
|
||||
>
|
||||
{actionLoading ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
Setting up...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Payment Method
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
paymentMethods.map((method) => (
|
||||
<PaymentMethodCard
|
||||
key={method.id}
|
||||
method={method}
|
||||
onSetDefault={handleSetDefault}
|
||||
onDelete={handleDeleteClick}
|
||||
loading={actionLoading}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Info Text */}
|
||||
{!loading && paymentMethods.length > 0 && (
|
||||
<p
|
||||
className="text-xs text-brand-purple/70 pt-2 border-t border-[var(--neutral-800)]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Your default payment method will be used for subscription renewals and donations.
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add Payment Method Dialog */}
|
||||
{clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={elementsOptions}>
|
||||
<AddPaymentMethodDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAddDialogOpen(open);
|
||||
if (!open) setClientSecret(null);
|
||||
}}
|
||||
onSuccess={handleAddSuccess}
|
||||
clientSecret={clientSecret}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Remove Payment Method"
|
||||
description="Are you sure you want to remove this payment method? This action cannot be undone."
|
||||
confirmText="Remove"
|
||||
cancelText="Cancel"
|
||||
variant="danger"
|
||||
loading={actionLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PaymentMethodsSection;
|
||||
@@ -73,9 +73,9 @@ const PendingInvitationsTable = () => {
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
|
||||
member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
|
||||
superadmin: { label: 'Superadmin', className: 'bg-brand-purple text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
|
||||
member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }
|
||||
};
|
||||
|
||||
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
|
||||
@@ -111,7 +111,7 @@ const PendingInvitationsTable = () => {
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Loading invitations...
|
||||
</p>
|
||||
</div>
|
||||
@@ -120,12 +120,12 @@ const PendingInvitationsTable = () => {
|
||||
|
||||
if (invitations.length === 0) {
|
||||
return (
|
||||
<Card className="p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||
<Mail 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" }}>
|
||||
<Card className="p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
<Mail className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Pending Invitations
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
All invitations have been accepted or expired
|
||||
</p>
|
||||
</Card>
|
||||
@@ -134,37 +134,37 @@ const PendingInvitationsTable = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="bg-white rounded-2xl border border-[#ddd8eb] overflow-hidden">
|
||||
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#DDD8EB] hover:bg-[#DDD8EB]">
|
||||
<TableHead className="text-[#422268] font-semibold">Email</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold">Name</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold">Role</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold">Invited</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold">Expires</TableHead>
|
||||
<TableHead className="text-[#422268] font-semibold text-right">Actions</TableHead>
|
||||
<TableRow className="bg-[var(--neutral-800)] hover:bg-[var(--neutral-800)]">
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Email</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Name</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Role</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Invited</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold">Expires</TableHead>
|
||||
<TableHead className="text-[var(--purple-ink)] font-semibold text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{invitations.map((invitation) => (
|
||||
<TableRow key={invitation.id} className="hover:bg-[#F9F8FB]">
|
||||
<TableCell className="font-medium text-[#422268]">
|
||||
<TableRow key={invitation.id} className="hover:bg-[var(--lavender-700)]">
|
||||
<TableCell className="font-medium text-[var(--purple-ink)]">
|
||||
{invitation.email}
|
||||
</TableCell>
|
||||
<TableCell className="text-[#664fa3]">
|
||||
<TableCell className="text-brand-purple ">
|
||||
{invitation.first_name && invitation.last_name
|
||||
? `${invitation.first_name} ${invitation.last_name}`
|
||||
: '-'}
|
||||
</TableCell>
|
||||
<TableCell>{getRoleBadge(invitation.role)}</TableCell>
|
||||
<TableCell className="text-[#664fa3]">
|
||||
<TableCell className="text-brand-purple ">
|
||||
{new Date(invitation.invited_at).toLocaleDateString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-[#664fa3]'}`} />
|
||||
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-[#664fa3]'}`}>
|
||||
<Clock className={`h-4 w-4 ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500' : 'text-brand-purple '}`} />
|
||||
<span className={`text-sm ${isExpiringSoon(invitation.expires_at) ? 'text-orange-500 font-semibold' : 'text-brand-purple '}`}>
|
||||
{formatDate(invitation.expires_at)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -176,7 +176,7 @@ const PendingInvitationsTable = () => {
|
||||
size="sm"
|
||||
onClick={() => handleResend(invitation.id)}
|
||||
disabled={resending === invitation.id}
|
||||
className="rounded-xl border-[#81B29A] text-[#81B29A] hover:bg-[#81B29A] hover:text-white"
|
||||
className="rounded-xl border-[var(--green-light)] text-[var(--green-light)] hover:bg-[var(--green-light)] hover:text-white"
|
||||
>
|
||||
{resending === invitation.id ? (
|
||||
'Resending...'
|
||||
@@ -208,10 +208,10 @@ const PendingInvitationsTable = () => {
|
||||
<AlertDialog open={revokeDialog.open} onOpenChange={(open) => setRevokeDialog({ open, invitation: null })}>
|
||||
<AlertDialogContent className="rounded-2xl">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<AlertDialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Revoke Invitation
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<AlertDialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Are you sure you want to revoke the invitation for{' '}
|
||||
<span className="font-semibold">{revokeDialog.invitation?.email}</span>?
|
||||
This action cannot be undone.
|
||||
|
||||
@@ -118,7 +118,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
// Validate custom cycle dates if enabled
|
||||
if (formData.custom_cycle_enabled) {
|
||||
if (!formData.custom_cycle_start_month || !formData.custom_cycle_start_day ||
|
||||
!formData.custom_cycle_end_month || !formData.custom_cycle_end_day) {
|
||||
!formData.custom_cycle_end_month || !formData.custom_cycle_end_day) {
|
||||
toast.error('All custom cycle dates must be provided');
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -159,12 +159,12 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="sm:max-w-[700px] max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plan ? 'Edit Plan' : 'Create New Plan'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{plan ? 'Update plan details below' : 'Enter plan details to create a new subscription plan'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -197,8 +197,8 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
</div>
|
||||
|
||||
{/* Dynamic Pricing */}
|
||||
<div className="border-2 border-[#DDD8EB] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="border-2 border-[var(--neutral-800)] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Dynamic Pricing
|
||||
</h3>
|
||||
|
||||
@@ -216,7 +216,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
required
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-[#664fa3] mt-1">Minimum $30</p>
|
||||
<p className="text-xs text-brand-purple mt-1">Minimum $30</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -232,7 +232,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
required
|
||||
className="mt-2"
|
||||
/>
|
||||
<p className="text-xs text-[#664fa3] mt-1">Pre-filled amount</p>
|
||||
<p className="text-xs text-brand-purple mt-1">Pre-filled amount</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -240,7 +240,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div>
|
||||
<Label htmlFor="allow_donation">Allow Donations</Label>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Members can pay more than minimum
|
||||
</p>
|
||||
</div>
|
||||
@@ -252,7 +252,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
onChange={(e) => setFormData({ ...formData, allow_donation: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#664fa3]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#81B29A]"></div>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-purple /20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,11 +279,11 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
|
||||
{/* Custom Billing Cycle Dates */}
|
||||
{formData.billing_cycle === 'custom' && (
|
||||
<div className="border-2 border-[#DDD8EB] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="border-2 border-[var(--neutral-800)] rounded-lg p-4 space-y-4">
|
||||
<h3 className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Custom Billing Period
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Set recurring date range (e.g., Jan 1 - Dec 31 for calendar year)
|
||||
</p>
|
||||
|
||||
@@ -349,8 +349,8 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f9f5ff] border border-[#DDD8EB] rounded p-3">
|
||||
<p className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="bg-[var(--lavender-400)] border border-[var(--neutral-800)] rounded p-3">
|
||||
<p className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Example:</strong> Jan 1 - Dec 31 for calendar year, or Jul 1 - Jun 30 for fiscal year
|
||||
</p>
|
||||
</div>
|
||||
@@ -361,7 +361,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<Label htmlFor="active">Active Status</Label>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Inactive plans won't appear for new subscriptions
|
||||
</p>
|
||||
</div>
|
||||
@@ -373,7 +373,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
onChange={(e) => setFormData({ ...formData, active: e.target.checked })}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-[#664fa3]/20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[#81B29A]"></div>
|
||||
<div className="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-brand-purple /20 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-background after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-[var(--green-light)]"></div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -389,7 +389,7 @@ const PlanDialog = ({ open, onOpenChange, plan, onSuccess }) => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Button } from './ui/button';
|
||||
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||
|
||||
const PublicFooter = () => {
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
const { getLogoUrl } = useThemeConfig();
|
||||
const loafLogo = getLogoUrl();
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Main Footer */}
|
||||
<footer className="bg-[#644c9f] border-t border-[rgba(0,0,0,0.1)] py-8 px-2 lg:px-10 flex items-center justify-between">
|
||||
<footer className="bg-[var(--purple-amethyst)] border-t border-[rgba(0,0,0,0.1)] py-8 px-2 lg:px-10 flex items-center justify-between">
|
||||
<div className=" flex flex-col md:flex-row gap-14 md:gap-2 lg:gap-32 xl:gap-40 items-center justify-center text-left md:justify-between w-full max-w-7xl mx-auto">
|
||||
<div className="w-40 sm:w-40 md:w-48 lg:w-[180px] flex-shrink-0">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="w-full h-auto aspect-square object-contain" />
|
||||
@@ -19,28 +21,28 @@ const PublicFooter = () => {
|
||||
<div className="pb-2 lg:pb-4">
|
||||
<p className="text-white text-xl font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>About</p>
|
||||
</div>
|
||||
<Link to="/about/history" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>History</Link>
|
||||
<Link to="/about/mission-values" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Mission and Values</Link>
|
||||
<Link to="/about/board" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Board of Directors</Link>
|
||||
<Link to="/about/history" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>History</Link>
|
||||
<Link to="/about/mission-values" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Mission and Values</Link>
|
||||
<Link to="/about/board" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Board of Directors</Link>
|
||||
</div>
|
||||
<div className="hidden md:flex flex-col gap-2 items-start text-left w-full sm:w-auto sm:min-w-[148px]">
|
||||
<div className="pb-2 lg:pb-4">
|
||||
<p className="text-white text-xl font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>Connect</p>
|
||||
</div>
|
||||
<Link to="/become-a-member" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Become a Member</Link>
|
||||
<Link to="/contact-us" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Contact Us</Link>
|
||||
<Link to="/resources" className="text-[#ddd8eb] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Resources</Link>
|
||||
<Link to="/become-a-member" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Become a Member</Link>
|
||||
<Link to="/contact-us" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Contact Us</Link>
|
||||
<Link to="/resources" className="text-[var(--neutral-800)] text-sm sm:text-base font-medium hover:text-white transition-colors" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Resources</Link>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2 items-center justify-center md:items-start text-left w-full sm:w-auto sm:min-w-[200px] md:min-w-[200px] lg:min-w-[220px]">
|
||||
<div className="pb-4 w-full flex justify-center lg:justify-start">
|
||||
<Link to="/donate" className="block">
|
||||
<Button className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-full px-12 lg:px-16 py-6 text-lg sm:text-lg font-medium ">
|
||||
<Button className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-full px-12 lg:px-16 py-6 text-lg sm:text-lg font-medium ">
|
||||
Donate
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-[#ddd8eb] text-sm sm:text-base font-medium text-center md:text-left w-full" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--neutral-800)] text-sm sm:text-base font-medium text-center md:text-left w-full" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF is supported by<br />the Hollyfield Foundation
|
||||
</p>
|
||||
</div>
|
||||
@@ -49,20 +51,20 @@ const PublicFooter = () => {
|
||||
</footer>
|
||||
|
||||
{/* Bottom Footer */}
|
||||
<footer className="bg-gradient-to-r from-[#48286e] to-[#644c9f] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-20 py-5">
|
||||
<footer className="bg-gradient-to-r from-[var(--purple-deep)] to-[var(--purple-amethyst)] border-t border-[rgba(0,0,0,0.1)] px-4 sm:px-8 md:px-20 py-5">
|
||||
<div className="flex flex-col sm:flex-row gap-4 sm:gap-6 justify-between items-center max-w-7xl mx-auto">
|
||||
<nav className="flex flex-col sm:flex-row gap-4 sm:gap-8 items-center order-1 sm:order-none">
|
||||
<Link to="/terms-of-service" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Link to="/terms-of-service" className="text-[var(--neutral-500)] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Terms of Service
|
||||
</Link>
|
||||
<Link to="/privacy-policy" className="text-[#c5b4e3] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Link to="/privacy-policy" className="text-[var(--neutral-500)] text-sm sm:text-base font-medium hover:text-white transition-colors whitespace-nowrap" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Privacy Policy
|
||||
</Link>
|
||||
</nav>
|
||||
<p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
© 2025 LOAF. All Rights Reserved.
|
||||
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-2 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
© {new Date().getFullYear()} LOAF. All Rights Reserved.
|
||||
</p>
|
||||
<p className="text-[#c5b4e3] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--neutral-500)] text-sm sm:text-base font-medium text-center order-3 sm:order-none" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Designed and Managed by{' '}
|
||||
<a href="https://konceptkit.com/" className=" text-white transition-colors whitespace-nowrap">
|
||||
Koncept Kit
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Link, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Button } from './ui/button';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||
import { ChevronDown, Menu, X } from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -12,6 +13,7 @@ import {
|
||||
|
||||
const PublicNavbar = () => {
|
||||
const { user, logout } = useAuth();
|
||||
const { getLogoUrl } = useThemeConfig();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
|
||||
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
|
||||
return location.pathname.startsWith('/about');
|
||||
};
|
||||
|
||||
// LOAF logo (local)
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
// Get logo URL from theme config (with fallback to default)
|
||||
const loafLogo = getLogoUrl();
|
||||
|
||||
const handleAuthAction = () => {
|
||||
if (user) {
|
||||
@@ -46,7 +48,7 @@ const PublicNavbar = () => {
|
||||
const getDesktopLinkClasses = (path) => {
|
||||
const baseClasses = "text-[17.5px] font-medium transition-all px-3 py-1 rounded-md";
|
||||
if (isActive(path)) {
|
||||
return `${baseClasses} text-[#ff9e77] hover:text-[#ff8c64] `;
|
||||
return `${baseClasses} text-[var(--orange-light)] hover:text-[var(--orange-coral)] `;
|
||||
}
|
||||
return `${baseClasses} text-white hover:opacity-80`;
|
||||
};
|
||||
@@ -55,18 +57,18 @@ const PublicNavbar = () => {
|
||||
const getMobileLinkClasses = (path) => {
|
||||
const baseClasses = "text-base font-medium px-4 py-3 rounded-md transition-colors";
|
||||
if (isActive(path)) {
|
||||
return `${baseClasses} bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e]`;
|
||||
return `${baseClasses} bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)]`;
|
||||
}
|
||||
return `${baseClasses} text-white hover:bg-[#48286e]`;
|
||||
return `${baseClasses} text-white hover:bg-[var(--purple-deep)]`;
|
||||
};
|
||||
|
||||
// Active and inactive link styles for mobile sub-items (About Us)
|
||||
const getMobileSubLinkClasses = (path) => {
|
||||
const baseClasses = "text-sm font-medium px-6 py-2 rounded-md transition-colors block";
|
||||
if (isActive(path)) {
|
||||
return `${baseClasses} bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e]`;
|
||||
return `${baseClasses} bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)]`;
|
||||
}
|
||||
return `${baseClasses} text-[#ddd8eb] hover:bg-[#48286e] hover:text-white`;
|
||||
return `${baseClasses} text-[var(--neutral-800)] hover:bg-[var(--purple-deep)] hover:text-white`;
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -74,9 +76,25 @@ const PublicNavbar = () => {
|
||||
{/* Top Header - Auth Actions */}
|
||||
<div className='sticky top-0 inset-x-0 z-50'>
|
||||
|
||||
<header className="bg-gradient-to-r flex-wrap from-[#644c9f] to-[#48286e] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
|
||||
<div className='flex gap-4 sm:gap-6'>
|
||||
|
||||
<header className="bg-gradient-to-r flex-wrap from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-[20px] py-[10px] flex md:justify-end justify-between items-center gap-4 sm:gap-6">
|
||||
<div className='flex gap-4 sm:gap-6 items-center'>
|
||||
{user && (
|
||||
<span
|
||||
className="text-white text-base font-medium"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Welcome, {user.first_name}
|
||||
</span>
|
||||
)}
|
||||
{(user?.role === 'admin' || user?.role === 'superadmin') && (
|
||||
<Link
|
||||
to="/admin"
|
||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={handleAuthAction}
|
||||
className="text-white text-base font-medium hover:opacity-80 transition-opacity bg-transparent border-none cursor-pointer"
|
||||
@@ -96,7 +114,7 @@ const PublicNavbar = () => {
|
||||
</div>
|
||||
<Link to="/donate">
|
||||
<Button
|
||||
className="bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-[50px] py-[5px] text-[16.5px] font-semibold h-[41px]"
|
||||
className="bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-[25px] px-[50px] py-[5px] text-[16.5px] font-semibold h-[41px]"
|
||||
style={{ fontFamily: "'Montserrat', sans-serif" }}
|
||||
>
|
||||
Donate
|
||||
@@ -105,7 +123,7 @@ const PublicNavbar = () => {
|
||||
</header>
|
||||
|
||||
{/* Main Header - Navigation */}
|
||||
<header className=" bg-[#664fa3] px-[20px] py-2 flex justify-between items-center">
|
||||
<header className=" bg-brand-purple px-[20px] py-2 flex justify-between items-center">
|
||||
<Link to="/">
|
||||
<img src={loafLogo} alt="LOAF Logo" className="h-16 w-16 sm:h-20 sm:w-20 md:h-28 md:w-28 object-contain" />
|
||||
</Link>
|
||||
@@ -113,7 +131,7 @@ const PublicNavbar = () => {
|
||||
{/* Mobile Menu Button */}
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(true)}
|
||||
className="lg:hidden p-2 text-white hover:bg-[#48286e] rounded-md transition-colors"
|
||||
className="lg:hidden p-2 text-white hover:bg-[var(--purple-deep)] rounded-md transition-colors"
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="size-14" />
|
||||
@@ -132,28 +150,28 @@ const PublicNavbar = () => {
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`${isAboutActive()
|
||||
? "text-[#ff9e77] hover:text-[#ff8c64]"
|
||||
? "text-[var(--orange-light)] hover:text-[var(--orange-coral)]"
|
||||
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
About Us
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-white min-w-[220px]">
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/history" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
History
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/mission-values" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Mission and Values
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[#48286e] hover:bg-[#f1eef9] cursor-pointer"
|
||||
<Link to="/about/board" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Board of Directors
|
||||
</Link>
|
||||
@@ -165,7 +183,7 @@ const PublicNavbar = () => {
|
||||
className={getDesktopLinkClasses(user ? "/dashboard" : "/become-a-member")}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Dashboard' : 'Become a Member'}
|
||||
{user ? 'My Profile' : 'Become a Member'}
|
||||
</Link>
|
||||
{!user && (
|
||||
<Link
|
||||
@@ -176,7 +194,71 @@ const PublicNavbar = () => {
|
||||
Members Only
|
||||
</Link>
|
||||
)}
|
||||
<Link
|
||||
{user && (
|
||||
<>
|
||||
<Link
|
||||
to="/events"
|
||||
className={getDesktopLinkClasses('/events')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/calendar"
|
||||
className={getDesktopLinkClasses('/members/calendar')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Calendar
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/directory"
|
||||
className={getDesktopLinkClasses('/members/directory')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Directory
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/gallery"
|
||||
className={getDesktopLinkClasses('/members/gallery')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
className={`${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws')
|
||||
? "text-[var(--orange-light)] hover:text-[var(--orange-coral)]"
|
||||
: "text-white hover:opacity-80"} text-[17.5px] font-medium transition-all flex items-center gap-1 bg-transparent border-none cursor-pointer px-3 py-1 rounded-md`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Documents
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="bg-background min-w-[220px]">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/newsletters" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Newsletters
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/financials" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Financials
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to="/members/bylaws" className="w-full px-3 py-2 text-[var(--purple-deep)] hover:bg-[var(--lavender-300)] cursor-pointer"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Bylaws
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
)}
|
||||
{/* <Link
|
||||
to="/resources"
|
||||
className={getDesktopLinkClasses('/resources')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
@@ -189,7 +271,7 @@ const PublicNavbar = () => {
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Contact Us
|
||||
</Link>
|
||||
</Link> */}
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
@@ -204,21 +286,33 @@ const PublicNavbar = () => {
|
||||
/>
|
||||
|
||||
{/* Drawer */}
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-[#664fa3] shadow-xl overflow-y-auto">
|
||||
<div className="fixed right-0 top-0 h-full w-[280px] bg-brand-purple shadow-xl overflow-y-auto scrollbar-dashboard">
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-6 border-b border-[#48286e]">
|
||||
<div className="flex justify-between items-center p-6 border-b border-[var(--purple-deep)]">
|
||||
<span className="text-white text-lg font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Menu
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="p-2 text-white hover:bg-[#48286e] rounded-md transition-colors"
|
||||
className="p-2 text-white hover:bg-[var(--purple-deep)] rounded-md transition-colors"
|
||||
aria-label="Close menu"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* User Info */}
|
||||
{user && (
|
||||
<div className="px-6 py-4 border-b border-[var(--purple-deep)]">
|
||||
<p className="text-white text-sm opacity-90" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Welcome,
|
||||
</p>
|
||||
<p className="text-white font-semibold text-base" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Navigation Links */}
|
||||
<nav className="flex flex-col p-6 space-y-4">
|
||||
<Link
|
||||
@@ -233,7 +327,7 @@ const PublicNavbar = () => {
|
||||
{/* About Us Section */}
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={`text-base font-semibold px-4 py-2 rounded-md ${isAboutActive() ? 'text-[#ff9e77]' : 'text-white'}`}
|
||||
className={`text-base font-semibold px-4 py-2 rounded-md ${isAboutActive() ? 'text-[var(--orange-light)]' : 'text-white'}`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
About Us
|
||||
@@ -270,7 +364,7 @@ const PublicNavbar = () => {
|
||||
className={getMobileLinkClasses(user ? "/dashboard" : "/become-a-member")}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Dashboard' : 'Become a Member'}
|
||||
{user ? 'My Profile' : 'Become a Member'}
|
||||
</Link>
|
||||
|
||||
{!user && (
|
||||
@@ -284,6 +378,80 @@ const PublicNavbar = () => {
|
||||
</Link>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<>
|
||||
<Link
|
||||
to="/events"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileLinkClasses('/events')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Events
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/members/calendar"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileLinkClasses('/members/calendar')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Calendar
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/members/directory"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileLinkClasses('/members/directory')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Directory
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
to="/members/gallery"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileLinkClasses('/members/gallery')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Gallery
|
||||
</Link>
|
||||
|
||||
{/* Documents Section */}
|
||||
<div className="space-y-2">
|
||||
<p
|
||||
className={`text-base font-semibold px-4 py-2 rounded-md ${location.pathname.startsWith('/members/newsletters') || location.pathname.startsWith('/members/financials') || location.pathname.startsWith('/members/bylaws') ? 'text-[var(--orange-light)]' : 'text-white'}`}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Documents
|
||||
</p>
|
||||
<Link
|
||||
to="/members/newsletters"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileSubLinkClasses('/members/newsletters')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Newsletters
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/financials"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileSubLinkClasses('/members/financials')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Financials
|
||||
</Link>
|
||||
<Link
|
||||
to="/members/bylaws"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className={getMobileSubLinkClasses('/members/bylaws')}
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Bylaws
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Link
|
||||
to="/resources"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
@@ -303,13 +471,23 @@ const PublicNavbar = () => {
|
||||
</Link>
|
||||
|
||||
{/* Auth Actions */}
|
||||
<div className="pt-4 border-t border-[#48286e] space-y-2">
|
||||
<div className="pt-4 border-t border-[var(--purple-deep)] space-y-2">
|
||||
{(user?.role === 'admin' || user?.role === 'superadmin') && (
|
||||
<Link
|
||||
to="/admin"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-white text-base font-medium hover:bg-[var(--purple-deep)] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
onClick={() => {
|
||||
handleAuthAction();
|
||||
setIsMobileMenuOpen(false);
|
||||
}}
|
||||
className="w-full text-left text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
className="w-full text-left text-white text-base font-medium hover:bg-[var(--purple-deep)] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{user ? 'Logout' : 'Login'}
|
||||
@@ -318,7 +496,7 @@ const PublicNavbar = () => {
|
||||
<Link
|
||||
to="/register"
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block text-white text-base font-medium hover:bg-[#48286e] px-4 py-3 rounded-md transition-colors"
|
||||
className="block text-white text-base font-medium hover:bg-[var(--purple-deep)] px-4 py-3 rounded-md transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Register
|
||||
@@ -329,7 +507,7 @@ const PublicNavbar = () => {
|
||||
onClick={() => setIsMobileMenuOpen(false)}
|
||||
className="block w-full"
|
||||
>
|
||||
<Button className="w-full bg-[#ff9e77] hover:bg-[#ff8c64] text-[#48286e] rounded-[25px] px-6 py-3 text-base font-semibold">
|
||||
<Button className="w-full bg-[var(--orange-light)] hover:bg-[var(--orange-coral)] text-[var(--purple-deep)] rounded-[25px] px-6 py-3 text-base font-semibold">
|
||||
Donate
|
||||
</Button>
|
||||
</Link>
|
||||
|
||||
@@ -31,33 +31,33 @@ export default function RejectionDialog({ open, onOpenChange, onConfirm, user, l
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<DialogContent className="sm:max-w-[500px] rounded-2xl border-2 border-[#ddd8eb]">
|
||||
<DialogContent className="sm:max-w-[500px] rounded-2xl border-2 border-[var(--neutral-800)]">
|
||||
<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" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Reject Application
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " 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" }}>
|
||||
<div className="bg-[var(--lavender-400)] border border-[var(--neutral-800)] rounded-lg p-4">
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Applicant:</strong> {user?.email}
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " 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">
|
||||
<Label htmlFor="reason" className="text-[var(--purple-ink)] font-medium">
|
||||
Rejection Reason <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
@@ -68,13 +68,13 @@ export default function RejectionDialog({ open, onOpenChange, onConfirm, user, l
|
||||
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]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] 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" }}>
|
||||
<p className="text-xs text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The applicant will receive an email with this reason.
|
||||
</p>
|
||||
</div>
|
||||
@@ -85,7 +85,7 @@ export default function RejectionDialog({ open, onOpenChange, onConfirm, user, l
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
variant="outline"
|
||||
className="border-2 border-[#ddd8eb] text-[#664fa3] hover:bg-[#f1eef9] rounded-full px-6"
|
||||
className="border-2 border-[var(--neutral-800)] text-brand-purple hover:bg-[var(--lavender-300)] rounded-full px-6"
|
||||
disabled={loading}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
|
||||
45
src/components/SettingsSidebar.js
Normal file
45
src/components/SettingsSidebar.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
import { NavLink, useLocation } from 'react-router-dom';
|
||||
import { CreditCard, Shield, Star, Palette, FileEdit, BookUser } from 'lucide-react';
|
||||
|
||||
const settingsItems = [
|
||||
{ label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
|
||||
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
|
||||
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
|
||||
{ label: 'Directory', path: '/admin/settings/directory', icon: BookUser },
|
||||
];
|
||||
|
||||
const SettingsTabs = () => {
|
||||
const location = useLocation();
|
||||
|
||||
return (
|
||||
<div className="w-full border-b border-border">
|
||||
<nav className="flex gap-1 overflow-x-auto pb-px -mb-px" aria-label="Settings tabs">
|
||||
{settingsItems.map((item) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = location.pathname === item.path;
|
||||
|
||||
return (
|
||||
<NavLink
|
||||
key={item.label}
|
||||
to={item.path}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap
|
||||
border-b-2 transition-all duration-200
|
||||
${isActive
|
||||
? 'border-primary text-primary bg-primary/5'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${isActive ? 'text-primary' : ''}`} />
|
||||
<span>{item.label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsTabs;
|
||||
66
src/components/StatCard.jsx
Normal file
66
src/components/StatCard.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React from "react";
|
||||
import { Card } from "./ui/card";
|
||||
|
||||
export const StatCard = ({
|
||||
title,
|
||||
value,
|
||||
icon: Icon,
|
||||
iconBgClass,
|
||||
dataTestId,
|
||||
}) => {
|
||||
const valueString = value == null ? "" : String(value);
|
||||
|
||||
const digitCount =
|
||||
valueString.replace(/\D/g, "").length || valueString.length;
|
||||
|
||||
const getValueFontSize = () => {
|
||||
switch (true) {
|
||||
case digitCount <= 2:
|
||||
// 3.75rem for 3 or fewer digits
|
||||
return "3.75rem";
|
||||
case digitCount <= 6:
|
||||
// Scale down for more digits
|
||||
return "clamp(2rem, 5cqi, 3rem)";
|
||||
case digitCount <= 9:
|
||||
return "clamp(1.5rem, 4cqi, 2.5rem)";
|
||||
default:
|
||||
return "clamp(1.25rem, 3cqi, 2rem)";
|
||||
}
|
||||
};
|
||||
const valueFontSize = getValueFontSize();
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="p-6 flex flex-col justify-between bg-background rounded-2xl border border-[var(--neutral-800)]"
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
<div className="flex items-start gap-4 mb-4 justify-between">
|
||||
<div
|
||||
className="space-y-8 "
|
||||
style={{
|
||||
containerType: "inline-size",
|
||||
maxWidth: "200px",
|
||||
width: "100%",
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="font-semibold text-[var(--purple-ink)] mb-1"
|
||||
style={{ fontSize: valueFontSize, lineHeight: 1 }}
|
||||
>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`${iconBgClass} px-3 py-2 rounded-lg `}>
|
||||
<Icon className="size-[valueFontSize]" />
|
||||
</div>
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-brand-purple "
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
46
src/components/StatusBadge.js
Normal file
46
src/components/StatusBadge.js
Normal file
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { Badge } from './ui/badge';
|
||||
|
||||
const STATUS_BADGE_CONFIG = {
|
||||
|
||||
//status-based badges
|
||||
pending_email: { label: 'Pending Email', variant: 'orange2' },
|
||||
pending_validation: { label: 'Pending Validation', variant: 'gray' },
|
||||
payment_pending: { label: 'Payment Pending', variant: 'orange' },
|
||||
active: { label: 'Active', variant: 'green' },
|
||||
inactive: { label: 'Inactive', variant: 'gray2' },
|
||||
canceled: { label: 'Canceled', variant: 'red' },
|
||||
expired: { label: 'Expired', variant: 'red2' },
|
||||
abandoned: { label: 'Abandoned', variant: 'gray3' },
|
||||
rejected: { label: 'Rejected', className: 'bg-red-100 text-red-700' },
|
||||
|
||||
//role-based badges
|
||||
finance: { label: 'Finance Manager', variant: 'purple' },
|
||||
guest: { label: 'Guest', variant: 'gray' },
|
||||
member: { label: 'Member', variant: 'purple' },
|
||||
superadmin: { label: 'Superadmin', variant: 'purple' },
|
||||
admin: { label: 'Admin', variant: 'purple' },
|
||||
moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' },
|
||||
staff: { label: 'Staff', variant: 'gray' },
|
||||
media: { label: 'Media', variant: 'gray2' },
|
||||
|
||||
//donation badges
|
||||
pending: { label: 'Payment Pending', variant: 'orange' },
|
||||
completed: { label: 'Completed', variant: 'green' },
|
||||
failed: { label: 'Failed', className: 'bg-red-100 text-red-700' }
|
||||
};
|
||||
|
||||
//todo: make shield icon dynamic based on status
|
||||
const StatusBadge = ({ status }) => {
|
||||
const statusConfig = STATUS_BADGE_CONFIG[status] || STATUS_BADGE_CONFIG.inactive;
|
||||
|
||||
return (
|
||||
<Badge variant={statusConfig.variant} className=" px-3 py-1 rounded-md text-sm">
|
||||
{/* <Shield className="h-3 w-3 mr-1 inline" /> */}
|
||||
{statusConfig.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
export default StatusBadge;
|
||||
1084
src/components/TemplateImportWizard.js
Normal file
1084
src/components/TemplateImportWizard.js
Normal file
File diff suppressed because it is too large
Load Diff
252
src/components/TransactionHistory.js
Normal file
252
src/components/TransactionHistory.js
Normal file
@@ -0,0 +1,252 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Card } from './ui/card';
|
||||
import { Badge } from './ui/badge';
|
||||
import { Button } from './ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from './ui/tabs';
|
||||
import { Receipt, CreditCard, Heart, Calendar, ExternalLink, DollarSign } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* TransactionHistory Component
|
||||
* Displays user transaction history including subscriptions and donations
|
||||
*
|
||||
* @param {Object} props
|
||||
* @param {Array} props.subscriptions - List of subscription transactions
|
||||
* @param {Array} props.donations - List of donation transactions
|
||||
* @param {number} props.totalSubscriptionCents - Total subscription amount in cents
|
||||
* @param {number} props.totalDonationCents - Total donation amount in cents
|
||||
* @param {boolean} props.loading - Loading state
|
||||
* @param {boolean} props.isAdmin - Whether viewing as admin (shows extra fields)
|
||||
*/
|
||||
const TransactionHistory = ({
|
||||
subscriptions = [],
|
||||
donations = [],
|
||||
totalSubscriptionCents = 0,
|
||||
totalDonationCents = 0,
|
||||
loading = false,
|
||||
isAdmin = false
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
|
||||
const formatAmount = (cents) => {
|
||||
if (!cents) return '$0.00';
|
||||
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'
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadgeClass = (status) => {
|
||||
switch (status?.toLowerCase()) {
|
||||
case 'active':
|
||||
case 'completed':
|
||||
return 'bg-green-100 text-green-800 border-green-200';
|
||||
case 'pending':
|
||||
return 'bg-yellow-100 text-yellow-800 border-yellow-200';
|
||||
case 'cancelled':
|
||||
case 'failed':
|
||||
case 'expired':
|
||||
return 'bg-red-100 text-red-800 border-red-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-800 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const allTransactions = [
|
||||
...subscriptions.map(s => ({ ...s, sortDate: s.created_at })),
|
||||
...donations.map(d => ({ ...d, sortDate: d.created_at }))
|
||||
].sort((a, b) => new Date(b.sortDate) - new Date(a.sortDate));
|
||||
|
||||
const TransactionRow = ({ transaction }) => {
|
||||
const isSubscription = transaction.type === 'subscription';
|
||||
|
||||
return (
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between p-4 border-b border-[var(--neutral-800)] last:border-b-0 hover:bg-[var(--lavender-500)] transition-colors">
|
||||
<div className="flex items-start gap-3 mb-2 sm:mb-0">
|
||||
<div className={`p-2 rounded-lg ${isSubscription ? 'bg-[var(--purple-lavender)] bg-opacity-20' : 'bg-[var(--orange-light)] bg-opacity-20'}`}>
|
||||
{isSubscription ? (
|
||||
<CreditCard className="h-5 w-5 text-white" />
|
||||
) : (
|
||||
<Heart className="h-5 w-5 text-white" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{transaction.description}
|
||||
</span>
|
||||
<Badge className={`text-xs ${getStatusBadgeClass(transaction.status)}`}>
|
||||
{transaction.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-brand-purple mt-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Calendar className="h-3 w-3" />
|
||||
<span>{formatDate(transaction.payment_completed_at || transaction.created_at)}</span>
|
||||
{transaction.card_brand && transaction.card_last4 && (
|
||||
<>
|
||||
<span className="text-[var(--neutral-800)]">•</span>
|
||||
<span>{transaction.card_brand} ****{transaction.card_last4}</span>
|
||||
</>
|
||||
)}
|
||||
{isSubscription && transaction.billing_cycle && (
|
||||
<>
|
||||
<span className="text-[var(--neutral-800)]">•</span>
|
||||
<span className="capitalize">{transaction.billing_cycle}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{isAdmin && transaction.manual_payment && (
|
||||
<div className="text-xs text-[var(--orange-light)] mt-1">
|
||||
Manual Payment {transaction.manual_payment_notes && `- ${transaction.manual_payment_notes}`}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pl-10 sm:pl-0">
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(transaction.amount_cents)}
|
||||
</div>
|
||||
{isSubscription && transaction.donation_cents > 0 && (
|
||||
<div className="text-xs text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
(incl. {formatAmount(transaction.donation_cents)} donation)
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{transaction.stripe_receipt_url && (
|
||||
<a
|
||||
href={transaction.stripe_receipt_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="p-2 text-brand-purple hover:text-[var(--purple-ink)] hover:bg-[var(--lavender-300)] rounded-lg transition-colors"
|
||||
title="View Receipt"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyState = ({ type }) => (
|
||||
<div className="py-12 text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-[var(--lavender-300)] rounded-full flex items-center justify-center mb-4">
|
||||
{type === 'subscription' ? (
|
||||
<CreditCard className="h-8 w-8 text-brand-purple" />
|
||||
) : type === 'donation' ? (
|
||||
<Heart className="h-8 w-8 text-brand-purple" />
|
||||
) : (
|
||||
<Receipt className="h-8 w-8 text-brand-purple" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{type === 'subscription'
|
||||
? 'No subscription payments yet'
|
||||
: type === 'donation'
|
||||
? 'No donations yet'
|
||||
: 'No transactions yet'}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-[var(--purple-lavender)]"></div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Receipt className="h-6 w-6 text-brand-purple" />
|
||||
Transaction History
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Summary Cards */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-6">
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
<span className="text-sm">Total Subscriptions</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(totalSubscriptionCents)}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple mt-1">{subscriptions.length} payment(s)</div>
|
||||
</div>
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
|
||||
<div className="flex items-center gap-2 text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Heart className="h-4 w-4" />
|
||||
<span className="text-sm">Total Donations</span>
|
||||
</div>
|
||||
<div className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatAmount(totalDonationCents)}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple mt-1">{donations.length} donation(s)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid w-full grid-cols-3 mb-4">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
All ({allTransactions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="subscriptions" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
Subscriptions ({subscriptions.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="donations" className="data-[state=active]:bg-[var(--purple-lavender)] data-[state=active]:text-white">
|
||||
Donations ({donations.length})
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="border border-[var(--neutral-800)] rounded-xl overflow-hidden">
|
||||
<TabsContent value="all" className="m-0">
|
||||
{allTransactions.length > 0 ? (
|
||||
allTransactions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="all" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="subscriptions" className="m-0">
|
||||
{subscriptions.length > 0 ? (
|
||||
subscriptions.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="subscription" />
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="donations" className="m-0">
|
||||
{donations.length > 0 ? (
|
||||
donations.map((transaction) => (
|
||||
<TransactionRow key={transaction.id} transaction={transaction} />
|
||||
))
|
||||
) : (
|
||||
<EmptyState type="donation" />
|
||||
)}
|
||||
</TabsContent>
|
||||
</div>
|
||||
</Tabs>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TransactionHistory;
|
||||
539
src/components/ViewRegistrationDialog.js
Normal file
539
src/components/ViewRegistrationDialog.js
Normal file
@@ -0,0 +1,539 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from './ui/dialog';
|
||||
import { Button } from './ui/button';
|
||||
import { Card } from './ui/card';
|
||||
import { Checkbox } from './ui/checkbox';
|
||||
import { Input } from './ui/input';
|
||||
import { Label } from './ui/label';
|
||||
import { Textarea } from './ui/textarea';
|
||||
import { User, Mail, Phone, Calendar, UserCheck, Clock, FileText } from 'lucide-react';
|
||||
import StatusBadge from './StatusBadge';
|
||||
import api from '../utils/api';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
const ViewRegistrationDialog = ({ open, onOpenChange, user }) => {
|
||||
if (!user) return null;
|
||||
|
||||
const [formData, setFormData] = useState(null);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const autoSaveTimeoutRef = useRef(null);
|
||||
const pendingSaveRef = useRef(false);
|
||||
|
||||
const leadSourceOptions = [
|
||||
'Current member',
|
||||
'Friend',
|
||||
'OutSmart Magazine',
|
||||
'Search engine (Google etc.)',
|
||||
"I've known about LOAF for a long time",
|
||||
'Other'
|
||||
];
|
||||
|
||||
const volunteerOptions = [
|
||||
'Welcoming new members at events',
|
||||
'Sending out birthday cards',
|
||||
'Care Team Calls',
|
||||
'Sharing ideas for events',
|
||||
'Researching grants',
|
||||
'Applying for grants',
|
||||
'Assisting with TeatherLOAFers',
|
||||
'Assisting with ActiveLOAFers',
|
||||
'Assisting with weekday Lunch Bunch',
|
||||
'Uploading Photos to the Website',
|
||||
'Assisting with eNewsletter',
|
||||
'Other administrative task'
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !user) return;
|
||||
const nextFormData = {
|
||||
lead_sources: Array.isArray(user.lead_sources) ? user.lead_sources : [],
|
||||
partner_first_name: user.partner_first_name || '',
|
||||
partner_last_name: user.partner_last_name || '',
|
||||
partner_is_member: Boolean(user.partner_is_member),
|
||||
partner_plan_to_become_member: Boolean(user.partner_plan_to_become_member),
|
||||
newsletter_publish_name: Boolean(user.newsletter_publish_name),
|
||||
newsletter_publish_photo: Boolean(user.newsletter_publish_photo),
|
||||
newsletter_publish_birthday: Boolean(user.newsletter_publish_birthday),
|
||||
newsletter_publish_none: Boolean(user.newsletter_publish_none),
|
||||
referred_by_member_name: user.referred_by_member_name || '',
|
||||
volunteer_interests: Array.isArray(user.volunteer_interests) ? user.volunteer_interests : [],
|
||||
scholarship_requested: Boolean(user.scholarship_requested),
|
||||
scholarship_reason: user.scholarship_reason || ''
|
||||
};
|
||||
setFormData(nextFormData);
|
||||
setHasUnsavedChanges(false);
|
||||
}, [open, user]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
};
|
||||
|
||||
const formatDateTime = (dateString) => {
|
||||
if (!dateString) return '—';
|
||||
return new Date(dateString).toLocaleString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
const formatPhoneNumber = (phone) => {
|
||||
if (!phone) return '—';
|
||||
const cleaned = phone.replace(/\D/g, '');
|
||||
if (cleaned.length === 10) {
|
||||
return `(${cleaned.slice(0, 3)}) ${cleaned.slice(3, 6)}-${cleaned.slice(6)}`;
|
||||
}
|
||||
return phone;
|
||||
};
|
||||
|
||||
const saveProfile = async (showToast = true) => {
|
||||
if (!formData) return;
|
||||
if (isSaving) {
|
||||
pendingSaveRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await api.put('/users/profile', {
|
||||
lead_sources: formData.lead_sources,
|
||||
partner_first_name: formData.partner_first_name,
|
||||
partner_last_name: formData.partner_last_name,
|
||||
partner_is_member: formData.partner_is_member,
|
||||
partner_plan_to_become_member: formData.partner_plan_to_become_member,
|
||||
newsletter_publish_name: formData.newsletter_publish_name,
|
||||
newsletter_publish_photo: formData.newsletter_publish_photo,
|
||||
newsletter_publish_birthday: formData.newsletter_publish_birthday,
|
||||
newsletter_publish_none: formData.newsletter_publish_none,
|
||||
referred_by_member_name: formData.referred_by_member_name,
|
||||
volunteer_interests: formData.volunteer_interests,
|
||||
scholarship_requested: formData.scholarship_requested,
|
||||
scholarship_reason: formData.scholarship_reason
|
||||
});
|
||||
setHasUnsavedChanges(false);
|
||||
if (showToast) {
|
||||
toast.success('Registration details saved');
|
||||
}
|
||||
} catch (error) {
|
||||
if (showToast) {
|
||||
toast.error(error.response?.data?.detail || 'Failed to save registration details');
|
||||
}
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
if (pendingSaveRef.current) {
|
||||
pendingSaveRef.current = false;
|
||||
saveProfile(showToast);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleAutoSave = () => {
|
||||
setHasUnsavedChanges(true);
|
||||
if (autoSaveTimeoutRef.current) {
|
||||
clearTimeout(autoSaveTimeoutRef.current);
|
||||
}
|
||||
autoSaveTimeoutRef.current = setTimeout(() => {
|
||||
saveProfile(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => {
|
||||
const next = { ...prev, [name]: value };
|
||||
return next;
|
||||
});
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (name, checked) => {
|
||||
setFormData(prev => ({ ...prev, [name]: checked }));
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const handleLeadSourceChange = (source) => {
|
||||
setFormData(prev => {
|
||||
const sources = prev.lead_sources.includes(source)
|
||||
? prev.lead_sources.filter((item) => item !== source)
|
||||
: [...prev.lead_sources, source];
|
||||
return { ...prev, lead_sources: sources };
|
||||
});
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const handleVolunteerChange = (option) => {
|
||||
setFormData(prev => {
|
||||
const interests = prev.volunteer_interests.includes(option)
|
||||
? prev.volunteer_interests.filter((item) => item !== option)
|
||||
: [...prev.volunteer_interests, option];
|
||||
return { ...prev, volunteer_interests: interests };
|
||||
});
|
||||
scheduleAutoSave();
|
||||
};
|
||||
|
||||
const InfoRow = ({ icon: Icon, label, value }) => (
|
||||
<div className="flex items-start gap-3 py-3 border-b border-[var(--neutral-800)] last:border-b-0">
|
||||
<div className="h-10 w-10 rounded-lg bg-[var(--lavender-400)] flex items-center justify-center flex-shrink-0">
|
||||
<Icon className="h-5 w-5 text-brand-purple" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{label}
|
||||
</p>
|
||||
<p className="font-medium text-[var(--purple-ink)] break-words" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{value || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[600px] rounded-2xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<FileText className="h-6 w-6" />
|
||||
Registration Details
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
View the registration information for this member application.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-4">
|
||||
{/* User Header Card */}
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border-2 border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-16 w-16 rounded-full bg-[var(--neutral-800)]/20 flex items-center justify-center">
|
||||
<User className="h-8 w-8 text-brand-purple" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{user.first_name} {user.last_name}
|
||||
</p>
|
||||
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.email}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<StatusBadge status={user.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Contact Information */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Contact Information
|
||||
</h3>
|
||||
<InfoRow icon={Mail} label="Email Address" value={user.email} />
|
||||
<InfoRow icon={Phone} label="Phone Number" value={formatPhoneNumber(user.phone)} />
|
||||
</Card>
|
||||
|
||||
{/* Registration Details */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Registration Details
|
||||
</h3>
|
||||
<InfoRow icon={Calendar} label="Registration Date" value={formatDate(user.created_at)} />
|
||||
<InfoRow icon={UserCheck} label="Referred By" value={formData?.referred_by_member_name} />
|
||||
<InfoRow icon={Clock} label="Email Verification Expires" value={formatDateTime(user.email_verification_expires_at)} />
|
||||
</Card>
|
||||
|
||||
{formData && (
|
||||
<>
|
||||
{/* How Did You Hear About Us */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
How Did You Hear About Us? *
|
||||
</h3>
|
||||
<div className="space-y-3">
|
||||
{leadSourceOptions.map((source) => (
|
||||
<div key={source} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`lead_${source}`}
|
||||
checked={formData.lead_sources.includes(source)}
|
||||
onCheckedChange={() => handleLeadSourceChange(source)}
|
||||
/>
|
||||
<Label htmlFor={`lead_${source}`} className="text-base cursor-pointer">
|
||||
{source}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Partner Information */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Partner Information (Optional)
|
||||
</h3>
|
||||
<div className="grid md:grid-cols-2 gap-4 mb-4">
|
||||
<div>
|
||||
<Label htmlFor="partner_first_name">Partner First Name</Label>
|
||||
<Input
|
||||
id="partner_first_name"
|
||||
name="partner_first_name"
|
||||
value={formData.partner_first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="partner_last_name">Partner Last Name</Label>
|
||||
<Input
|
||||
id="partner_last_name"
|
||||
name="partner_last_name"
|
||||
value={formData.partner_last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="partner_is_member"
|
||||
checked={formData.partner_is_member}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('partner_is_member', checked)}
|
||||
/>
|
||||
<Label htmlFor="partner_is_member" className="text-base cursor-pointer">
|
||||
Is your partner already a member?
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="partner_plan_to_become_member"
|
||||
checked={formData.partner_plan_to_become_member}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('partner_plan_to_become_member', checked)}
|
||||
/>
|
||||
<Label htmlFor="partner_plan_to_become_member" className="text-base cursor-pointer">
|
||||
Does your partner plan to become a member?
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Newsletter Preferences */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Newsletter Publication Preferences *
|
||||
</h3>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Please check what information may be published in LOAF Newsletter
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="newsletter_publish_name"
|
||||
checked={formData.newsletter_publish_name}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_name', checked)}
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_name" className="text-base cursor-pointer">
|
||||
Name
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="newsletter_publish_photo"
|
||||
checked={formData.newsletter_publish_photo}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_photo', checked)}
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_photo" className="text-base cursor-pointer">
|
||||
Photo (added later in profile)
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="newsletter_publish_birthday"
|
||||
checked={formData.newsletter_publish_birthday}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_birthday', checked)}
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_birthday" className="text-base cursor-pointer">
|
||||
Birthday
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="newsletter_publish_none"
|
||||
checked={formData.newsletter_publish_none}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('newsletter_publish_none', checked)}
|
||||
/>
|
||||
<Label htmlFor="newsletter_publish_none" className="text-base cursor-pointer">
|
||||
Do not publish any of my information
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Referral */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Referral
|
||||
</h3>
|
||||
<div>
|
||||
<Label htmlFor="referred_by_member_name">Name of a LOAF Member who already knows you</Label>
|
||||
<Input
|
||||
id="referred_by_member_name"
|
||||
name="referred_by_member_name"
|
||||
value={formData.referred_by_member_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter member name or email"
|
||||
className="h-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
/>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If referred by a current member, you may skip the event attendance requirement.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Volunteer Interests */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Volunteer Interests (Optional)
|
||||
</h3>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
{volunteerOptions.map((option) => (
|
||||
<div key={option} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`volunteer_${option}`}
|
||||
checked={formData.volunteer_interests.includes(option)}
|
||||
onCheckedChange={() => handleVolunteerChange(option)}
|
||||
/>
|
||||
<Label htmlFor={`volunteer_${option}`} className="text-base cursor-pointer">
|
||||
{option}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Scholarship Request */}
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="scholarship_requested"
|
||||
checked={formData.scholarship_requested}
|
||||
onCheckedChange={(checked) => handleCheckboxChange('scholarship_requested', checked)}
|
||||
/>
|
||||
<Label htmlFor="scholarship_requested" className="text-base cursor-pointer font-semibold">
|
||||
I am requesting for scholarship
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Scholarship information is kept confidential
|
||||
</p>
|
||||
{formData.scholarship_requested && (
|
||||
<div className="mt-4">
|
||||
<Label htmlFor="scholarship_reason">Please explain your situation *</Label>
|
||||
<Textarea
|
||||
id="scholarship_reason"
|
||||
name="scholarship_reason"
|
||||
value={formData.scholarship_reason}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Tell us why you're requesting a scholarship..."
|
||||
rows={4}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Additional Information (if available) */}
|
||||
{(user.address || user.city || user.state || user.zip_code) && (
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Address
|
||||
</h3>
|
||||
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.address && <p>{user.address}</p>}
|
||||
{(user.city || user.state || user.zip_code) && (
|
||||
<p>
|
||||
{[user.city, user.state, user.zip_code].filter(Boolean).join(', ')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notes (if available) */}
|
||||
{user.notes && (
|
||||
<Card className="p-4 border border-[var(--neutral-800)] rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Notes
|
||||
</h3>
|
||||
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.notes}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Rejection Reason (if rejected) */}
|
||||
{user.status === 'rejected' && user.rejection_reason && (
|
||||
<Card className="p-4 border border-red-300 bg-red-50 dark:bg-red-500/10 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-red-600 mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Rejection Reason
|
||||
</h3>
|
||||
<p className="text-red-600" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user.rejection_reason}
|
||||
</p>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<div className="flex-1 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{isSaving && 'Saving changes...'}
|
||||
{!isSaving && hasUnsavedChanges && 'Unsaved changes'}
|
||||
{!isSaving && !hasUnsavedChanges && 'All changes saved'}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => saveProfile(true)}
|
||||
disabled={!hasUnsavedChanges || isSaving}
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] bg-white text-[var(--purple-ink)] hover:bg-[var(--lavender-300)]"
|
||||
>
|
||||
Save All
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="rounded-xl bg-[var(--purple-ink)] hover:bg-[var(--purple-ink)]/90 text-white"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default ViewRegistrationDialog;
|
||||
@@ -370,15 +370,15 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
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]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Upload WordPress CSV Export</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
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]">
|
||||
<Card className="p-6 border-2 border-dashed border-[var(--neutral-800)] bg-[var(--lavender-400)]">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<Upload className="h-12 w-12 text-[#664fa3]" />
|
||||
<Upload className="h-12 w-12 text-brand-purple " />
|
||||
<div className="text-center">
|
||||
<Input
|
||||
type="file"
|
||||
@@ -387,7 +387,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
className="max-w-xs"
|
||||
/>
|
||||
{uploadedFile && (
|
||||
<p className="text-sm text-[#664fa3] mt-2">
|
||||
<p className="text-sm text-brand-purple mt-2">
|
||||
Selected: {uploadedFile.name}
|
||||
</p>
|
||||
)}
|
||||
@@ -399,7 +399,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
<Button
|
||||
onClick={handleUpload}
|
||||
disabled={uploading}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#422268]"
|
||||
className="w-full bg-brand-purple hover:bg-[var(--purple-ink)]"
|
||||
>
|
||||
{uploading ? (
|
||||
<>
|
||||
@@ -465,8 +465,8 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
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]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Field Mapping</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
WordPress fields have been automatically mapped to LOAF platform fields.
|
||||
</p>
|
||||
</div>
|
||||
@@ -537,20 +537,20 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
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]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Review & Adjust User Status</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Review suggested status mappings and override as needed before import.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Bulk edit toolbar */}
|
||||
<Card className="p-4 bg-[#f9f5ff] border-[#ddd8eb]">
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border-[var(--neutral-800)]">
|
||||
<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">
|
||||
<span className="text-sm text-brand-purple font-medium">
|
||||
{selectedRows.size > 0 ? `${selectedRows.size} selected` : 'Select all'}
|
||||
</span>
|
||||
{selectedRows.size > 0 && (
|
||||
@@ -572,13 +572,13 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
{/* Data table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#664fa3]" />
|
||||
<Loader2 className="h-8 w-8 animate-spin text-brand-purple " />
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-lg overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-[#f9f5ff]">
|
||||
<TableRow className="bg-[var(--lavender-400)]">
|
||||
<TableHead className="w-12">
|
||||
<Checkbox checked={false} />
|
||||
</TableHead>
|
||||
@@ -606,7 +606,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
{row.first_name} {row.last_name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge className="bg-[#ddd8eb] text-[#422268]">
|
||||
<Badge className="bg-[var(--neutral-800)] text-[var(--purple-ink)]">
|
||||
{row.wordpress_roles?.join(', ') || 'N/A'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
@@ -651,7 +651,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Page {currentPage} of {totalPages}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
@@ -689,41 +689,41 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
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]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Preview</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
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>
|
||||
<p className="text-sm text-brand-purple ">Total Users</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{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>
|
||||
<p className="text-sm text-brand-purple ">Status Overrides</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{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>
|
||||
<p className="text-sm text-brand-purple ">Expected Imports</p>
|
||||
<p className="text-3xl font-semibold text-[var(--purple-ink)]">{analysisResult?.valid_rows}</p>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="p-6">
|
||||
<h4 className="font-semibold text-[#422268] mb-4">Import Options</h4>
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] 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>
|
||||
<span className="text-sm text-brand-purple ">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>
|
||||
<span className="text-sm text-brand-purple ">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>
|
||||
<span className="text-sm text-brand-purple ">Full rollback capability available after import</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -748,10 +748,10 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
const Step5Execute = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
|
||||
{importing ? 'Import in Progress...' : 'Ready to Import'}
|
||||
</h3>
|
||||
<p className="text-sm text-[#664fa3]">
|
||||
<p className="text-sm text-brand-purple ">
|
||||
{importing
|
||||
? 'Please wait while users are imported. This may take a few minutes.'
|
||||
: 'Click "Start Import" to begin importing users.'}
|
||||
@@ -761,7 +761,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
{importing && (
|
||||
<div className="space-y-4">
|
||||
<Progress value={importProgress} className="w-full" />
|
||||
<p className="text-center text-sm text-[#664fa3]">
|
||||
<p className="text-center text-sm text-brand-purple ">
|
||||
{importProgress.toFixed(1)}% complete
|
||||
</p>
|
||||
</div>
|
||||
@@ -770,7 +770,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
{!importing && !importResults && (
|
||||
<Button
|
||||
onClick={handleExecuteImport}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#422268] py-6 text-lg"
|
||||
className="w-full bg-brand-purple hover:bg-[var(--purple-ink)] py-6 text-lg"
|
||||
>
|
||||
<Play className="mr-2 h-5 w-5" />
|
||||
Start Import
|
||||
@@ -786,8 +786,8 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
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]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">Import Complete</h3>
|
||||
<p className="text-sm text-brand-purple ">
|
||||
Review the import results and download error reports if needed.
|
||||
</p>
|
||||
</div>
|
||||
@@ -850,11 +850,11 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
<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]">
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
|
||||
Confirm Rollback
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="text-[#664fa3]">
|
||||
<DialogDescription className="text-brand-purple ">
|
||||
This will permanently delete{' '}
|
||||
<strong>{importResults?.successful_rows} users</strong> that were imported.
|
||||
This action cannot be undone.
|
||||
@@ -896,12 +896,12 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className="max-w-6xl max-h-[90vh] overflow-y-auto scrollbar-dashboard">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]">
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]">
|
||||
WordPress Import Wizard
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]">
|
||||
<DialogDescription className="text-brand-purple ">
|
||||
Import WordPress users with interactive status review and full rollback capability
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -919,7 +919,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
<div
|
||||
className={`
|
||||
w-10 h-10 rounded-full flex items-center justify-center
|
||||
${isCurrent ? 'bg-[#664fa3] text-white' : ''}
|
||||
${isCurrent ? 'bg-brand-purple text-white' : ''}
|
||||
${isCompleted ? 'bg-green-600 text-white' : ''}
|
||||
${!isCurrent && !isCompleted ? 'bg-gray-200 text-gray-600' : ''}
|
||||
`}
|
||||
@@ -930,7 +930,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
<StepIcon className="h-5 w-5" />
|
||||
)}
|
||||
</div>
|
||||
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[#422268]' : 'text-gray-600'}`}>
|
||||
<p className={`text-xs mt-1 ${isCurrent ? 'font-semibold text-[var(--purple-ink)]' : 'text-gray-600'}`}>
|
||||
{step.title}
|
||||
</p>
|
||||
</div>
|
||||
@@ -962,7 +962,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
<Button
|
||||
onClick={handleNext}
|
||||
disabled={!canProceed()}
|
||||
className="bg-[#664fa3] hover:bg-[#422268]"
|
||||
className="bg-brand-purple hover:bg-[var(--purple-ink)]"
|
||||
>
|
||||
Next
|
||||
<ChevronRight className="h-4 w-4 ml-2" />
|
||||
@@ -975,7 +975,7 @@ export default function WordPressImportWizard({ open, onOpenChange, onSuccess })
|
||||
onOpenChange(false);
|
||||
if (onSuccess) onSuccess();
|
||||
}}
|
||||
className="bg-[#664fa3] hover:bg-[#422268]"
|
||||
className="bg-brand-purple hover:bg-[var(--purple-ink)]"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
531
src/components/admin/AdminPaymentMethodsPanel.js
Normal file
531
src/components/admin/AdminPaymentMethodsPanel.js
Normal file
@@ -0,0 +1,531 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Elements } from '@stripe/react-stripe-js';
|
||||
import { Card } from '../ui/card';
|
||||
import { Button } from '../ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Label } from '../ui/label';
|
||||
import {
|
||||
CreditCard,
|
||||
Plus,
|
||||
Loader2,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
Banknote,
|
||||
Building2,
|
||||
FileCheck,
|
||||
Trash2,
|
||||
Star,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../../utils/api';
|
||||
import useStripeConfig from '../../hooks/use-stripe-config';
|
||||
import ConfirmationDialog from '../ConfirmationDialog';
|
||||
import PasswordConfirmDialog from '../PasswordConfirmDialog';
|
||||
import AddPaymentMethodDialog from '../AddPaymentMethodDialog';
|
||||
|
||||
/**
|
||||
* Get icon for payment method type
|
||||
*/
|
||||
const getPaymentTypeIcon = (paymentType) => {
|
||||
switch (paymentType) {
|
||||
case 'cash':
|
||||
return Banknote;
|
||||
case 'bank_transfer':
|
||||
return Building2;
|
||||
case 'check':
|
||||
return FileCheck;
|
||||
default:
|
||||
return CreditCard;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Format payment type for display
|
||||
*/
|
||||
const formatPaymentType = (paymentType) => {
|
||||
switch (paymentType) {
|
||||
case 'cash':
|
||||
return 'Cash';
|
||||
case 'bank_transfer':
|
||||
return 'Bank Transfer';
|
||||
case 'check':
|
||||
return 'Check';
|
||||
case 'card':
|
||||
return 'Card';
|
||||
default:
|
||||
return paymentType;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* AdminPaymentMethodsPanel - Admin panel for managing user payment methods
|
||||
*/
|
||||
const AdminPaymentMethodsPanel = ({ userId, userName }) => {
|
||||
const [paymentMethods, setPaymentMethods] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [actionLoading, setActionLoading] = useState(false);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// Get Stripe configuration from API
|
||||
const { stripePromise, loading: stripeLoading, error: stripeError } = useStripeConfig();
|
||||
|
||||
// Dialog states
|
||||
const [addCardDialogOpen, setAddCardDialogOpen] = useState(false);
|
||||
const [addManualDialogOpen, setAddManualDialogOpen] = useState(false);
|
||||
const [clientSecret, setClientSecret] = useState(null);
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
|
||||
const [methodToDelete, setMethodToDelete] = useState(null);
|
||||
const [revealDialogOpen, setRevealDialogOpen] = useState(false);
|
||||
const [revealedData, setRevealedData] = useState(null);
|
||||
|
||||
// Manual payment form state
|
||||
const [manualPaymentType, setManualPaymentType] = useState('cash');
|
||||
const [manualNotes, setManualNotes] = useState('');
|
||||
const [manualSetDefault, setManualSetDefault] = useState(false);
|
||||
|
||||
/**
|
||||
* Fetch payment methods from API
|
||||
*/
|
||||
const fetchPaymentMethods = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get(`/admin/users/${userId}/payment-methods`);
|
||||
setPaymentMethods(response.data);
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to load payment methods';
|
||||
setError(errorMessage);
|
||||
console.error('Failed to fetch payment methods:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userId) {
|
||||
fetchPaymentMethods();
|
||||
}
|
||||
}, [userId, fetchPaymentMethods]);
|
||||
|
||||
/**
|
||||
* Create SetupIntent for adding a card
|
||||
*/
|
||||
const handleAddCard = async () => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
const response = await api.post(`/admin/users/${userId}/payment-methods/setup-intent`);
|
||||
setClientSecret(response.data.client_secret);
|
||||
setAddCardDialogOpen(true);
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to initialize payment setup';
|
||||
toast.error(errorMessage);
|
||||
console.error('Failed to create setup intent:', err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle successful card addition
|
||||
*/
|
||||
const handleCardAddSuccess = () => {
|
||||
setAddCardDialogOpen(false);
|
||||
setClientSecret(null);
|
||||
fetchPaymentMethods();
|
||||
};
|
||||
|
||||
/**
|
||||
* Save manual payment method
|
||||
*/
|
||||
const handleSaveManualPayment = async () => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await api.post(`/admin/users/${userId}/payment-methods/manual`, {
|
||||
payment_type: manualPaymentType,
|
||||
manual_notes: manualNotes || null,
|
||||
set_as_default: manualSetDefault,
|
||||
});
|
||||
toast.success('Manual payment method recorded');
|
||||
setAddManualDialogOpen(false);
|
||||
setManualPaymentType('cash');
|
||||
setManualNotes('');
|
||||
setManualSetDefault(false);
|
||||
fetchPaymentMethods();
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to record payment method';
|
||||
toast.error(errorMessage);
|
||||
console.error('Failed to save manual payment:', err);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Set a payment method as default
|
||||
*/
|
||||
const handleSetDefault = async (methodId) => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await api.put(`/admin/users/${userId}/payment-methods/${methodId}/default`);
|
||||
toast.success('Default payment method updated');
|
||||
fetchPaymentMethods();
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to update default';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirm and delete payment method
|
||||
*/
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!methodToDelete) return;
|
||||
|
||||
try {
|
||||
setActionLoading(true);
|
||||
await api.delete(`/admin/users/${userId}/payment-methods/${methodToDelete}`);
|
||||
toast.success('Payment method removed');
|
||||
setDeleteConfirmOpen(false);
|
||||
setMethodToDelete(null);
|
||||
fetchPaymentMethods();
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to remove payment method';
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Reveal sensitive payment details with password confirmation
|
||||
*/
|
||||
const handleRevealDetails = async (password) => {
|
||||
try {
|
||||
setActionLoading(true);
|
||||
const response = await api.post(`/admin/users/${userId}/payment-methods/reveal`, {
|
||||
password,
|
||||
});
|
||||
setRevealedData(response.data);
|
||||
setRevealDialogOpen(false);
|
||||
toast.success('Sensitive details revealed');
|
||||
} catch (err) {
|
||||
const errorMessage = err.response?.data?.detail || 'Failed to reveal details';
|
||||
throw new Error(errorMessage);
|
||||
} finally {
|
||||
setActionLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Stripe Elements options - simplified for CardElement
|
||||
const elementsOptions = {
|
||||
appearance: {
|
||||
theme: 'stripe',
|
||||
variables: {
|
||||
colorPrimary: '#6b5b95',
|
||||
colorBackground: '#ffffff',
|
||||
colorText: '#2d2a4a',
|
||||
fontFamily: "'Nunito Sans', sans-serif",
|
||||
borderRadius: '12px',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<CreditCard className="h-5 w-5 text-brand-purple" />
|
||||
<h2
|
||||
className="text-lg font-semibold text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Payment Methods
|
||||
</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setRevealDialogOpen(true)}
|
||||
disabled={actionLoading || paymentMethods.length === 0}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
Reveal Details
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setAddManualDialogOpen(true)}
|
||||
disabled={actionLoading}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)] rounded-lg"
|
||||
>
|
||||
<Banknote className="h-4 w-4 mr-1" />
|
||||
Add Manual
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddCard}
|
||||
disabled={actionLoading}
|
||||
size="sm"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-lg"
|
||||
>
|
||||
{actionLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
Add Card
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Loading State */}
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-brand-purple" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !loading && (
|
||||
<div className="flex items-center gap-2 p-4 bg-red-50 border border-red-200 rounded-xl">
|
||||
<AlertCircle className="h-5 w-5 text-red-500 flex-shrink-0" />
|
||||
<p className="text-sm text-red-600">{error}</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={fetchPaymentMethods}
|
||||
className="ml-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Payment Methods List */}
|
||||
{!loading && !error && (
|
||||
<div className="space-y-3">
|
||||
{paymentMethods.length === 0 ? (
|
||||
<p
|
||||
className="text-center py-6 text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
No payment methods on file for this user.
|
||||
</p>
|
||||
) : (
|
||||
(revealedData || paymentMethods).map((method) => {
|
||||
const PaymentIcon = getPaymentTypeIcon(method.payment_type);
|
||||
return (
|
||||
<div
|
||||
key={method.id}
|
||||
className={`flex items-center justify-between p-4 border rounded-xl ${
|
||||
method.is_default
|
||||
? 'border-brand-purple bg-[var(--lavender-500)]'
|
||||
: 'border-[var(--neutral-800)] bg-white'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`p-2 rounded-full ${
|
||||
method.is_default
|
||||
? 'bg-brand-purple text-white'
|
||||
: 'bg-[var(--lavender-300)] text-brand-purple'
|
||||
}`}
|
||||
>
|
||||
<PaymentIcon className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
{method.payment_type === 'card' ? (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{method.card_brand
|
||||
? method.card_brand.charAt(0).toUpperCase() +
|
||||
method.card_brand.slice(1)
|
||||
: 'Card'}{' '}
|
||||
•••• {method.card_last4 || '****'}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p
|
||||
className="text-sm text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
Expires {method.card_exp_month?.toString().padStart(2, '0')}/
|
||||
{method.card_exp_year?.toString().slice(-2)}
|
||||
{revealedData && method.stripe_payment_method_id && (
|
||||
<span className="ml-2 text-xs font-mono bg-[var(--lavender-300)] px-2 py-0.5 rounded">
|
||||
{method.stripe_payment_method_id}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="font-medium text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPaymentType(method.payment_type)}
|
||||
</span>
|
||||
{method.is_default && (
|
||||
<span className="flex items-center gap-1 text-xs text-brand-purple font-medium">
|
||||
<Star className="h-3 w-3 fill-current" />
|
||||
Default
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{method.manual_notes && (
|
||||
<p
|
||||
className="text-sm text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{method.manual_notes}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{!method.is_default && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSetDefault(method.id)}
|
||||
disabled={actionLoading}
|
||||
className="text-xs"
|
||||
>
|
||||
Set Default
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setMethodToDelete(method.id);
|
||||
setDeleteConfirmOpen(true);
|
||||
}}
|
||||
disabled={actionLoading}
|
||||
className="border-red-500 text-red-500 hover:bg-red-50 p-2"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Add Card Dialog */}
|
||||
{clientSecret && stripePromise && (
|
||||
<Elements stripe={stripePromise} options={elementsOptions}>
|
||||
<AddPaymentMethodDialog
|
||||
open={addCardDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
setAddCardDialogOpen(open);
|
||||
if (!open) setClientSecret(null);
|
||||
}}
|
||||
onSuccess={handleCardAddSuccess}
|
||||
clientSecret={clientSecret}
|
||||
saveEndpoint={`/admin/users/${userId}/payment-methods`}
|
||||
/>
|
||||
</Elements>
|
||||
)}
|
||||
|
||||
{/* Add Manual Payment Method Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={addManualDialogOpen}
|
||||
onOpenChange={setAddManualDialogOpen}
|
||||
onConfirm={handleSaveManualPayment}
|
||||
title="Record Manual Payment Method"
|
||||
description={
|
||||
<div className="space-y-4 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Payment Type</Label>
|
||||
<Select value={manualPaymentType} onValueChange={setManualPaymentType}>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cash">Cash</SelectItem>
|
||||
<SelectItem value="check">Check</SelectItem>
|
||||
<SelectItem value="bank_transfer">Bank Transfer</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Notes (optional)</Label>
|
||||
<Textarea
|
||||
value={manualNotes}
|
||||
onChange={(e) => setManualNotes(e.target.value)}
|
||||
placeholder="e.g., Check #1234, received 01/15/2026"
|
||||
className="min-h-[80px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
confirmText="Save"
|
||||
variant="info"
|
||||
loading={actionLoading}
|
||||
/>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmationDialog
|
||||
open={deleteConfirmOpen}
|
||||
onOpenChange={setDeleteConfirmOpen}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Remove Payment Method"
|
||||
description="Are you sure you want to remove this payment method? This action cannot be undone."
|
||||
confirmText="Remove"
|
||||
variant="danger"
|
||||
loading={actionLoading}
|
||||
/>
|
||||
|
||||
{/* Password Confirm Dialog for Reveal */}
|
||||
<PasswordConfirmDialog
|
||||
open={revealDialogOpen}
|
||||
onOpenChange={setRevealDialogOpen}
|
||||
onConfirm={handleRevealDetails}
|
||||
title="Reveal Sensitive Details"
|
||||
description="Enter your password to view Stripe payment method IDs. This action will be logged."
|
||||
loading={actionLoading}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminPaymentMethodsPanel;
|
||||
347
src/components/admin/SubscriptionsTable.jsx
Normal file
347
src/components/admin/SubscriptionsTable.jsx
Normal file
@@ -0,0 +1,347 @@
|
||||
import React from 'react';
|
||||
import { Button } from '../ui/button';
|
||||
import StatusBadge from '../StatusBadge';
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Edit,
|
||||
XCircle,
|
||||
CreditCard,
|
||||
Info,
|
||||
ExternalLink,
|
||||
Copy
|
||||
} from 'lucide-react';
|
||||
|
||||
const HEADER_CELLS = [
|
||||
{ label: 'Member', align: 'text-left' },
|
||||
{ label: 'Plan', align: 'text-left' },
|
||||
{ label: 'Status', align: 'text-left' },
|
||||
{ label: 'Period', align: 'text-left' },
|
||||
{ label: 'Base Fee', align: 'text-right' },
|
||||
{ label: 'Donation', align: 'text-right' },
|
||||
{ label: 'Total', align: 'text-right' },
|
||||
{ label: 'Details', align: 'text-center' },
|
||||
{ label: 'Actions', align: 'text-center' }
|
||||
];
|
||||
|
||||
const HeaderCell = ({ align, children }) => (
|
||||
<th
|
||||
className={`p-4 text-[var(--purple-ink)] font-semibold ${align}`}
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
|
||||
const TableCell = ({ align = 'text-left', className = '', style, children, ...props }) => (
|
||||
<td
|
||||
className={`p-4 ${align} ${className}`.trim()}
|
||||
style={style}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
|
||||
|
||||
const SubscriptionRow = ({
|
||||
sub,
|
||||
isExpanded,
|
||||
onToggle,
|
||||
onEdit,
|
||||
onCancel,
|
||||
hasPermission,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatPrice,
|
||||
copyToClipboard
|
||||
}) => (
|
||||
<>
|
||||
<tr className="border-b border-[var(--neutral-800)] hover:bg-[var(--lavender-400)] transition-colors">
|
||||
<TableCell>
|
||||
<div className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{sub.user.first_name} {sub.user.last_name}
|
||||
</div>
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{sub.plan.name}
|
||||
</div>
|
||||
<div className="text-xs text-brand-purple ">
|
||||
{sub.plan.billing_cycle}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<StatusBadge status={sub.status} />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div>{formatDate(sub.start_date)}</div>
|
||||
<div className="text-xs text-brand-purple ">to {formatDate(sub.end_date)}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="text-right"
|
||||
className="text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPrice(sub.base_subscription_cents || 0)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="text-right"
|
||||
className="text-[var(--orange-light)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPrice(sub.donation_cents || 0)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
align="text-right"
|
||||
className="font-semibold text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{formatPrice(sub.amount_paid_cents || 0)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={onToggle}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{hasPermission('subscriptions.edit') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => onEdit(sub)}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{sub.status === 'active' && hasPermission('subscriptions.cancel') && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline-destructive"
|
||||
onClick={() => onCancel(sub.id)}
|
||||
>
|
||||
<XCircle className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</tr>
|
||||
|
||||
{isExpanded && (
|
||||
<tr className="bg-[var(--lavender-400)]/30">
|
||||
<TableCell colSpan={9} className="p-6">
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Transaction Details
|
||||
</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="h-4 w-4" />
|
||||
Payment Information
|
||||
</h5>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sub.payment_completed_at && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Payment Date:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium">{formatDateTime(sub.payment_completed_at)}</span>
|
||||
</div>
|
||||
)}
|
||||
{sub.payment_method && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Payment Method:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium capitalize">{sub.payment_method}</span>
|
||||
</div>
|
||||
)}
|
||||
{sub.card_brand && sub.card_last4 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-brand-purple ">Card:</span>
|
||||
<span className="text-[var(--purple-ink)] font-medium">{sub.card_brand} ****{sub.card_last4}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<h5 className="font-medium text-[var(--purple-ink)] flex items-center gap-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Info className="h-4 w-4" />
|
||||
Stripe Transaction IDs
|
||||
</h5>
|
||||
<div className="space-y-2 text-sm">
|
||||
{sub.stripe_payment_intent_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Payment Intent:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_payment_intent_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_payment_intent_id, 'Payment Intent ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_charge_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Charge ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_charge_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_charge_id, 'Charge ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_subscription_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Subscription ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_subscription_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_subscription_id, 'Subscription ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_invoice_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Invoice ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_invoice_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_invoice_id, 'Invoice ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_customer_id && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Customer ID:</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-xs bg-[var(--neutral-800)]/30 px-2 py-1 rounded text-[var(--purple-ink)]">
|
||||
{sub.stripe_customer_id.substring(0, 20)}...
|
||||
</code>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => copyToClipboard(sub.stripe_customer_id, 'Customer ID')}
|
||||
>
|
||||
<Copy className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{sub.stripe_receipt_url && (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-brand-purple ">Receipt:</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => window.open(sub.stripe_receipt_url, '_blank')}
|
||||
className="text-brand-purple hover:bg-[var(--neutral-800)]"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3 mr-1" />
|
||||
View Receipt
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const SubscriptionsTable = ({
|
||||
subscriptions,
|
||||
expandedRows,
|
||||
onToggleRowExpansion,
|
||||
onEdit,
|
||||
onCancel,
|
||||
hasPermission,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
formatPrice,
|
||||
copyToClipboard
|
||||
}) => (
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="bg-[var(--neutral-800)]/20 border-b border-[var(--neutral-800)]">
|
||||
{HEADER_CELLS.map((cell) => (
|
||||
<HeaderCell key={cell.label} align={cell.align}>
|
||||
{cell.label}
|
||||
</HeaderCell>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{subscriptions.length > 0 ? (
|
||||
subscriptions.map((sub) => (
|
||||
<SubscriptionRow
|
||||
key={sub.id}
|
||||
sub={sub}
|
||||
isExpanded={expandedRows.has(sub.id)}
|
||||
onToggle={() => onToggleRowExpansion(sub.id)}
|
||||
onEdit={onEdit}
|
||||
onCancel={onCancel}
|
||||
hasPermission={hasPermission}
|
||||
formatDate={formatDate}
|
||||
formatDateTime={formatDateTime}
|
||||
formatPrice={formatPrice}
|
||||
copyToClipboard={copyToClipboard}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<TableCell
|
||||
align="text-center"
|
||||
className="p-12 text-brand-purple "
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
colSpan={9}
|
||||
>
|
||||
No subscriptions found
|
||||
</TableCell>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
|
||||
export default SubscriptionsTable;
|
||||
427
src/components/registration/DynamicFormField.js
Normal file
427
src/components/registration/DynamicFormField.js
Normal file
@@ -0,0 +1,427 @@
|
||||
import React from 'react';
|
||||
import { Label } from '../ui/label';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Checkbox } from '../ui/checkbox';
|
||||
import { RadioGroup, RadioGroupItem } from '../ui/radio-group';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '../ui/select';
|
||||
|
||||
/**
|
||||
* DynamicFormField - Renders form fields based on schema configuration
|
||||
*
|
||||
* Supports field types:
|
||||
* - text, email, phone, password: Input fields
|
||||
* - date: Date picker input
|
||||
* - textarea: Multi-line text input
|
||||
* - checkbox: Single checkbox
|
||||
* - radio: Radio button group
|
||||
* - dropdown: Select dropdown
|
||||
* - multiselect: Checkbox group for multiple selections
|
||||
* - address_group: Group of address-related fields
|
||||
* - file_upload: File upload input
|
||||
*/
|
||||
const DynamicFormField = ({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
errors = [],
|
||||
formData = {},
|
||||
}) => {
|
||||
const {
|
||||
id,
|
||||
type,
|
||||
label,
|
||||
required,
|
||||
placeholder,
|
||||
options = [],
|
||||
rows = 4,
|
||||
validation = {},
|
||||
} = field;
|
||||
|
||||
const hasError = errors.length > 0;
|
||||
const errorMessage = errors[0];
|
||||
|
||||
const formatPhoneNumber = (rawValue) => {
|
||||
const digits = String(rawValue || '').replace(/\D/g, '').slice(0, 10);
|
||||
if (digits.length <= 3) return digits;
|
||||
if (digits.length <= 6) {
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3)}`;
|
||||
}
|
||||
return `(${digits.slice(0, 3)}) ${digits.slice(3, 6)}-${digits.slice(6)}`;
|
||||
};
|
||||
|
||||
// Common input className
|
||||
const inputClassName = `h-14 rounded-xl border-2 ${
|
||||
hasError
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`;
|
||||
|
||||
// Handle change for different field types
|
||||
const handleInputChange = (e) => {
|
||||
const { value: newValue, type: inputType, checked } = e.target;
|
||||
if (inputType === 'checkbox') {
|
||||
onChange(id, checked);
|
||||
return;
|
||||
}
|
||||
if (type === 'phone') {
|
||||
onChange(id, formatPhoneNumber(newValue));
|
||||
return;
|
||||
} else {
|
||||
onChange(id, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectChange = (newValue) => {
|
||||
onChange(id, newValue);
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (checked) => {
|
||||
onChange(id, checked);
|
||||
};
|
||||
|
||||
const handleMultiselectChange = (optionValue) => {
|
||||
const currentValues = Array.isArray(value) ? value : [];
|
||||
const newValues = currentValues.includes(optionValue)
|
||||
? currentValues.filter((v) => v !== optionValue)
|
||||
: [...currentValues, optionValue];
|
||||
onChange(id, newValues);
|
||||
};
|
||||
|
||||
// Render error message
|
||||
const renderError = () => {
|
||||
if (!hasError) return null;
|
||||
return (
|
||||
<p className="text-sm text-red-500 mt-1">{errorMessage}</p>
|
||||
);
|
||||
};
|
||||
|
||||
// Render label
|
||||
const renderLabel = () => (
|
||||
<Label htmlFor={id} className={hasError ? 'text-red-500' : ''}>
|
||||
{label} {required && '*'}
|
||||
</Label>
|
||||
);
|
||||
|
||||
// Render based on field type
|
||||
switch (type) {
|
||||
case 'text':
|
||||
case 'email':
|
||||
case 'phone':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type={type === 'phone' ? 'tel' : type}
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
inputMode={type === 'phone' ? 'numeric' : undefined}
|
||||
maxLength={type === 'phone' ? 14 : undefined}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'password':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="password"
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
minLength={validation.minLength}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'date':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="date"
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'textarea':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Textarea
|
||||
id={id}
|
||||
name={id}
|
||||
required={required}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className={`rounded-xl border-2 ${
|
||||
hasError
|
||||
? 'border-red-500 focus:border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'checkbox':
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
name={id}
|
||||
checked={value || false}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={`text-base cursor-pointer ${hasError ? 'text-red-500' : ''}`}
|
||||
>
|
||||
{label} {required && '*'}
|
||||
</Label>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'radio':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<RadioGroup
|
||||
value={value || ''}
|
||||
onValueChange={handleSelectChange}
|
||||
className="space-y-2"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<RadioGroupItem
|
||||
value={option.value}
|
||||
id={`${id}-${option.value}`}
|
||||
data-testid={`field-${id}-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${id}-${option.value}`}
|
||||
className="text-base cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'dropdown':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Select value={value || ''} onValueChange={handleSelectChange}>
|
||||
<SelectTrigger
|
||||
className={`h-14 rounded-xl border-2 ${
|
||||
hasError
|
||||
? 'border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`}
|
||||
data-testid={`field-${id}`}
|
||||
>
|
||||
<SelectValue placeholder={placeholder || 'Select an option'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{options.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'multiselect':
|
||||
const selectedValues = Array.isArray(value) ? value : [];
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<div className="space-y-3">
|
||||
{options.map((option) => (
|
||||
<div key={option.value} className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={`${id}-${option.value}`}
|
||||
checked={selectedValues.includes(option.value)}
|
||||
onCheckedChange={() => handleMultiselectChange(option.value)}
|
||||
data-testid={`field-${id}-${option.value}`}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${id}-${option.value}`}
|
||||
className="text-base cursor-pointer"
|
||||
>
|
||||
{option.label}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'address_group':
|
||||
// Address group renders multiple related fields
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{renderLabel()}
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
id={`${id}_address`}
|
||||
name={`${id}_address`}
|
||||
placeholder="Street Address"
|
||||
value={formData[`${id}_address`] || ''}
|
||||
onChange={(e) => onChange(`${id}_address`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
id={`${id}_city`}
|
||||
name={`${id}_city`}
|
||||
placeholder="City"
|
||||
value={formData[`${id}_city`] || ''}
|
||||
onChange={(e) => onChange(`${id}_city`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}_state`}
|
||||
name={`${id}_state`}
|
||||
placeholder="State"
|
||||
value={formData[`${id}_state`] || ''}
|
||||
onChange={(e) => onChange(`${id}_state`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
<Input
|
||||
id={`${id}_zipcode`}
|
||||
name={`${id}_zipcode`}
|
||||
placeholder="Zipcode"
|
||||
value={formData[`${id}_zipcode`] || ''}
|
||||
onChange={(e) => onChange(`${id}_zipcode`, e.target.value)}
|
||||
className={inputClassName}
|
||||
required={required}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'file_upload':
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
type="file"
|
||||
accept={field.allowed_types?.join(',')}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
onChange(id, file);
|
||||
}}
|
||||
className={`h-14 rounded-xl border-2 pt-3 ${
|
||||
hasError
|
||||
? 'border-red-500'
|
||||
: 'border-[var(--neutral-800)] focus:border-brand-purple'
|
||||
}`}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{field.max_size_mb && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Max file size: {field.max_size_mb}MB
|
||||
</p>
|
||||
)}
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
console.warn(`Unknown field type: ${type}`);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{renderLabel()}
|
||||
<Input
|
||||
id={id}
|
||||
name={id}
|
||||
value={value || ''}
|
||||
onChange={handleInputChange}
|
||||
placeholder={placeholder}
|
||||
className={inputClassName}
|
||||
data-testid={`field-${id}`}
|
||||
/>
|
||||
{renderError()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get width class based on field width configuration
|
||||
*/
|
||||
export const getWidthClass = (width) => {
|
||||
switch (width) {
|
||||
case 'half':
|
||||
return 'md:col-span-1';
|
||||
case 'third':
|
||||
return 'md:col-span-1';
|
||||
case 'two-thirds':
|
||||
return 'md:col-span-2';
|
||||
case 'full':
|
||||
default:
|
||||
return 'md:col-span-2';
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get grid columns class based on field widths in a row
|
||||
*/
|
||||
export const getGridClass = (fields) => {
|
||||
const hasThird = fields.some((f) => f.width === 'third');
|
||||
if (hasThird) {
|
||||
return 'grid md:grid-cols-3 gap-4';
|
||||
}
|
||||
return 'grid md:grid-cols-2 gap-4';
|
||||
};
|
||||
|
||||
export default DynamicFormField;
|
||||
482
src/components/registration/DynamicRegistrationForm.js
Normal file
482
src/components/registration/DynamicRegistrationForm.js
Normal file
@@ -0,0 +1,482 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import DynamicFormField, { getWidthClass } from './DynamicFormField';
|
||||
|
||||
/**
|
||||
* DynamicRegistrationForm - Renders the entire registration form from schema
|
||||
*
|
||||
* Features:
|
||||
* - Renders steps and sections based on schema
|
||||
* - Handles conditional field visibility
|
||||
* - Supports step navigation
|
||||
* - Validates fields per step
|
||||
*/
|
||||
const DynamicRegistrationForm = ({
|
||||
schema,
|
||||
formData,
|
||||
onFormDataChange,
|
||||
currentStep,
|
||||
errors = {},
|
||||
}) => {
|
||||
// Get current step data
|
||||
const stepData = useMemo(() => {
|
||||
const steps = schema?.steps || [];
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
return sortedSteps[currentStep - 1] || null;
|
||||
}, [schema, currentStep]);
|
||||
|
||||
// Evaluate conditional rules to determine which fields are visible
|
||||
const hiddenFields = useMemo(() => {
|
||||
const rules = schema?.conditional_rules || [];
|
||||
const hidden = new Set();
|
||||
|
||||
// First pass: collect fields that have "show" rules (hidden by default)
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'show') {
|
||||
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: evaluate rules and show/hide fields
|
||||
for (const rule of rules) {
|
||||
const {
|
||||
trigger_field,
|
||||
trigger_operator = 'equals',
|
||||
trigger_value,
|
||||
action,
|
||||
target_fields = [],
|
||||
} = rule;
|
||||
|
||||
const fieldValue = formData[trigger_field];
|
||||
let conditionMet = false;
|
||||
|
||||
switch (trigger_operator) {
|
||||
case 'equals':
|
||||
conditionMet = fieldValue === trigger_value;
|
||||
break;
|
||||
case 'not_equals':
|
||||
conditionMet = fieldValue !== trigger_value;
|
||||
break;
|
||||
case 'contains':
|
||||
conditionMet = Array.isArray(fieldValue)
|
||||
? fieldValue.includes(trigger_value)
|
||||
: String(fieldValue || '').includes(trigger_value);
|
||||
break;
|
||||
case 'not_empty':
|
||||
conditionMet = Boolean(fieldValue);
|
||||
break;
|
||||
case 'empty':
|
||||
conditionMet = !Boolean(fieldValue);
|
||||
break;
|
||||
default:
|
||||
conditionMet = false;
|
||||
}
|
||||
|
||||
if (conditionMet) {
|
||||
if (action === 'show') {
|
||||
target_fields.forEach((fieldId) => hidden.delete(fieldId));
|
||||
} else if (action === 'hide') {
|
||||
target_fields.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hidden;
|
||||
}, [schema, formData]);
|
||||
|
||||
// Handle field change
|
||||
const handleFieldChange = useCallback(
|
||||
(fieldId, value) => {
|
||||
onFormDataChange((prev) => ({
|
||||
...prev,
|
||||
[fieldId]: value,
|
||||
}));
|
||||
},
|
||||
[onFormDataChange]
|
||||
);
|
||||
|
||||
// Check if a field is visible
|
||||
const isFieldVisible = useCallback(
|
||||
(fieldId) => {
|
||||
return !hiddenFields.has(fieldId);
|
||||
},
|
||||
[hiddenFields]
|
||||
);
|
||||
|
||||
// Get errors for a specific field
|
||||
const getFieldErrors = useCallback(
|
||||
(fieldId) => {
|
||||
return errors[fieldId] || [];
|
||||
},
|
||||
[errors]
|
||||
);
|
||||
|
||||
// Group fields by their width for rendering
|
||||
const groupFieldsByRow = (fields) => {
|
||||
const rows = [];
|
||||
let currentRow = [];
|
||||
let currentRowWidth = 0;
|
||||
|
||||
const visibleFields = fields.filter((f) => isFieldVisible(f.id));
|
||||
|
||||
for (const field of visibleFields) {
|
||||
const width = field.width || 'full';
|
||||
let widthValue = 1;
|
||||
|
||||
if (width === 'half') widthValue = 0.5;
|
||||
else if (width === 'third') widthValue = 0.33;
|
||||
else if (width === 'two-thirds') widthValue = 0.67;
|
||||
|
||||
if (currentRowWidth + widthValue > 1) {
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
currentRow = [field];
|
||||
currentRowWidth = widthValue;
|
||||
} else {
|
||||
currentRow.push(field);
|
||||
currentRowWidth += widthValue;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
return rows;
|
||||
};
|
||||
|
||||
if (!stepData) {
|
||||
return (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No step data available
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
{/* Step Header */}
|
||||
{stepData.description && (
|
||||
<p
|
||||
className="text-brand-purple"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
{stepData.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{stepData.sections
|
||||
?.sort((a, b) => a.order - b.order)
|
||||
.map((section) => {
|
||||
const visibleFields = section.fields?.filter((f) =>
|
||||
isFieldVisible(f.id)
|
||||
);
|
||||
|
||||
// Skip empty sections
|
||||
if (!visibleFields || visibleFields.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fieldRows = groupFieldsByRow(
|
||||
section.fields?.sort((a, b) => a.order - b.order) || []
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={section.id} className="space-y-4">
|
||||
{/* Section Title */}
|
||||
{section.title && (
|
||||
<h2
|
||||
className="text-2xl font-semibold text-[var(--purple-ink)]"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{section.title}
|
||||
</h2>
|
||||
)}
|
||||
|
||||
{/* Section Description */}
|
||||
{section.description && (
|
||||
<p className="text-muted-foreground">{section.description}</p>
|
||||
)}
|
||||
|
||||
{/* Fields */}
|
||||
<div className="space-y-4">
|
||||
{fieldRows.map((row, rowIndex) => {
|
||||
// Determine grid class based on field widths
|
||||
const hasThird = row.some((f) => f.width === 'third');
|
||||
const hasHalf = row.some((f) => f.width === 'half');
|
||||
const gridCols = hasThird
|
||||
? 'grid md:grid-cols-3 gap-4'
|
||||
: hasHalf
|
||||
? 'grid md:grid-cols-2 gap-4'
|
||||
: '';
|
||||
|
||||
if (row.length === 1 && !hasHalf && !hasThird) {
|
||||
// Single full-width field
|
||||
const field = row[0];
|
||||
return (
|
||||
<DynamicFormField
|
||||
key={field.id}
|
||||
field={field}
|
||||
value={formData[field.id]}
|
||||
onChange={handleFieldChange}
|
||||
errors={getFieldErrors(field.id)}
|
||||
formData={formData}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={`row-${rowIndex}`} className={gridCols}>
|
||||
{row.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className={getWidthClass(field.width)}
|
||||
>
|
||||
<DynamicFormField
|
||||
field={field}
|
||||
value={formData[field.id]}
|
||||
onChange={handleFieldChange}
|
||||
errors={getFieldErrors(field.id)}
|
||||
formData={formData}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* DynamicStepIndicator - Renders step progress indicator
|
||||
*/
|
||||
export const DynamicStepIndicator = ({ steps, currentStep }) => {
|
||||
const sortedSteps = [...steps].sort((a, b) => a.order - b.order);
|
||||
|
||||
return (
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center justify-between">
|
||||
{sortedSteps.map((step, index) => {
|
||||
const stepNumber = index + 1;
|
||||
const isActive = stepNumber === currentStep;
|
||||
const isCompleted = stepNumber < currentStep;
|
||||
|
||||
return (
|
||||
<div key={step.id} className="flex items-center flex-1">
|
||||
{/* Step Circle */}
|
||||
<div className="flex flex-col items-center">
|
||||
<div
|
||||
className={`w-10 h-10 rounded-full flex items-center justify-center text-lg font-medium transition-colors ${
|
||||
isActive
|
||||
? 'bg-brand-purple text-white'
|
||||
: isCompleted
|
||||
? 'bg-green-500 text-white'
|
||||
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]'
|
||||
}`}
|
||||
>
|
||||
{isCompleted ? '✓' : stepNumber}
|
||||
</div>
|
||||
<span
|
||||
className={`mt-2 text-sm text-center hidden md:block ${
|
||||
isActive ? 'text-brand-purple font-medium' : 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{step.title}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Connector Line */}
|
||||
{index < sortedSteps.length - 1 && (
|
||||
<div
|
||||
className={`flex-1 h-1 mx-4 rounded ${
|
||||
isCompleted
|
||||
? 'bg-green-500'
|
||||
: 'bg-[var(--neutral-800)]'
|
||||
}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a single step based on schema
|
||||
*/
|
||||
export const validateStep = (stepData, formData, hiddenFields) => {
|
||||
const errors = {};
|
||||
|
||||
if (!stepData?.sections) return { isValid: true, errors };
|
||||
|
||||
for (const section of stepData.sections) {
|
||||
// Check section-level validation (e.g., atLeastOne)
|
||||
const sectionValidation = section.validation || {};
|
||||
if (sectionValidation.atLeastOne) {
|
||||
const fieldIds = (section.fields || []).map((f) => f.id);
|
||||
const hasValue = fieldIds.some((id) => {
|
||||
if (hiddenFields.has(id)) return true; // Skip hidden fields
|
||||
const value = formData[id];
|
||||
return Boolean(value);
|
||||
});
|
||||
if (!hasValue) {
|
||||
// Add error to first field in section
|
||||
const firstFieldId = fieldIds[0];
|
||||
if (firstFieldId) {
|
||||
errors[firstFieldId] = [
|
||||
sectionValidation.message ||
|
||||
`At least one field in ${section.title || 'this section'} is required`,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check field-level validation
|
||||
for (const field of section.fields || []) {
|
||||
const { id, required, validation = {}, type, label } = field;
|
||||
|
||||
// Skip hidden fields
|
||||
if (hiddenFields.has(id)) continue;
|
||||
|
||||
// Skip client-only fields for server validation
|
||||
if (field.client_only && field.id !== 'confirmPassword') continue;
|
||||
|
||||
const value = formData[id];
|
||||
|
||||
// Required check
|
||||
if (required) {
|
||||
const isEmpty =
|
||||
value === undefined ||
|
||||
value === null ||
|
||||
value === '' ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
errors[id] = [`${label || id} is required`];
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Skip further validation if value is empty
|
||||
if (!value && value !== false) continue;
|
||||
|
||||
// Type-specific validation
|
||||
const fieldErrors = [];
|
||||
|
||||
if (type === 'email') {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(value)) {
|
||||
fieldErrors.push('Please enter a valid email address');
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'password') {
|
||||
if (validation.minLength && value.length < validation.minLength) {
|
||||
fieldErrors.push(
|
||||
`Password must be at least ${validation.minLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (type === 'text' || type === 'textarea') {
|
||||
if (validation.minLength && value.length < validation.minLength) {
|
||||
fieldErrors.push(
|
||||
`${label || id} must be at least ${validation.minLength} characters`
|
||||
);
|
||||
}
|
||||
if (validation.maxLength && value.length > validation.maxLength) {
|
||||
fieldErrors.push(
|
||||
`${label || id} must be at most ${validation.maxLength} characters`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Match field validation (for confirmPassword)
|
||||
if (validation.matchField) {
|
||||
if (value !== formData[validation.matchField]) {
|
||||
fieldErrors.push('Passwords do not match');
|
||||
}
|
||||
}
|
||||
|
||||
if (fieldErrors.length > 0) {
|
||||
errors[id] = fieldErrors;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isValid: Object.keys(errors).length === 0,
|
||||
errors,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Evaluate conditional rules to get hidden fields set
|
||||
*/
|
||||
export const evaluateConditionalRules = (schema, formData) => {
|
||||
const rules = schema?.conditional_rules || [];
|
||||
const hidden = new Set();
|
||||
|
||||
// First pass: collect fields that have "show" rules (hidden by default)
|
||||
for (const rule of rules) {
|
||||
if (rule.action === 'show') {
|
||||
rule.target_fields?.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass: evaluate rules and show/hide fields
|
||||
for (const rule of rules) {
|
||||
const {
|
||||
trigger_field,
|
||||
trigger_operator = 'equals',
|
||||
trigger_value,
|
||||
action,
|
||||
target_fields = [],
|
||||
} = rule;
|
||||
|
||||
const fieldValue = formData[trigger_field];
|
||||
let conditionMet = false;
|
||||
|
||||
switch (trigger_operator) {
|
||||
case 'equals':
|
||||
conditionMet = fieldValue === trigger_value;
|
||||
break;
|
||||
case 'not_equals':
|
||||
conditionMet = fieldValue !== trigger_value;
|
||||
break;
|
||||
case 'contains':
|
||||
conditionMet = Array.isArray(fieldValue)
|
||||
? fieldValue.includes(trigger_value)
|
||||
: String(fieldValue || '').includes(trigger_value);
|
||||
break;
|
||||
case 'not_empty':
|
||||
conditionMet = Boolean(fieldValue);
|
||||
break;
|
||||
case 'empty':
|
||||
conditionMet = !Boolean(fieldValue);
|
||||
break;
|
||||
default:
|
||||
conditionMet = false;
|
||||
}
|
||||
|
||||
if (conditionMet) {
|
||||
if (action === 'show') {
|
||||
target_fields.forEach((fieldId) => hidden.delete(fieldId));
|
||||
} else if (action === 'hide') {
|
||||
target_fields.forEach((fieldId) => hidden.add(fieldId));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return hidden;
|
||||
};
|
||||
|
||||
export default DynamicRegistrationForm;
|
||||
@@ -26,7 +26,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="space-y-8">
|
||||
{/* Personal Information */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Personal Information
|
||||
</h2>
|
||||
|
||||
@@ -40,7 +40,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="first-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -52,7 +52,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="last-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -69,7 +69,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.phone}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="phone-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -82,7 +82,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.date_of_birth}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="dob-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.address}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#EAE0D5] focus:border-[#E07A5F]"
|
||||
className="h-14 rounded-xl border-2 border-var[(--neutral-300)] focus:border-[var(--orange-soft)]"
|
||||
data-testid="address-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -112,7 +112,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.city}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="city-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -124,7 +124,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.state}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="state-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -136,7 +136,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
required
|
||||
value={formData.zipcode}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="zipcode-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -145,7 +145,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* How Did You Hear About Us */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
How Did You Hear About Us? *
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
@@ -167,7 +167,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Partner Information */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Partner Information (Optional)
|
||||
</h2>
|
||||
|
||||
@@ -179,7 +179,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="partner_first_name"
|
||||
value={formData.partner_first_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="partner-first-name-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -190,7 +190,7 @@ const RegistrationStep1 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="partner_last_name"
|
||||
value={formData.partner_last_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="partner-last-name-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -33,10 +33,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="space-y-8">
|
||||
{/* Newsletter Publication Preferences */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Newsletter Publication Preferences *
|
||||
</h2>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Please check what information may be published in LOAF Newsletter
|
||||
</p>
|
||||
|
||||
@@ -97,7 +97,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Referral */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Referral
|
||||
</h2>
|
||||
<div>
|
||||
@@ -110,10 +110,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
value={formData.referred_by_member_name}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter member name or email"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="referral-input"
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If referred by a current member, you may skip the event attendance requirement.
|
||||
</p>
|
||||
</div>
|
||||
@@ -121,10 +121,10 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Volunteer Interests */}
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Volunteer Interests (Optional)
|
||||
</h2>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
I may at some time be interested in volunteering with LOAF in the following ways (training is provided)
|
||||
</p>
|
||||
|
||||
@@ -158,7 +158,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
I am requesting for scholarship
|
||||
</Label>
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Scholarship information is kept confidential
|
||||
</p>
|
||||
|
||||
@@ -174,7 +174,7 @@ const RegistrationStep2 = ({ formData, setFormData, handleInputChange }) => {
|
||||
onChange={handleInputChange}
|
||||
placeholder="Tell us why you're requesting a scholarship..."
|
||||
rows={4}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -23,11 +23,11 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Members Directory
|
||||
</h2>
|
||||
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Would you like to be displayed on our private members directory? (optional and you can change the answer later)
|
||||
</p>
|
||||
|
||||
@@ -37,8 +37,8 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
className={`
|
||||
p-4 rounded-xl border-2 cursor-pointer transition-all
|
||||
${formData.show_in_directory
|
||||
? 'border-[#ff9e77] bg-[#ff9e77]/5'
|
||||
: 'border-[#ddd8eb] hover:border-[#664fa3]'
|
||||
? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5'
|
||||
: 'border-[var(--neutral-800)] hover:border-brand-purple '
|
||||
}
|
||||
`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, show_in_directory: true }))}
|
||||
@@ -46,13 +46,13 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`
|
||||
w-5 h-5 rounded-full border-2 flex items-center justify-center
|
||||
${formData.show_in_directory ? 'border-[#ff9e77]' : 'border-[#ddd8eb]'}
|
||||
${formData.show_in_directory ? 'border-[var(--orange-light)]' : 'border-[var(--neutral-800)]'}
|
||||
`}>
|
||||
{formData.show_in_directory && (
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff9e77]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[var(--orange-light)]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Yes, include me in the Members Directory
|
||||
</span>
|
||||
</div>
|
||||
@@ -62,8 +62,8 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
className={`
|
||||
p-4 rounded-xl border-2 cursor-pointer transition-all
|
||||
${!formData.show_in_directory
|
||||
? 'border-[#ff9e77] bg-[#ff9e77]/5'
|
||||
: 'border-[#ddd8eb] hover:border-[#664fa3]'
|
||||
? 'border-[var(--orange-light)] bg-[var(--orange-light)]/5'
|
||||
: 'border-[var(--neutral-800)] hover:border-brand-purple '
|
||||
}
|
||||
`}
|
||||
onClick={() => setFormData(prev => ({ ...prev, show_in_directory: false }))}
|
||||
@@ -71,13 +71,13 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className={`
|
||||
w-5 h-5 rounded-full border-2 flex items-center justify-center
|
||||
${!formData.show_in_directory ? 'border-[#ff9e77]' : 'border-[#ddd8eb]'}
|
||||
${!formData.show_in_directory ? 'border-[var(--orange-light)]' : 'border-[var(--neutral-800)]'}
|
||||
`}>
|
||||
{!formData.show_in_directory && (
|
||||
<div className="w-3 h-3 rounded-full bg-[#ff9e77]" />
|
||||
<div className="w-3 h-3 rounded-full bg-[var(--orange-light)]" />
|
||||
)}
|
||||
</div>
|
||||
<span className="font-medium text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No, don't include me in the Members Directory
|
||||
</span>
|
||||
</div>
|
||||
@@ -87,8 +87,8 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
|
||||
{/* Conditional Directory Fields */}
|
||||
{formData.show_in_directory && (
|
||||
<div className="space-y-4 mt-6 p-6 bg-white rounded-xl border border-[#ddd8eb]">
|
||||
<p className="text-[#664fa3] text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="space-y-4 mt-6 p-6 bg-background rounded-xl border border-[var(--neutral-800)]">
|
||||
<p className="text-brand-purple text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Below, choose what information you would like include in the Members Only Directory.
|
||||
(If you ever want to update this information, remember the Directory Section and Account Section are separate)
|
||||
</p>
|
||||
@@ -101,7 +101,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
type="email"
|
||||
value={formData.directory_email}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -114,7 +114,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
onChange={handleInputChange}
|
||||
placeholder="Tell other members about yourself..."
|
||||
rows={4}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="directory_address"
|
||||
value={formData.directory_address}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -137,7 +137,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
type="tel"
|
||||
value={formData.directory_phone}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -149,7 +149,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
type="date"
|
||||
value={formData.directory_dob}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -162,7 +162,7 @@ const RegistrationStep3 = ({ formData, setFormData, handleInputChange }) => {
|
||||
name="directory_partner_name"
|
||||
value={formData.directory_partner_name}
|
||||
onChange={handleInputChange}
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,11 +7,11 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Account Credentials
|
||||
</h2>
|
||||
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your email is also your username that you can use to login.
|
||||
Please note you can only login after your application is validated.
|
||||
</p>
|
||||
@@ -28,7 +28,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="your.email@example.com"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="email-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -43,10 +43,10 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="At least 6 characters"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="password-input"
|
||||
/>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Must be at least 6 characters long
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,7 +60,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter your password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="confirm-password-input"
|
||||
/>
|
||||
{formData.confirmPassword && formData.password !== formData.confirmPassword && (
|
||||
@@ -71,7 +71,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
</div>
|
||||
|
||||
{/* Terms of Service Acceptance */}
|
||||
<div className="p-4 bg-[#F8F7FB] rounded-lg border border-[#ddd8eb]">
|
||||
<div className="p-4 bg-[var(--lavender-500)] rounded-lg border border-[var(--neutral-800)]">
|
||||
<div className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -79,7 +79,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
name="accepts_tos"
|
||||
checked={formData.accepts_tos || false}
|
||||
onChange={handleInputChange}
|
||||
className="mt-1 w-4 h-4 text-[#664fa3] border-gray-300 rounded focus:ring-[#664fa3]"
|
||||
className="mt-1 w-4 h-4 text-brand-purple border-gray-300 rounded focus:ring-brand-purple "
|
||||
required
|
||||
data-testid="tos-checkbox"
|
||||
/>
|
||||
@@ -89,7 +89,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
href="/become-a-member/terms-of-service"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
Terms of Service
|
||||
</a>
|
||||
@@ -98,7 +98,7 @@ const RegistrationStep4 = ({ formData, handleInputChange }) => {
|
||||
href="become-a-member/privacy-policy"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</a>
|
||||
|
||||
@@ -20,17 +20,17 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
|
||||
w-12 h-12 rounded-full flex items-center justify-center font-semibold text-lg
|
||||
transition-all duration-300
|
||||
${currentStep === step.number
|
||||
? 'bg-[#ff9e77] text-white scale-110 shadow-lg'
|
||||
? 'bg-[var(--orange-light)] text-white scale-110 shadow-lg'
|
||||
: currentStep > step.number
|
||||
? 'bg-[#81B29A] text-white'
|
||||
: 'bg-[#ddd8eb] text-[#664fa3]'
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: 'bg-[var(--neutral-800)] text-brand-purple '
|
||||
}
|
||||
`}>
|
||||
{currentStep > step.number ? '✓' : step.number}
|
||||
</div>
|
||||
<span className={`
|
||||
text-sm mt-2 font-medium transition-colors
|
||||
${currentStep === step.number ? 'text-[#ff9e77]' : 'text-[#664fa3]'}
|
||||
${currentStep === step.number ? 'text-[var(--orange-light)]' : 'text-brand-purple '}
|
||||
`} style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{step.title}
|
||||
</span>
|
||||
@@ -38,11 +38,11 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
|
||||
|
||||
{/* Connecting Line */}
|
||||
{index < steps.length - 1 && (
|
||||
<div className="flex-1 h-1 mx-2 relative -top-6 bg-[#ddd8eb]">
|
||||
<div className="flex-1 h-1 mx-2 relative -top-6 bg-[var(--neutral-800)]">
|
||||
<div
|
||||
className={`
|
||||
h-full transition-all duration-500
|
||||
${currentStep > step.number ? 'bg-[#81B29A] w-full' : 'bg-transparent w-0'}
|
||||
${currentStep > step.number ? 'bg-[var(--green-light)] w-full' : 'bg-transparent w-0'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
@@ -52,8 +52,8 @@ const RegistrationStepIndicator = ({ currentStep, totalSteps = 4 }) => {
|
||||
</div>
|
||||
|
||||
{/* Step Counter */}
|
||||
<p className="text-center text-[#664fa3] mt-6 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Step <span className="font-semibold text-[#ff9e77]">{currentStep}</span> of {totalSteps}
|
||||
<p className="text-center text-brand-purple mt-6 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Step <span className="font-semibold text-[var(--orange-light)]">{currentStep}</span> of {totalSteps}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
@@ -9,26 +9,38 @@ const badgeVariants = cva(
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
|
||||
"border-transparent bg-primary text-primary-foreground shadow ",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
green:
|
||||
"border-transparent bg-[var(--green-forest)] text-white hover:bg-[var(--green-fern)]",
|
||||
orange:
|
||||
"border-transparent bg-orange-500 text-white hover:bg-orange-500/80",
|
||||
orange2:
|
||||
"border-transparent bg-orange-100 text-orange-700 hover:bg-orange-100/80",
|
||||
pink: "border-transparent bg-[var(--pink-500)] text-white hover:bg-[var(--pink-500)]/80",
|
||||
red: "border-transparent bg-red-100 text-red-700 hover:bg-red-100/80",
|
||||
red2: "border-transparent bg-red-500 text-white hover:bg-red-500/80",
|
||||
gray: "border-transparent bg-gray-200 text-gray-700 hover:bg-gray-200/80",
|
||||
gray2: "border-transparent bg-gray-400 text-white hover:bg-gray-400/80",
|
||||
gray3:
|
||||
"border-transparent bg-gray-300 text-gray-600 hover:bg-gray-300/80",
|
||||
purple: "bg-light-lavender",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}) {
|
||||
return (<div className={cn(badgeVariants({ variant }), className)} {...props} />);
|
||||
function Badge({ className, variant, ...props }) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
export { Badge, badgeVariants };
|
||||
|
||||
@@ -1,48 +1,46 @@
|
||||
import * as React from "react"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input shadow-sm hover:bg-accent hover:text-accent-foreground",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2",
|
||||
sm: "h-8 rounded-md px-3 text-xs",
|
||||
lg: "h-10 rounded-md px-8",
|
||||
icon: "h-9 w-9",
|
||||
},
|
||||
const buttonVariants = cva("btn", {
|
||||
variants: {
|
||||
variant: {
|
||||
default: "btn-primary",
|
||||
secondary: "btn-secondary",
|
||||
ghost: "btn-ghost",
|
||||
outline: "btn-outline",
|
||||
"outline-destructive": "btn-outline-destructive",
|
||||
accent: "btn-accent",
|
||||
destructive: "btn-destructive",
|
||||
link: "btn-link",
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
size: {
|
||||
default: "btn-md",
|
||||
sm: "btn-sm",
|
||||
lg: "btn-lg",
|
||||
icon: "btn-icon",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
});
|
||||
|
||||
const Button = React.forwardRef(
|
||||
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size }), className)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
Button.displayName = "Button";
|
||||
|
||||
const Button = React.forwardRef(({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button"
|
||||
return (
|
||||
<Comp
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props} />
|
||||
);
|
||||
})
|
||||
Button.displayName = "Button"
|
||||
|
||||
export { Button, buttonVariants }
|
||||
export { Button, buttonVariants };
|
||||
|
||||
@@ -1,50 +1,65 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Card = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("rounded-xl border bg-card text-card-foreground shadow", className)}
|
||||
{...props} />
|
||||
))
|
||||
Card.displayName = "Card"
|
||||
className={cn(
|
||||
"rounded-xl border bg-card text-card-foreground shadow",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
Card.displayName = "Card";
|
||||
|
||||
const CardHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex flex-col space-y-1.5 p-6", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardHeader.displayName = "CardHeader"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardHeader.displayName = "CardHeader";
|
||||
|
||||
const CardTitle = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("font-semibold leading-none tracking-tight", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardTitle.displayName = "CardTitle"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardTitle.displayName = "CardTitle";
|
||||
|
||||
const CardDescription = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardDescription.displayName = "CardDescription"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardDescription.displayName = "CardDescription";
|
||||
|
||||
const CardContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = "CardContent"
|
||||
));
|
||||
CardContent.displayName = "CardContent";
|
||||
|
||||
const CardFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("flex items-center p-6 pt-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
CardFooter.displayName = "CardFooter"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
CardFooter.displayName = "CardFooter";
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
|
||||
@@ -50,7 +50,7 @@ CommandInput.displayName = CommandPrimitive.Input.displayName
|
||||
const CommandList = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<CommandPrimitive.List
|
||||
ref={ref}
|
||||
className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
|
||||
className={cn("max-h-[300px] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden", className)}
|
||||
{...props} />
|
||||
))
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ const ContextMenuContent = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<ContextMenuPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
"z-50 max-h-[--radix-context-menu-content-available-height] min-w-[8rem] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-context-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
{...props} />
|
||||
|
||||
@@ -20,7 +20,7 @@ const DropdownMenuSubTrigger = React.forwardRef(({ className, inset, children, .
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-brand-light-lavender data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
@@ -50,7 +50,7 @@ const DropdownMenuContent = React.forwardRef(({ className, sideOffset = 4, ...pr
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto scrollbar-dashboard scrollbar-x-dashboard overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
|
||||
className
|
||||
)}
|
||||
@@ -63,7 +63,7 @@ const DropdownMenuItem = React.forwardRef(({ className, inset, ...props }, ref)
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-brand-light-lavender focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
inset && "pl-8",
|
||||
className
|
||||
)}
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef(({ className, type, ...props }, ref) => {
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
"flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import * as React from "react"
|
||||
import { Eye, EyeOff } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react";
|
||||
import { Eye, EyeOff } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PasswordInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
const [showPassword, setShowPassword] = React.useState(false)
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
@@ -19,7 +19,7 @@ const PasswordInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[#6B708D] hover:text-[#3D405B] transition-colors focus:outline-none"
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-[var(--slate-muted)] hover:text-[var(--slate-dark)] transition-colors focus:outline-none"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showPassword ? (
|
||||
@@ -29,8 +29,8 @@ const PasswordInput = React.forwardRef(({ className, ...props }, ref) => {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
PasswordInput.displayName = "PasswordInput"
|
||||
);
|
||||
});
|
||||
PasswordInput.displayName = "PasswordInput";
|
||||
|
||||
export { PasswordInput }
|
||||
export { PasswordInput };
|
||||
|
||||
@@ -52,7 +52,7 @@ const SelectContent = React.forwardRef(({ className, children, position = "poppe
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden scrollbar-dashboard scrollbar-x-dashboard rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className
|
||||
@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 hover:text-white focus:text-white",
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center ">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
@@ -1,78 +1,91 @@
|
||||
import * as React from "react"
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Table = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
{...props} />
|
||||
<table ref={ref} className={cn("w-full", className)} {...props} />
|
||||
</div>
|
||||
))
|
||||
Table.displayName = "Table"
|
||||
));
|
||||
Table.displayName = "Table";
|
||||
|
||||
const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = "TableHeader"
|
||||
<thead
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"bg-[var(--lavender-300)] border-b border-[var(--neutral-800)]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
const TableBody = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn("[&_tr:last-child]:border-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableBody.displayName = "TableBody"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableBody.displayName = "TableBody";
|
||||
|
||||
const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableFooter.displayName = "TableFooter"
|
||||
className={cn(
|
||||
"border-t border-[var(--neutral-800)] font-medium [&>tr]:last:border-b-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableFooter.displayName = "TableFooter";
|
||||
|
||||
const TableRow = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
className
|
||||
"border-b border-[var(--neutral-800)] transition-colors hover:bg-[var(--lavender-400)]",
|
||||
className,
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableRow.displayName = "TableRow"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableRow.displayName = "TableRow";
|
||||
|
||||
const TableHead = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
"p-4 text-left align-middle font-semibold text-[var(--purple-ink)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableHead.displayName = "TableHead"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableHead.displayName = "TableHead";
|
||||
|
||||
const TableCell = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className
|
||||
"p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] ",
|
||||
className,
|
||||
)}
|
||||
{...props} />
|
||||
))
|
||||
TableCell.displayName = "TableCell"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCell.displayName = "TableCell";
|
||||
|
||||
const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props} />
|
||||
))
|
||||
TableCaption.displayName = "TableCaption"
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TableCaption.displayName = "TableCaption";
|
||||
|
||||
export {
|
||||
Table,
|
||||
@@ -83,4 +96,4 @@ export {
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@ const TabsTrigger = React.forwardRef(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"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",
|
||||
"inline-flex items-center justify-center whitespace-nowrap hover:bg-[var(--lavender-300)] border-2 border-brand-purple rounded-2xl px-3 py-1 text-brand-purple 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 dark:data-[state=active]:bg-brand-light-lavender dark:data-[state=active]:text-background dark:border-brand-light-lavender dark:text-brand-light-lavender",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
70
src/config/MemberTiers.js
Normal file
70
src/config/MemberTiers.js
Normal file
@@ -0,0 +1,70 @@
|
||||
// src/config/memberTiers.js
|
||||
|
||||
/**
|
||||
* Default member tier configuration
|
||||
* Used as fallback when API is unavailable
|
||||
* Format matches backend MemberTier interface
|
||||
*/
|
||||
export const DEFAULT_MEMBER_TIERS = [
|
||||
{
|
||||
id: 'new_member',
|
||||
label: 'New Member',
|
||||
minYears: 0,
|
||||
maxYears: 0.999,
|
||||
iconKey: 'sparkle',
|
||||
badgeClass: 'bg-blue-100 text-blue-800 border-blue-200',
|
||||
},
|
||||
{
|
||||
id: 'member_1_year',
|
||||
label: '1 Year Member',
|
||||
minYears: 1,
|
||||
maxYears: 2.999,
|
||||
iconKey: 'star',
|
||||
badgeClass: 'bg-green-100 text-green-800 border-green-200',
|
||||
},
|
||||
{
|
||||
id: 'member_3_year',
|
||||
label: '3+ Year Member',
|
||||
minYears: 3,
|
||||
maxYears: 4.999,
|
||||
iconKey: 'award',
|
||||
badgeClass: 'bg-purple-100 text-purple-800 border-purple-200',
|
||||
},
|
||||
{
|
||||
id: 'veteran',
|
||||
label: 'Veteran Member',
|
||||
minYears: 5,
|
||||
maxYears: 999,
|
||||
iconKey: 'crown',
|
||||
badgeClass: 'bg-amber-100 text-amber-800 border-amber-200',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Available icon options for tier configuration
|
||||
*/
|
||||
export const TIER_ICON_OPTIONS = [
|
||||
{ key: 'sparkle', label: 'Sparkle' },
|
||||
{ key: 'star', label: 'Star' },
|
||||
{ key: 'award', label: 'Award' },
|
||||
{ key: 'crown', label: 'Crown' },
|
||||
{ key: 'medal', label: 'Medal' },
|
||||
{ key: 'trophy', label: 'Trophy' },
|
||||
{ key: 'gem', label: 'Gem' },
|
||||
{ key: 'heart', label: 'Heart' },
|
||||
{ key: 'shield', label: 'Shield' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Available badge color presets
|
||||
*/
|
||||
export const BADGE_COLOR_PRESETS = [
|
||||
{ label: 'Blue', badgeClass: 'bg-blue-100 text-blue-800 border-blue-200' },
|
||||
{ label: 'Green', badgeClass: 'bg-green-100 text-green-800 border-green-200' },
|
||||
{ label: 'Purple', badgeClass: 'bg-purple-100 text-purple-800 border-purple-200' },
|
||||
{ label: 'Amber', badgeClass: 'bg-amber-100 text-amber-800 border-amber-200' },
|
||||
{ label: 'Red', badgeClass: 'bg-red-100 text-red-800 border-red-200' },
|
||||
{ label: 'Teal', badgeClass: 'bg-teal-100 text-teal-800 border-teal-200' },
|
||||
{ label: 'Pink', badgeClass: 'bg-pink-100 text-pink-800 border-pink-200' },
|
||||
{ label: 'Indigo', badgeClass: 'bg-indigo-100 text-indigo-800 border-indigo-200' },
|
||||
];
|
||||
29
src/config/memberTierIcons.js
Normal file
29
src/config/memberTierIcons.js
Normal file
@@ -0,0 +1,29 @@
|
||||
// src/config/memberTierIcons.js
|
||||
import { User, Star, Crown, Award, Sparkles, Medal, Trophy, Gem, Heart, Shield } from 'lucide-react';
|
||||
|
||||
/**
|
||||
* Member tier icon mapping
|
||||
* Maps iconKey strings from backend to Lucide React components
|
||||
*/
|
||||
export const MEMBER_TIER_ICONS = {
|
||||
// Primary tier icons
|
||||
sparkle: Sparkles,
|
||||
sparkles: Sparkles,
|
||||
star: Star,
|
||||
award: Award,
|
||||
crown: Crown,
|
||||
// Additional options
|
||||
medal: Medal,
|
||||
trophy: Trophy,
|
||||
gem: Gem,
|
||||
heart: Heart,
|
||||
shield: Shield,
|
||||
user: User,
|
||||
};
|
||||
|
||||
/**
|
||||
* Get icon component by key with fallback
|
||||
*/
|
||||
export const getTierIcon = (iconKey) => {
|
||||
return MEMBER_TIER_ICONS[iconKey?.toLowerCase()] || MEMBER_TIER_ICONS.sparkle;
|
||||
};
|
||||
@@ -1,12 +1,14 @@
|
||||
import React, { createContext, useState, useContext, useEffect } from 'react';
|
||||
import axios from 'axios';
|
||||
import api from '../utils/api';
|
||||
import logger from '../utils/logger';
|
||||
|
||||
const AuthContext = createContext();
|
||||
|
||||
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:', {
|
||||
logger.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
|
||||
@@ -55,31 +57,31 @@ export const AuthProvider = ({ children }) => {
|
||||
});
|
||||
setPermissions(response.data.permissions || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch permissions:', error);
|
||||
logger.error('Failed to fetch permissions:', error);
|
||||
setPermissions([]);
|
||||
}
|
||||
};
|
||||
|
||||
const login = async (email, password) => {
|
||||
try {
|
||||
console.log('[AuthContext] Starting login request...', {
|
||||
logger.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`,
|
||||
// Use api instance for retry logic
|
||||
const response = await api.post(
|
||||
'/auth/login',
|
||||
{ email, password },
|
||||
{
|
||||
timeout: 30000, // 30 second timeout
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[AuthContext] Login response received:', {
|
||||
logger.log('[AuthContext] Login response received:', {
|
||||
status: response.status,
|
||||
hasToken: !!response.data?.access_token,
|
||||
hasUser: !!response.data?.user
|
||||
@@ -87,39 +89,46 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
const { access_token, user: userData } = response.data;
|
||||
|
||||
// Store token first
|
||||
localStorage.setItem('token', access_token);
|
||||
console.log('[AuthContext] Token stored in localStorage');
|
||||
if (!access_token || !userData) {
|
||||
throw new Error('Invalid response from server - missing token or user data');
|
||||
}
|
||||
|
||||
// Update state
|
||||
// Store token FIRST and verify it was stored
|
||||
localStorage.setItem('token', access_token);
|
||||
const storedToken = localStorage.getItem('token');
|
||||
if (storedToken !== access_token) {
|
||||
throw new Error('Failed to store token in localStorage');
|
||||
}
|
||||
logger.log('[AuthContext] Token stored and verified in localStorage');
|
||||
|
||||
// Update state in correct order
|
||||
setToken(access_token);
|
||||
setUser(userData);
|
||||
console.log('[AuthContext] User state updated:', {
|
||||
logger.log('[AuthContext] User state updated:', {
|
||||
email: userData.email,
|
||||
role: userData.role
|
||||
});
|
||||
|
||||
// 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);
|
||||
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
|
||||
// Fetch permissions immediately and WAIT for it (but don't fail login if it fails)
|
||||
try {
|
||||
logger.log('[AuthContext] Fetching permissions...');
|
||||
await fetchPermissions(access_token);
|
||||
logger.log('[AuthContext] Permissions fetched successfully');
|
||||
} catch (permError) {
|
||||
logger.error('[AuthContext] Failed to fetch permissions (non-critical):', {
|
||||
message: permError.message,
|
||||
response: permError.response?.data,
|
||||
status: permError.response?.status
|
||||
});
|
||||
// Set empty permissions array so hasPermission doesn't break
|
||||
setPermissions([]);
|
||||
// Don't throw - login succeeded even if permissions failed
|
||||
}
|
||||
|
||||
return userData;
|
||||
} catch (error) {
|
||||
// Enhanced error logging
|
||||
console.error('[AuthContext] Login failed:', {
|
||||
logger.error('[AuthContext] Login failed:', {
|
||||
message: error.message,
|
||||
response: error.response?.data,
|
||||
status: error.response?.status,
|
||||
@@ -131,6 +140,12 @@ export const AuthProvider = ({ children }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Clear any partial state
|
||||
localStorage.removeItem('token');
|
||||
setToken(null);
|
||||
setUser(null);
|
||||
setPermissions([]);
|
||||
|
||||
// Re-throw to let Login component handle the error
|
||||
throw error;
|
||||
}
|
||||
@@ -160,7 +175,7 @@ export const AuthProvider = ({ children }) => {
|
||||
setUser(response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh user:', error);
|
||||
logger.error('Failed to refresh user:', error);
|
||||
// If token expired, logout
|
||||
if (error.response?.status === 401) {
|
||||
logout();
|
||||
|
||||
161
src/context/ThemeConfigContext.js
Normal file
161
src/context/ThemeConfigContext.js
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { createContext, useState, useContext, useEffect, useCallback } from 'react';
|
||||
import axios from 'axios';
|
||||
|
||||
const ThemeConfigContext = createContext();
|
||||
|
||||
const API_URL = process.env.REACT_APP_BACKEND_URL || window.location.origin;
|
||||
|
||||
const DEFAULT_THEME = {
|
||||
site_name: 'LOAF - Lesbians Over Age Fifty',
|
||||
site_short_name: 'LOAF',
|
||||
site_description: 'A community organization for lesbians over age fifty in Houston and surrounding areas.',
|
||||
logo_url: null,
|
||||
favicon_url: null,
|
||||
colors: {
|
||||
primary: '280 47% 27%',
|
||||
primary_foreground: '0 0% 100%',
|
||||
accent: '24 86% 55%',
|
||||
brand_purple: '256 35% 47%',
|
||||
brand_orange: '24 86% 55%',
|
||||
brand_lavender: '262 46% 80%'
|
||||
},
|
||||
meta_theme_color: '#664fa3'
|
||||
};
|
||||
|
||||
export const ThemeConfigProvider = ({ children }) => {
|
||||
const [themeConfig, setThemeConfig] = useState(DEFAULT_THEME);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const applyThemeToDOM = useCallback((config) => {
|
||||
// Apply CSS variables for colors
|
||||
if (config.colors) {
|
||||
const root = document.documentElement;
|
||||
Object.entries(config.colors).forEach(([key, value]) => {
|
||||
// Convert snake_case to kebab-case for CSS variable names
|
||||
const cssVarName = `--${key.replace(/_/g, '-')}`;
|
||||
root.style.setProperty(cssVarName, value);
|
||||
});
|
||||
}
|
||||
|
||||
// Update favicon
|
||||
if (config.favicon_url) {
|
||||
let link = document.querySelector("link[rel*='icon']");
|
||||
if (!link) {
|
||||
link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
link.href = config.favicon_url;
|
||||
}
|
||||
|
||||
// Update document title
|
||||
if (config.site_name) {
|
||||
document.title = config.site_name;
|
||||
// Also store for use by pages that want to append their own title
|
||||
window.__SITE_NAME__ = config.site_name;
|
||||
}
|
||||
|
||||
// Update meta description
|
||||
if (config.site_description) {
|
||||
let metaDesc = document.querySelector("meta[name='description']");
|
||||
if (!metaDesc) {
|
||||
metaDesc = document.createElement('meta');
|
||||
metaDesc.name = 'description';
|
||||
document.head.appendChild(metaDesc);
|
||||
}
|
||||
metaDesc.content = config.site_description;
|
||||
}
|
||||
|
||||
// Update meta theme-color for PWA
|
||||
if (config.meta_theme_color) {
|
||||
let meta = document.querySelector("meta[name='theme-color']");
|
||||
if (!meta) {
|
||||
meta = document.createElement('meta');
|
||||
meta.name = 'theme-color';
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
meta.content = config.meta_theme_color;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchThemeConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await axios.get(`${API_URL}/api/config/theme`);
|
||||
const config = { ...DEFAULT_THEME, ...response.data };
|
||||
|
||||
// Merge colors if provided
|
||||
if (response.data.colors) {
|
||||
config.colors = { ...DEFAULT_THEME.colors, ...response.data.colors };
|
||||
}
|
||||
|
||||
setThemeConfig(config);
|
||||
applyThemeToDOM(config);
|
||||
} catch (err) {
|
||||
console.warn('Failed to fetch theme config, using defaults:', err.message);
|
||||
setError(err.message);
|
||||
// Apply default theme to DOM
|
||||
applyThemeToDOM(DEFAULT_THEME);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyThemeToDOM]);
|
||||
|
||||
// Fetch theme config on mount
|
||||
useEffect(() => {
|
||||
fetchThemeConfig();
|
||||
}, [fetchThemeConfig]);
|
||||
|
||||
// Helper function to get logo URL with fallback
|
||||
const getLogoUrl = useCallback(() => {
|
||||
return themeConfig.logo_url || `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
}, [themeConfig.logo_url]);
|
||||
|
||||
// Helper function to get favicon URL with fallback
|
||||
const getFaviconUrl = useCallback(() => {
|
||||
return themeConfig.favicon_url || `${process.env.PUBLIC_URL}/favicon.ico`;
|
||||
}, [themeConfig.favicon_url]);
|
||||
|
||||
const value = {
|
||||
// Theme configuration
|
||||
themeConfig,
|
||||
loading,
|
||||
error,
|
||||
|
||||
// Convenience accessors
|
||||
siteName: themeConfig.site_name,
|
||||
siteShortName: themeConfig.site_short_name,
|
||||
siteDescription: themeConfig.site_description,
|
||||
colors: themeConfig.colors,
|
||||
metaThemeColor: themeConfig.meta_theme_color,
|
||||
|
||||
// Helper functions
|
||||
getLogoUrl,
|
||||
getFaviconUrl,
|
||||
|
||||
// Actions
|
||||
refreshTheme: fetchThemeConfig,
|
||||
|
||||
// Default theme for reference
|
||||
DEFAULT_THEME
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeConfigContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeConfigContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useThemeConfig = () => {
|
||||
const context = useContext(ThemeConfigContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useThemeConfig must be used within a ThemeConfigProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default ThemeConfigContext;
|
||||
93
src/context/UsersContext.js
Normal file
93
src/context/UsersContext.js
Normal file
@@ -0,0 +1,93 @@
|
||||
import React, { createContext, useState, useContext, useEffect, useCallback, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../utils/api';
|
||||
|
||||
const UsersContext = createContext();
|
||||
|
||||
// Role definitions
|
||||
const STAFF_ROLES = ['admin', 'superadmin', 'finance'];
|
||||
const MEMBER_ROLES = ['member'];
|
||||
|
||||
export const UsersProvider = ({ children }) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchUsers = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get('/admin/users');
|
||||
setUsers(response.data);
|
||||
} catch (err) {
|
||||
setError(err);
|
||||
toast.error('Failed to fetch users');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [fetchUsers]);
|
||||
|
||||
// Filtered views based on role
|
||||
const staff = useMemo(
|
||||
() => users.filter(user => STAFF_ROLES.includes(user.role)),
|
||||
[users]
|
||||
);
|
||||
|
||||
const members = useMemo(
|
||||
() => users.filter(user => MEMBER_ROLES.includes(user.role)),
|
||||
[users]
|
||||
);
|
||||
|
||||
const allUsers = users;
|
||||
|
||||
// Update a single user in the local state (useful after edits)
|
||||
const updateUser = useCallback((updatedUser) => {
|
||||
setUsers(prev => prev.map(user =>
|
||||
user.id === updatedUser.id ? updatedUser : user
|
||||
));
|
||||
}, []);
|
||||
|
||||
// Remove a user from local state
|
||||
const removeUser = useCallback((userId) => {
|
||||
setUsers(prev => prev.filter(user => user.id !== userId));
|
||||
}, []);
|
||||
|
||||
// Add a user to local state
|
||||
const addUser = useCallback((newUser) => {
|
||||
setUsers(prev => [...prev, newUser]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<UsersContext.Provider value={{
|
||||
// All data
|
||||
users: allUsers,
|
||||
staff,
|
||||
members,
|
||||
// State
|
||||
loading,
|
||||
error,
|
||||
// Actions
|
||||
refetch: fetchUsers,
|
||||
updateUser,
|
||||
removeUser,
|
||||
addUser,
|
||||
}}>
|
||||
{children}
|
||||
</UsersContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// Base hook to access the context
|
||||
export const useUsers = () => {
|
||||
const context = useContext(UsersContext);
|
||||
if (!context) {
|
||||
throw new Error('useUsers must be used within a UsersProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export default UsersContext;
|
||||
92
src/hooks/use-directory-config.js
Normal file
92
src/hooks/use-directory-config.js
Normal file
@@ -0,0 +1,92 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../utils/api';
|
||||
|
||||
/**
|
||||
* Default directory configuration - used as fallback if API fails
|
||||
*/
|
||||
const DEFAULT_DIRECTORY_CONFIG = {
|
||||
fields: {
|
||||
show_in_directory: { enabled: true, label: 'Show in Directory', required: false },
|
||||
directory_email: { enabled: true, label: 'Directory Email', required: false },
|
||||
directory_bio: { enabled: true, label: 'Bio', required: false },
|
||||
directory_address: { enabled: true, label: 'Address', required: false },
|
||||
directory_phone: { enabled: true, label: 'Phone', required: false },
|
||||
directory_dob: { enabled: true, label: 'Birthday', required: false },
|
||||
directory_partner_name: { enabled: true, label: 'Partner Name', required: false },
|
||||
volunteer_interests: { enabled: true, label: 'Volunteer Interests', required: false },
|
||||
social_media: { enabled: true, label: 'Social Media Links', required: false },
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook to fetch and manage directory field configuration
|
||||
* @returns {Object} - { config, loading, error, isFieldEnabled, getFieldLabel, refetch }
|
||||
*/
|
||||
const useDirectoryConfig = () => {
|
||||
const [config, setConfig] = useState(DEFAULT_DIRECTORY_CONFIG);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get('/directory/config');
|
||||
setConfig(response.data || DEFAULT_DIRECTORY_CONFIG);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch directory config:', err);
|
||||
setError(err);
|
||||
// Use default config on error
|
||||
setConfig(DEFAULT_DIRECTORY_CONFIG);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
/**
|
||||
* Check if a field is enabled in the config
|
||||
* @param {string} fieldId - The field ID to check (e.g., 'directory_email')
|
||||
* @returns {boolean} - Whether the field is enabled
|
||||
*/
|
||||
const isFieldEnabled = useCallback((fieldId) => {
|
||||
const field = config?.fields?.[fieldId];
|
||||
return field?.enabled !== false; // Default to true if not specified
|
||||
}, [config]);
|
||||
|
||||
/**
|
||||
* Get the label for a field
|
||||
* @param {string} fieldId - The field ID
|
||||
* @param {string} defaultLabel - Default label if not in config
|
||||
* @returns {string} - The field label
|
||||
*/
|
||||
const getFieldLabel = useCallback((fieldId, defaultLabel = '') => {
|
||||
const field = config?.fields?.[fieldId];
|
||||
return field?.label || defaultLabel;
|
||||
}, [config]);
|
||||
|
||||
/**
|
||||
* Check if a field is required
|
||||
* @param {string} fieldId - The field ID
|
||||
* @returns {boolean} - Whether the field is required
|
||||
*/
|
||||
const isFieldRequired = useCallback((fieldId) => {
|
||||
const field = config?.fields?.[fieldId];
|
||||
return field?.required === true;
|
||||
}, [config]);
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
error,
|
||||
isFieldEnabled,
|
||||
getFieldLabel,
|
||||
isFieldRequired,
|
||||
refetch: fetchConfig
|
||||
};
|
||||
};
|
||||
|
||||
export default useDirectoryConfig;
|
||||
106
src/hooks/use-member-tiers.js
Normal file
106
src/hooks/use-member-tiers.js
Normal file
@@ -0,0 +1,106 @@
|
||||
// src/hooks/use-member-tiers.js
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../utils/api';
|
||||
import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
|
||||
|
||||
/**
|
||||
* Hook for fetching and managing member tier configuration
|
||||
* @param {Object} options
|
||||
* @param {boolean} options.isAdmin - Whether to use admin endpoint (includes metadata)
|
||||
* @returns {Object} Tier state and methods
|
||||
*/
|
||||
const useMemberTiers = ({ isAdmin = false } = {}) => {
|
||||
const [tiers, setTiers] = useState(DEFAULT_MEMBER_TIERS);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const endpoint = isAdmin
|
||||
? '/admin/settings/member-tiers'
|
||||
: '/settings/member-tiers';
|
||||
|
||||
const fetchTiers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get(endpoint);
|
||||
const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
|
||||
setTiers(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch member tiers:', err);
|
||||
setError('Failed to load member tiers');
|
||||
// Use defaults on error
|
||||
setTiers(DEFAULT_MEMBER_TIERS);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [endpoint]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTiers();
|
||||
}, [fetchTiers]);
|
||||
|
||||
/**
|
||||
* Update tier configuration (admin only)
|
||||
* @param {Array} newTiers - Updated tier array
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
const updateTiers = useCallback(async (newTiers) => {
|
||||
if (!isAdmin) {
|
||||
console.error('updateTiers requires admin access');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await api.put('/admin/settings/member-tiers', { tiers: newTiers });
|
||||
setTiers(newTiers);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to update member tiers:', err);
|
||||
setError('Failed to save member tiers');
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
/**
|
||||
* Reset tiers to defaults (superadmin only)
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
const resetToDefaults = useCallback(async () => {
|
||||
if (!isAdmin) {
|
||||
console.error('resetToDefaults requires admin access');
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
const response = await api.post('/admin/settings/member-tiers/reset');
|
||||
const data = response.data?.tiers || response.data || DEFAULT_MEMBER_TIERS;
|
||||
setTiers(data);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('Failed to reset member tiers:', err);
|
||||
setError('Failed to reset member tiers');
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [isAdmin]);
|
||||
|
||||
return {
|
||||
tiers,
|
||||
loading,
|
||||
error,
|
||||
saving,
|
||||
fetchTiers,
|
||||
updateTiers,
|
||||
resetToDefaults,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMemberTiers;
|
||||
90
src/hooks/use-members.js
Normal file
90
src/hooks/use-members.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import api from '../utils/api';
|
||||
|
||||
const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
|
||||
|
||||
/**
|
||||
* Hook for fetching users from a custom endpoint (e.g., member-facing directory).
|
||||
* For admin pages, use hooks from use-users.js instead which share a centralized context.
|
||||
*/
|
||||
const useMembers = ({
|
||||
endpoint = '/admin/users',
|
||||
initialFilter = 'active',
|
||||
initialSearch = '',
|
||||
filterKey = 'status',
|
||||
allowedRoles = ['member'],
|
||||
searchFields = DEFAULT_SEARCH_FIELDS,
|
||||
fetchErrorMessage = 'Failed to fetch members',
|
||||
searchAccessor,
|
||||
transform,
|
||||
onFetchError,
|
||||
} = {}) => {
|
||||
const [users, setUsers] = useState([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [filterValue, setFilterValue] = useState(initialFilter);
|
||||
|
||||
const fetchMembers = useCallback(async () => {
|
||||
try {
|
||||
const response = await api.get(endpoint);
|
||||
let filtered = response.data;
|
||||
if (typeof transform === 'function') {
|
||||
filtered = transform(filtered);
|
||||
}
|
||||
if (allowedRoles && allowedRoles.length) {
|
||||
filtered = filtered.filter(user => allowedRoles.includes(user.role));
|
||||
}
|
||||
setUsers(filtered);
|
||||
} catch (error) {
|
||||
if (typeof onFetchError === 'function') {
|
||||
onFetchError(error);
|
||||
} else {
|
||||
toast.error(fetchErrorMessage);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [allowedRoles, endpoint, fetchErrorMessage, onFetchError, transform]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMembers();
|
||||
}, [fetchMembers]);
|
||||
|
||||
useEffect(() => {
|
||||
let filtered = users;
|
||||
|
||||
if (filterValue && filterValue !== 'all') {
|
||||
filtered = filtered.filter(user => user[filterKey] === filterValue);
|
||||
}
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(user => {
|
||||
const values = typeof searchAccessor === 'function'
|
||||
? searchAccessor(user)
|
||||
: searchFields.map(field => user?.[field]);
|
||||
|
||||
return values
|
||||
.filter(Boolean)
|
||||
.some(value => value.toString().toLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
|
||||
|
||||
return {
|
||||
users,
|
||||
filteredUsers,
|
||||
loading,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
fetchMembers,
|
||||
};
|
||||
};
|
||||
|
||||
export default useMembers;
|
||||
91
src/hooks/use-stripe-config.js
Normal file
91
src/hooks/use-stripe-config.js
Normal file
@@ -0,0 +1,91 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
import api from '../utils/api';
|
||||
|
||||
// Cache the stripe promise to avoid multiple loads
|
||||
let stripePromiseCache = null;
|
||||
let cachedPublishableKey = null;
|
||||
|
||||
/**
|
||||
* Hook to get Stripe configuration from the backend.
|
||||
*
|
||||
* Returns the Stripe publishable key and a pre-initialized Stripe promise.
|
||||
* The publishable key is fetched from the backend API, allowing admins
|
||||
* to configure it through the admin panel instead of environment variables.
|
||||
*/
|
||||
const useStripeConfig = () => {
|
||||
const [publishableKey, setPublishableKey] = useState(cachedPublishableKey);
|
||||
const [stripePromise, setStripePromise] = useState(stripePromiseCache);
|
||||
const [loading, setLoading] = useState(!cachedPublishableKey);
|
||||
const [error, setError] = useState(null);
|
||||
const [environment, setEnvironment] = useState(null);
|
||||
|
||||
const fetchConfig = useCallback(async () => {
|
||||
// If we already have a cached key, use it
|
||||
if (cachedPublishableKey && stripePromiseCache) {
|
||||
setPublishableKey(cachedPublishableKey);
|
||||
setStripePromise(stripePromiseCache);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const response = await api.get('/config/stripe');
|
||||
const { publishable_key, environment: env } = response.data;
|
||||
|
||||
// Cache the key and stripe promise
|
||||
cachedPublishableKey = publishable_key;
|
||||
stripePromiseCache = loadStripe(publishable_key);
|
||||
|
||||
setPublishableKey(publishable_key);
|
||||
setStripePromise(stripePromiseCache);
|
||||
setEnvironment(env);
|
||||
} catch (err) {
|
||||
console.error('[useStripeConfig] Failed to fetch Stripe config:', err);
|
||||
setError(err.response?.data?.detail || 'Failed to load Stripe configuration');
|
||||
|
||||
// Fallback to environment variable if available
|
||||
const envKey = process.env.REACT_APP_STRIPE_PUBLISHABLE_KEY;
|
||||
if (envKey) {
|
||||
console.warn('[useStripeConfig] Falling back to environment variable');
|
||||
cachedPublishableKey = envKey;
|
||||
stripePromiseCache = loadStripe(envKey);
|
||||
setPublishableKey(envKey);
|
||||
setStripePromise(stripePromiseCache);
|
||||
setEnvironment(envKey.startsWith('pk_live_') ? 'live' : 'test');
|
||||
setError(null);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
// Function to clear cache (useful after admin updates settings)
|
||||
const clearCache = useCallback(() => {
|
||||
cachedPublishableKey = null;
|
||||
stripePromiseCache = null;
|
||||
setPublishableKey(null);
|
||||
setStripePromise(null);
|
||||
fetchConfig();
|
||||
}, [fetchConfig]);
|
||||
|
||||
return {
|
||||
publishableKey,
|
||||
stripePromise,
|
||||
loading,
|
||||
error,
|
||||
environment,
|
||||
refetch: fetchConfig,
|
||||
clearCache,
|
||||
isConfigured: !!publishableKey,
|
||||
};
|
||||
};
|
||||
|
||||
export default useStripeConfig;
|
||||
171
src/hooks/use-users.js
Normal file
171
src/hooks/use-users.js
Normal file
@@ -0,0 +1,171 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { useUsers } from '../context/UsersContext';
|
||||
|
||||
const DEFAULT_SEARCH_FIELDS = ['first_name', 'last_name', 'email'];
|
||||
|
||||
/**
|
||||
* Base hook that adds search and filter functionality to any user list
|
||||
*/
|
||||
const useFilteredUsers = ({
|
||||
users,
|
||||
initialFilter = 'all',
|
||||
filterKey = 'status',
|
||||
searchFields = DEFAULT_SEARCH_FIELDS,
|
||||
searchAccessor,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterValue, setFilterValue] = useState(initialFilter);
|
||||
|
||||
const filteredUsers = useMemo(() => {
|
||||
let filtered = users;
|
||||
|
||||
// Apply filter
|
||||
if (filterValue && filterValue !== 'all') {
|
||||
filtered = filtered.filter(user => user[filterKey] === filterValue);
|
||||
}
|
||||
|
||||
// Apply search
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
filtered = filtered.filter(user => {
|
||||
const values = typeof searchAccessor === 'function'
|
||||
? searchAccessor(user)
|
||||
: searchFields.map(field => user?.[field]);
|
||||
|
||||
return values
|
||||
.filter(Boolean)
|
||||
.some(value => value.toString().toLowerCase().includes(query));
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [users, searchQuery, filterKey, filterValue, searchAccessor, searchFields]);
|
||||
|
||||
return {
|
||||
filteredUsers,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for staff users (admin, superadmin, finance roles)
|
||||
*/
|
||||
export const useStaff = ({
|
||||
initialFilter = 'all',
|
||||
filterKey = 'role',
|
||||
searchFields = DEFAULT_SEARCH_FIELDS,
|
||||
searchAccessor,
|
||||
} = {}) => {
|
||||
const { staff, loading, error, refetch, updateUser, removeUser } = useUsers();
|
||||
|
||||
const {
|
||||
filteredUsers,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
} = useFilteredUsers({
|
||||
users: staff,
|
||||
initialFilter,
|
||||
filterKey,
|
||||
searchFields,
|
||||
searchAccessor,
|
||||
});
|
||||
|
||||
return {
|
||||
users: staff,
|
||||
filteredUsers,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
refetch,
|
||||
updateUser,
|
||||
removeUser,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for member users (non-admin roles)
|
||||
*/
|
||||
export const useMembers = ({
|
||||
initialFilter = 'active',
|
||||
filterKey = 'status',
|
||||
searchFields = DEFAULT_SEARCH_FIELDS,
|
||||
searchAccessor,
|
||||
} = {}) => {
|
||||
const { members, loading, error, refetch, updateUser, removeUser } = useUsers();
|
||||
|
||||
const {
|
||||
filteredUsers,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
} = useFilteredUsers({
|
||||
users: members,
|
||||
initialFilter,
|
||||
filterKey,
|
||||
searchFields,
|
||||
searchAccessor,
|
||||
});
|
||||
|
||||
return {
|
||||
users: members,
|
||||
filteredUsers,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
refetch,
|
||||
updateUser,
|
||||
removeUser,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Hook for all users (both staff and members)
|
||||
*/
|
||||
export const useAllUsers = ({
|
||||
initialFilter = 'all',
|
||||
filterKey = 'status',
|
||||
searchFields = DEFAULT_SEARCH_FIELDS,
|
||||
searchAccessor,
|
||||
} = {}) => {
|
||||
const { users, loading, error, refetch, updateUser, removeUser } = useUsers();
|
||||
|
||||
const {
|
||||
filteredUsers,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
} = useFilteredUsers({
|
||||
users,
|
||||
initialFilter,
|
||||
filterKey,
|
||||
searchFields,
|
||||
searchAccessor,
|
||||
});
|
||||
|
||||
return {
|
||||
users,
|
||||
filteredUsers,
|
||||
loading,
|
||||
error,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filterValue,
|
||||
setFilterValue,
|
||||
refetch,
|
||||
updateUser,
|
||||
removeUser,
|
||||
};
|
||||
};
|
||||
152
src/index.css
152
src/index.css
@@ -1,142 +1,10 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Poppins:wght@400;500;600;700&display=swap");
|
||||
|
||||
@import url("https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,200..1000;1,6..12,200..1000&display=swap");
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
|
||||
monospace;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 280 47% 27%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 280 47% 27%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 280 47% 27%;
|
||||
--primary: 280 47% 27%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 268 33% 89%;
|
||||
--secondary-foreground: 280 47% 27%;
|
||||
--muted: 268 43% 95%;
|
||||
--muted-foreground: 268 35% 47%;
|
||||
--accent: 17 100% 73%;
|
||||
--accent-foreground: 280 47% 27%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 268 33% 89%;
|
||||
--input: 268 33% 89%;
|
||||
--ring: 268 35% 47%;
|
||||
--chart-1: 268 36% 46%;
|
||||
--chart-2: 17 100% 73%;
|
||||
--chart-3: 268 33% 89%;
|
||||
--chart-4: 280 44% 29%;
|
||||
--chart-5: 268 35% 47%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
.dark {
|
||||
--background: 0 0% 3.9%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 0 0% 3.9%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 0 0% 3.9%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 0 0% 98%;
|
||||
--primary-foreground: 0 0% 9%;
|
||||
--secondary: 0 0% 14.9%;
|
||||
--secondary-foreground: 0 0% 98%;
|
||||
--muted: 0 0% 14.9%;
|
||||
--muted-foreground: 0 0% 63.9%;
|
||||
--accent: 0 0% 14.9%;
|
||||
--accent-foreground: 0 0% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 0 0% 98%;
|
||||
--border: 0 0% 14.9%;
|
||||
--input: 0 0% 14.9%;
|
||||
--ring: 0 0% 83.1%;
|
||||
--chart-1: 220 70% 50%;
|
||||
--chart-2: 160 60% 45%;
|
||||
--chart-3: 30 80% 55%;
|
||||
--chart-4: 280 65% 60%;
|
||||
--chart-5: 340 75% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
[data-debug-wrapper="true"] {
|
||||
display: contents !important;
|
||||
}
|
||||
|
||||
[data-debug-wrapper="true"] > * {
|
||||
margin-left: inherit;
|
||||
margin-right: inherit;
|
||||
margin-top: inherit;
|
||||
margin-bottom: inherit;
|
||||
padding-left: inherit;
|
||||
padding-right: inherit;
|
||||
padding-top: inherit;
|
||||
padding-bottom: inherit;
|
||||
column-gap: inherit;
|
||||
row-gap: inherit;
|
||||
gap: inherit;
|
||||
border-left-width: inherit;
|
||||
border-right-width: inherit;
|
||||
border-top-width: inherit;
|
||||
border-bottom-width: inherit;
|
||||
border-left-style: inherit;
|
||||
border-right-style: inherit;
|
||||
border-top-style: inherit;
|
||||
border-bottom-style: inherit;
|
||||
border-left-color: inherit;
|
||||
border-right-color: inherit;
|
||||
border-top-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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@import "./styles/App.css";
|
||||
@import "./styles/theme.css";
|
||||
@import "./styles/components.css";
|
||||
@import "./styles/base.css";
|
||||
@import "./styles/utilities.css";
|
||||
/*
|
||||
=========================
|
||||
End of File
|
||||
========================
|
||||
*/
|
||||
|
||||
13
src/index.js
13
src/index.js
@@ -1,5 +1,7 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { ThemeConfigProvider } from './context/ThemeConfigContext';
|
||||
import '@fontsource/fraunces/600.css';
|
||||
import '@fontsource/dm-sans/400.css';
|
||||
import '@fontsource/dm-sans/700.css';
|
||||
@@ -9,6 +11,15 @@ import App from './App';
|
||||
const root = ReactDOM.createRoot(document.getElementById('root'));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ThemeProvider
|
||||
attribute="data-theme"
|
||||
defaultTheme="light"
|
||||
enableSystem={false}
|
||||
storageKey="admin-theme"
|
||||
>
|
||||
<ThemeConfigProvider>
|
||||
<App />
|
||||
</ThemeConfigProvider>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useTheme } from 'next-themes';
|
||||
import { Menu } from 'lucide-react';
|
||||
import AdminSidebar from '../components/AdminSidebar';
|
||||
import { UsersProvider } from '../context/UsersContext';
|
||||
|
||||
const AdminLayout = ({ children }) => {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
const { theme } = useTheme();
|
||||
const isDark = theme === 'dark';
|
||||
|
||||
// Initialize sidebar state from localStorage
|
||||
useEffect(() => {
|
||||
@@ -43,29 +48,48 @@ const AdminLayout = ({ children }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-white">
|
||||
{/* Sidebar */}
|
||||
<AdminSidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={toggleSidebar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
|
||||
{/* Mobile Overlay */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
|
||||
onClick={closeSidebar}
|
||||
<UsersProvider>
|
||||
<div className={`flex h-screen bg-background ${isDark ? 'dark' : ''}`}>
|
||||
{/* Sidebar */}
|
||||
<AdminSidebar
|
||||
isOpen={sidebarOpen}
|
||||
onToggle={toggleSidebar}
|
||||
isMobile={isMobile}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
{/* Mobile Overlay */}
|
||||
{isMobile && sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 z-30 transition-opacity"
|
||||
onClick={closeSidebar}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<main className="flex-1 overflow-y-auto scrollbar-dashboard">
|
||||
{isMobile && (
|
||||
<div className="sticky top-0 z-20 bg-background/90 backdrop-blur border-b border-[var(--neutral-800)] px-4 py-3 flex items-center gap-3">
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="p-2 rounded-lg hover:bg-[var(--neutral-800)]/20 transition-colors"
|
||||
aria-label={sidebarOpen ? 'Close sidebar' : 'Open sidebar'}
|
||||
>
|
||||
<Menu className="h-5 w-5 text-primary" />
|
||||
</button>
|
||||
<span
|
||||
className="text-sm font-semibold text-primary"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Menu
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="max-w-7xl mx-auto px-6 py-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</UsersProvider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
31
src/layouts/SettingsLayout.js
Normal file
31
src/layouts/SettingsLayout.js
Normal file
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import SettingsTabs from '../components/SettingsSidebar';
|
||||
import { Settings } from 'lucide-react';
|
||||
|
||||
const SettingsLayout = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-foreground flex items-center gap-2">
|
||||
<Settings className="h-6 w-6" />
|
||||
Settings
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage your platform configuration and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs Navigation */}
|
||||
<SettingsTabs />
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="min-w-0">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingsLayout;
|
||||
@@ -163,9 +163,9 @@ const AcceptInvitation = () => {
|
||||
|
||||
const getRoleBadge = (role) => {
|
||||
const config = {
|
||||
superadmin: { label: 'Superadmin', className: 'bg-[#664fa3] text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[#81B29A] text-white' },
|
||||
member: { label: 'Member', className: 'bg-[#DDD8EB] text-[#422268]' }
|
||||
superadmin: { label: 'Superadmin', className: 'bg-brand-purple text-white' },
|
||||
admin: { label: 'Admin', className: 'bg-[var(--green-light)] text-white' },
|
||||
member: { label: 'Member', className: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }
|
||||
};
|
||||
|
||||
const roleConfig = config[role] || { label: role, className: 'bg-gray-500 text-white' };
|
||||
@@ -179,10 +179,10 @@ const AcceptInvitation = () => {
|
||||
|
||||
if (loading) {
|
||||
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-md p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||
<Loader2 className="h-12 w-12 text-[#664fa3] mx-auto mb-4 animate-spin" />
|
||||
<p className="text-lg text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
<Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
|
||||
<p className="text-lg text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Verifying your invitation...
|
||||
</p>
|
||||
</Card>
|
||||
@@ -192,18 +192,18 @@ const AcceptInvitation = () => {
|
||||
|
||||
if (error) {
|
||||
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-md p-12 bg-white rounded-2xl border border-[#ddd8eb] text-center">
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-md p-12 bg-background rounded-2xl border border-[var(--neutral-800)] text-center">
|
||||
<XCircle className="h-16 w-16 text-red-500 mx-auto mb-6" />
|
||||
<h1 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Invalid Invitation
|
||||
</h1>
|
||||
<p className="text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{error}
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => navigate('/login')}
|
||||
className="rounded-xl bg-[#664fa3] hover:bg-[#422268] text-white"
|
||||
className="rounded-xl bg-brand-purple hover:bg-[var(--purple-ink)] text-white"
|
||||
>
|
||||
Go to Login
|
||||
</Button>
|
||||
@@ -216,53 +216,53 @@ const AcceptInvitation = () => {
|
||||
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">
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl p-12 bg-background rounded-2xl border border-[var(--neutral-800)] 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">
|
||||
<div className="h-24 w-24 mx-auto rounded-full bg-gradient-to-br from-[var(--green-light)] to-[var(--green-fern)] 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" }}>
|
||||
<h1 className="text-4xl font-semibold text-[var(--purple-ink)] 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" }}>
|
||||
<p className="text-xl text-brand-purple 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="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] 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" }}>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Name
|
||||
</p>
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" 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" }}>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Email
|
||||
</p>
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{successUser?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple 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" }}>
|
||||
<p className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Status
|
||||
</p>
|
||||
<Badge className="bg-[#81B29A] text-white px-4 py-2 rounded-full text-sm">
|
||||
<Badge className="bg-[var(--green-light)] text-white px-4 py-2 rounded-full text-sm">
|
||||
{successUser?.status}
|
||||
</Badge>
|
||||
</div>
|
||||
@@ -280,7 +280,7 @@ const AcceptInvitation = () => {
|
||||
{/* 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"
|
||||
className="w-full h-14 rounded-xl bg-gradient-to-r from-[var(--green-light)] to-[var(--green-fern)] hover:from-[var(--green-fern)] hover:to-[var(--green-sage)] text-white text-lg font-semibold"
|
||||
>
|
||||
Continue to Dashboard
|
||||
</Button>
|
||||
@@ -290,46 +290,46 @@ const AcceptInvitation = () => {
|
||||
}
|
||||
|
||||
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-3xl p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb]">
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-3xl p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)]">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="flex justify-center mb-4">
|
||||
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-[#664fa3] to-[#422268] flex items-center justify-center">
|
||||
<div className="h-16 w-16 rounded-full bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] flex items-center justify-center">
|
||||
<Mail className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-3xl md:text-4xl font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl md:text-4xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to LOAF!
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Complete your profile to accept the invitation
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Invitation Details */}
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-[#DDD8EB] to-[#F9F8FB] rounded-xl">
|
||||
<div className="mb-8 p-6 bg-gradient-to-r from-[var(--neutral-800)] to-[var(--lavender-700)] rounded-xl">
|
||||
<div className="grid md:grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Email Address
|
||||
</p>
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{invitation?.email}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Role
|
||||
</p>
|
||||
<div>{getRoleBadge(invitation?.role)}</div>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<p className="text-[#664fa3] mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-1 flex items-center gap-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Calendar className="h-4 w-4" />
|
||||
Invitation Expires
|
||||
</p>
|
||||
<p className="font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<p className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{invitation?.expires_at ? new Date(invitation.expires_at).toLocaleString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
@@ -342,7 +342,7 @@ const AcceptInvitation = () => {
|
||||
{/* Password Fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="password" className="text-[#422268]">
|
||||
<Label htmlFor="password" className="text-[var(--purple-ink)]">
|
||||
Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -350,7 +350,7 @@ const AcceptInvitation = () => {
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => handleChange('password', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Minimum 8 characters"
|
||||
/>
|
||||
{formErrors.password && (
|
||||
@@ -359,7 +359,7 @@ const AcceptInvitation = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="confirmPassword" className="text-[#422268]">
|
||||
<Label htmlFor="confirmPassword" className="text-[var(--purple-ink)]">
|
||||
Confirm Password <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -367,7 +367,7 @@ const AcceptInvitation = () => {
|
||||
type="password"
|
||||
value={formData.confirmPassword}
|
||||
onChange={(e) => handleChange('confirmPassword', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Re-enter password"
|
||||
/>
|
||||
{formErrors.confirmPassword && (
|
||||
@@ -379,15 +379,15 @@ const AcceptInvitation = () => {
|
||||
{/* Name Fields */}
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="first_name" className="text-[#422268]">
|
||||
<Label htmlFor="first_name" className="text-[var(--purple-ink)]">
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="first_name"
|
||||
value={formData.first_name}
|
||||
onChange={(e) => handleChange('first_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
placeholder="John"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Jane"
|
||||
/>
|
||||
{formErrors.first_name && (
|
||||
<p className="text-sm text-red-500">{formErrors.first_name}</p>
|
||||
@@ -395,14 +395,14 @@ const AcceptInvitation = () => {
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="last_name" className="text-[#422268]">
|
||||
<Label htmlFor="last_name" className="text-[var(--purple-ink)]">
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="last_name"
|
||||
value={formData.last_name}
|
||||
onChange={(e) => handleChange('last_name', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="Doe"
|
||||
/>
|
||||
{formErrors.last_name && (
|
||||
@@ -413,7 +413,7 @@ const AcceptInvitation = () => {
|
||||
|
||||
{/* Phone */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="phone" className="text-[#422268]">
|
||||
<Label htmlFor="phone" className="text-[var(--purple-ink)]">
|
||||
Phone <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -421,7 +421,7 @@ const AcceptInvitation = () => {
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="(555) 123-4567"
|
||||
/>
|
||||
{formErrors.phone && (
|
||||
@@ -432,20 +432,20 @@ const AcceptInvitation = () => {
|
||||
{/* Optional Fields Section */}
|
||||
{invitation?.role === 'member' && (
|
||||
<>
|
||||
<div className="border-t border-[#ddd8eb] pt-6 mt-2">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="border-t border-[var(--neutral-800)] pt-6 mt-2">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Additional Information (Optional)
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Address */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="address" className="text-[#422268]">Address</Label>
|
||||
<Label htmlFor="address" className="text-[var(--purple-ink)]">Address</Label>
|
||||
<Input
|
||||
id="address"
|
||||
value={formData.address}
|
||||
onChange={(e) => handleChange('address', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="123 Main St"
|
||||
/>
|
||||
</div>
|
||||
@@ -453,35 +453,35 @@ const AcceptInvitation = () => {
|
||||
{/* City, State, Zipcode */}
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="city" className="text-[#422268]">City</Label>
|
||||
<Label htmlFor="city" className="text-[var(--purple-ink)]">City</Label>
|
||||
<Input
|
||||
id="city"
|
||||
value={formData.city}
|
||||
onChange={(e) => handleChange('city', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="San Francisco"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="state" className="text-[#422268]">State</Label>
|
||||
<Label htmlFor="state" className="text-[var(--purple-ink)]">State</Label>
|
||||
<Input
|
||||
id="state"
|
||||
value={formData.state}
|
||||
onChange={(e) => handleChange('state', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="CA"
|
||||
maxLength={2}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="zipcode" className="text-[#422268]">Zipcode</Label>
|
||||
<Label htmlFor="zipcode" className="text-[var(--purple-ink)]">Zipcode</Label>
|
||||
<Input
|
||||
id="zipcode"
|
||||
value={formData.zipcode}
|
||||
onChange={(e) => handleChange('zipcode', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="94102"
|
||||
/>
|
||||
</div>
|
||||
@@ -489,13 +489,13 @@ const AcceptInvitation = () => {
|
||||
|
||||
{/* Date of Birth */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="date_of_birth" className="text-[#422268]">Date of Birth</Label>
|
||||
<Label htmlFor="date_of_birth" className="text-[var(--purple-ink)]">Date of Birth</Label>
|
||||
<Input
|
||||
id="date_of_birth"
|
||||
type="date"
|
||||
value={formData.date_of_birth}
|
||||
onChange={(e) => handleChange('date_of_birth', e.target.value)}
|
||||
className="rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -507,7 +507,7 @@ const AcceptInvitation = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
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"
|
||||
className="w-full h-14 rounded-xl bg-gradient-to-r from-[var(--green-light)] to-[var(--green-fern)] hover:from-[var(--green-fern)] hover:to-[var(--green-sage)] text-white text-lg font-semibold"
|
||||
>
|
||||
{submitting ? (
|
||||
<>
|
||||
@@ -526,11 +526,11 @@ const AcceptInvitation = () => {
|
||||
|
||||
{/* Footer Note */}
|
||||
<div className="mt-6 text-center">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Already have an account?{' '}
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="text-[#664fa3] hover:text-[#422268] font-semibold underline"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
Sign in instead
|
||||
</button>
|
||||
|
||||
@@ -16,7 +16,7 @@ const BecomeMember = () => {
|
||||
|
||||
const Arrow = ({ ...props }) => (
|
||||
<div className="flex justify-center mb-2">
|
||||
<ArrowDown className="size-8 text-[#4f378a] font-bold" strokeWidth={2} />
|
||||
<ArrowDown className="size-8 text---brand-purple font-bold" strokeWidth={2} />
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
@@ -29,13 +29,13 @@ const BecomeMember = () => {
|
||||
<div className="relative bg-gray-50 pt-20 px-6 pb-16">
|
||||
<div className="max-w-7xl mx-auto text-center">
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.96px]"
|
||||
className="text-3xl sm:text-4xl md:text-5xl font-bold text-[var(--purple-deep)] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.96px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Become a Member
|
||||
</h1>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] max-w-2xl mx-auto leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] max-w-2xl mx-auto leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Become a member to receive our monthly newsletter and find out about all the activities LOAF has planned each month. LOAF hosts over 40 social activities each year and occasionally covers the costs for members only
|
||||
@@ -53,15 +53,15 @@ const BecomeMember = () => {
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-[#eeebf4] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-[var(--lavender-200)] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Annual Administrative Fees
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Annual Administrative Fees for all members are $30 per person. These fees help cover general business expenses (website, advertising, e-newsletter).
|
||||
@@ -71,7 +71,7 @@ const BecomeMember = () => {
|
||||
</div>
|
||||
|
||||
{/* Membership Process Section */}
|
||||
<div className="relative bg-gray-50 py-32 bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB] ">
|
||||
<div className="relative bg-gray-50 py-32 bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] ">
|
||||
{/* Decorative shooting star element */}
|
||||
<div className="hidden lg:block absolute left-0 top-64 w-[195px] h-[1130px] pointer-events-none opacity-50">
|
||||
<img
|
||||
@@ -82,13 +82,13 @@ const BecomeMember = () => {
|
||||
</div>
|
||||
<div className="max-w-7xl mx-auto px-6 mb-24 text-center">
|
||||
<h2
|
||||
className="text-2xl sm:text-3xl md:text-[40px] font-bold text-[#48286e] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
className="text-2xl sm:text-3xl md:text-[40px] font-bold text-[var(--purple-deep)] mb-6 sm:mb-8 leading-[1.2] tracking-[-0.8px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Membership Process
|
||||
</h2>
|
||||
<p
|
||||
className="text-base sm:text-lg md:text-xl lg:text-2xl lg:font-semibold font-medium text-[#48286e] max-w-2xl mx-auto leading-[1.6]"
|
||||
className="text-base sm:text-lg md:text-xl lg:text-2xl lg:font-semibold font-medium text-[var(--purple-deep)] max-w-2xl mx-auto leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Becoming a member is easy, but for the safety and privacy of our membership, there are a few steps:
|
||||
@@ -106,15 +106,15 @@ const BecomeMember = () => {
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-background rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 1: Application & Email Confirmation
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Complete the online application form and submit it. Check your email for a confirmation link and use it to verify your email. You will then begin to receive LOAF's monthly e-newsletter where all of the social events are listed. Your application will remain pending, and you won't be able to log into the Members Only section of the website until step 2 is complete and you are validated by an admin.
|
||||
@@ -136,15 +136,15 @@ const BecomeMember = () => {
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-background rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 2: Attend an event and meet us!
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
You have 3 months to attend a LOAF event and introduce yourself to a board member. If you do not attend an event within 3 months, you will no longer receive the e-newsletter. (This step can be skipped if you have been referred from a current member and list her on your registration form).
|
||||
@@ -165,15 +165,15 @@ const BecomeMember = () => {
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-white rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-background rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[#48286e] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-[var(--purple-deep)] mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Step 3: Login and pay the annual fee
|
||||
</h3>
|
||||
<p
|
||||
className="text-base sm:text-lg font-medium text-[#48286e] leading-[1.6]"
|
||||
className="text-base sm:text-lg font-medium text-[var(--purple-deep)] leading-[1.6]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif", fontVariationSettings: "'YTLC' 500, 'wdth' 100" }}
|
||||
>
|
||||
Once we know that you are indeed you, an admin will validate your application and you will receive an email prompting you to login to your user profile and pay the annual administrative fee.
|
||||
@@ -195,7 +195,7 @@ const BecomeMember = () => {
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 bg-gradient-to-r from-[#48286e] to-[#664fa3] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<div className="flex-1 bg-gradient-to-r from-[var(--purple-deep)] to-[var(--purple-lavender)] rounded-[25px] px-4 py-6 sm:px-6 sm:py-7 md:px-8 md:py-8">
|
||||
<h3
|
||||
className="text-xl sm:text-2xl md:text-[32px] font-semibold text-white mb-3 sm:mb-4 md:mb-5 leading-[1.49]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
@@ -216,14 +216,14 @@ const BecomeMember = () => {
|
||||
<div className="relative bg-gray-50 py-16 ">
|
||||
<div className="max-w-7xl mx-auto px-6 flex flex-col md:flex-row items-center justify-center gap-12 text-center">
|
||||
<h2
|
||||
className="text-2xl sm:text-3xl md:text-[40px] content-center font-bold text-[#48286e] leading-[1.2] tracking-[-0.8px]"
|
||||
className="text-2xl sm:text-3xl md:text-[40px] content-center font-bold text-[var(--purple-deep)] leading-[1.2] tracking-[-0.8px]"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
Ready to Join Us?
|
||||
</h2>
|
||||
<Link to="/register">
|
||||
<Button
|
||||
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-[35px] px-6 py-3 sm:px-12 sm:py-5 md:px-16 md:py-6 text-[19px] sm:text-lg font-medium tracking-[-0.09px] leading-5 h-auto"
|
||||
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-deep)] rounded-[35px] px-6 py-3 sm:px-12 sm:py-5 md:px-16 md:py-6 text-[19px] sm:text-lg font-medium tracking-[-0.09px] leading-5 h-auto"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Register Now!
|
||||
|
||||
@@ -22,10 +22,10 @@ const BoardOfDirectors = () => {
|
||||
const DirectorCards = ({ title, members }) => {
|
||||
return (
|
||||
<section className=" w-full">
|
||||
<div className="mx-auto bg-white rounded-3xl p-10 shadow-lg h-full">
|
||||
<div className="mx-auto bg-background rounded-3xl p-10 shadow-lg h-full">
|
||||
{title && (
|
||||
<h2
|
||||
className="text-2xl sm:text-4xl font-bold text-[#48286e] text-center mb-8"
|
||||
className="text-2xl sm:text-4xl font-bold text-[var(--purple-deep)] text-center mb-8"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
>
|
||||
{title}
|
||||
@@ -43,10 +43,10 @@ const BoardOfDirectors = () => {
|
||||
<Card
|
||||
key={`${name}-${index}`}
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}
|
||||
className="bg-[#eeebf4] text-[#48286e] text-center px-6 py-5 rounded-3xl border border-white/70 shadow-sm"
|
||||
className="bg-[var(--lavender-200)] text-[var(--purple-deep)] text-center px-6 py-5 rounded-3xl border border-white/70 shadow-sm"
|
||||
>
|
||||
<div className="min-h-16">
|
||||
<p className="text-xl font-bold text-[#48286e]">
|
||||
<p className="text-xl font-bold text-[var(--purple-deep)]">
|
||||
{name}
|
||||
</p>
|
||||
|
||||
@@ -66,14 +66,14 @@ const BoardOfDirectors = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-[#f9fafb] to-[#ddd8eb] pt-8 sm:pt-10 md:pt-12">
|
||||
<main className="bg-gradient-to-b from-[var(--neutral-100:)] to-[var(--neutral-800)] pt-8 sm:pt-10 md:pt-12">
|
||||
{/* Hero Section */}
|
||||
<section className=" pt-16 pb-4 px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
<div className="max-w-5xl mx-auto text-center px-8">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-[40px] leading-[1.2] text-[#48286e] font-bold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-[40px] leading-[1.2] text-[var(--purple-deep)] font-bold mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
LOAF Board of Directors 2025
|
||||
</h1>
|
||||
|
||||
@@ -81,11 +81,11 @@ const BoardOfDirectors = () => {
|
||||
</section>
|
||||
{/* Contact Info */}
|
||||
<section className="flex justify-center mt-4 mb-8">
|
||||
<div className=" w-full text-center px-8 justify-center bg-gradient-to-r from-[#664fa3] to-[#48286e] max-w-5xl mx-6 flex lg:mx-12 py-5 rounded-3xl sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
<div className=" w-full text-center px-8 justify-center bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-deep)] max-w-5xl mx-6 flex lg:mx-12 py-5 rounded-3xl sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
|
||||
<p className="text-white text-xl font-bold " style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
For any questions or inquiries please email us at{' '}
|
||||
<a href="mailto:info@loaftx.org" className="text-[#c5b4e3] underline font-bold hover:text-white transition-colors">
|
||||
<a href="mailto:info@loaftx.org" className="text-[var(--neutral-500)] underline font-bold hover:text-white transition-colors">
|
||||
info@loaftx.org
|
||||
</a>
|
||||
</p>
|
||||
@@ -102,22 +102,22 @@ const BoardOfDirectors = () => {
|
||||
</section>
|
||||
|
||||
{/* Join the Board Section */}
|
||||
<section className="py-12 bg-white mt-12 ">
|
||||
<section className="py-12 bg-background mt-12 ">
|
||||
{/* content containter */}
|
||||
<div className="w-full mx-auto flex flex-col px-4 sm:px-6 md:px-8 lg:px-12 xl:px-20">
|
||||
<h2 className="text-xl mx-auto sm:text-2xl md:text-4xl font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<h2 className="text-xl mx-auto sm:text-2xl md:text-4xl font-bold text-[var(--purple-deep)] text-center mb-8" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Join the Board of Directors
|
||||
</h2>
|
||||
<p className="lg:text-2xl text-md md:text-lg max-w-4xl mx-auto justify-center font-bold text-[#48286e] text-center mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="lg:text-2xl text-md md:text-lg max-w-4xl mx-auto justify-center font-bold text-[var(--purple-deep)] text-center mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our elections take place at our December holiday social. Here are some things to know if you are thinking about serving on the Board of Directors.
|
||||
</p>
|
||||
{/* card */}
|
||||
<Card className="bg-[#eeebf4] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Card className="bg-[var(--lavender-200)] p-8 rounded-2xl shadow-lg mx-auto border border-white/70">
|
||||
<ol className="list-decimal list-inside space-y-4 text-lg text-[var(--purple-deep)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>
|
||||
Nominations are due by November 1. Nomination Form:{' '}
|
||||
<a href="https://docs.google.com/forms/d/e/1FAIpQLSfNomination" target="_blank" rel="noopener noreferrer"
|
||||
className="text-[#664fa3] underline hover:text-[#48286e] transition-colors">
|
||||
className="text-[var(--purple-lavender)] underline hover:text-[var(--purple-deep)] transition-colors">
|
||||
Click Here
|
||||
</a>
|
||||
</li>
|
||||
@@ -139,7 +139,7 @@ const BoardOfDirectors = () => {
|
||||
href="https://loaftx.org/bylaws/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-[#48286e] underline"
|
||||
className="text-[var(--purple-deep)] underline"
|
||||
>
|
||||
https://loaftx.org/bylaws/
|
||||
</a>
|
||||
|
||||
@@ -83,19 +83,19 @@ const ChangePasswordRequired = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#FFEBEE] mb-4">
|
||||
<AlertTriangle className="h-8 w-8 text-orange-500" />
|
||||
</div>
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Password Change Required
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your password was reset by an administrator. Please create a new password to continue.
|
||||
</p>
|
||||
</div>
|
||||
@@ -111,7 +111,7 @@ const ChangePasswordRequired = () => {
|
||||
value={formData.currentPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter temporary password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -125,7 +125,7 @@ const ChangePasswordRequired = () => {
|
||||
value={formData.newPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter new password (min. 6 characters)"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -139,15 +139,15 @@ const ChangePasswordRequired = () => {
|
||||
value={formData.confirmPassword}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Re-enter new password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] border-l-4 border-[#664fa3] p-4 rounded-lg">
|
||||
<div className="bg-[var(--lavender-300)] border-l-4 border-brand-purple p-4 rounded-lg">
|
||||
<div className="flex items-start">
|
||||
<Lock className="h-5 w-5 text-[#664fa3] mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="font-medium text-[#422268] mb-1">Password Requirements:</p>
|
||||
<Lock className="h-5 w-5 text-brand-purple mr-2 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="font-medium text-[var(--purple-ink)] mb-1">Password Requirements:</p>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
<li>At least 6 characters long</li>
|
||||
<li>Both passwords must match</li>
|
||||
@@ -159,17 +159,17 @@ const ChangePasswordRequired = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Changing Password...' : 'Change Password'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<div className="text-center pt-4 border-t border-[#ddd8eb]">
|
||||
<div className="text-center pt-4 border-t border-[var(--neutral-800)]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleLogout}
|
||||
className="text-[#664fa3] hover:text-[#ff9e77] text-sm underline"
|
||||
className="text-brand-purple hover:text-[var(--orange-light)] text-sm underline"
|
||||
>
|
||||
Logout instead
|
||||
</button>
|
||||
|
||||
@@ -96,15 +96,15 @@ const ContactUs = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-[#e8e0f5] to-[#f1eef9] px-6 py-16">
|
||||
<main className="bg-gradient-to-b from-[var(--lavender-100)] to-[var(--lavender-300)] px-6 py-16">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
|
||||
{/* Contact Form */}
|
||||
<Card className="p-8 bg-white rounded-2xl">
|
||||
<h1 className="text-2xl sm:text-[28px] leading-5 font-bold text-[#48286e] mb-12" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl">
|
||||
<h1 className="text-2xl sm:text-[28px] leading-5 font-bold text-[var(--purple-deep)] mb-12" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Contact Form
|
||||
</h1>
|
||||
|
||||
@@ -112,7 +112,7 @@ const ContactUs = () => {
|
||||
{/* First Name & Last Name */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label htmlFor="firstName" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="firstName" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
First Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -120,13 +120,13 @@ const ContactUs = () => {
|
||||
name="firstName"
|
||||
value={formData.firstName}
|
||||
onChange={handleChange}
|
||||
className="border-2 border-[#ddd8eb] bg-[#eaedf4] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 border-[var(--neutral-800)] bg-[var(--lavender-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="lastName" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="lastName" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Last Name <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -134,7 +134,7 @@ const ContactUs = () => {
|
||||
name="lastName"
|
||||
value={formData.lastName}
|
||||
onChange={handleChange}
|
||||
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -143,7 +143,7 @@ const ContactUs = () => {
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<Label htmlFor="email" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="email" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Email <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -152,7 +152,7 @@ const ContactUs = () => {
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -160,7 +160,7 @@ const ContactUs = () => {
|
||||
|
||||
{/* Subject */}
|
||||
<div>
|
||||
<Label htmlFor="subject" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="subject" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Subject <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
@@ -168,7 +168,7 @@ const ContactUs = () => {
|
||||
name="subject"
|
||||
value={formData.subject}
|
||||
onChange={handleChange}
|
||||
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-full h-12 px-4"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-full h-12 px-4"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -176,7 +176,7 @@ const ContactUs = () => {
|
||||
|
||||
{/* Message */}
|
||||
<div>
|
||||
<Label htmlFor="message" className="text-[#48286e] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="message" className="text-[var(--purple-deep)] font-medium mb-2 block" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Your Message <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
@@ -184,7 +184,7 @@ const ContactUs = () => {
|
||||
name="message"
|
||||
value={formData.message}
|
||||
onChange={handleChange}
|
||||
className="border-2 bg-[#eaedf4] border-[#ddd8eb] focus:border-[#664fa3] rounded-2xl min-h-[150px] px-4 py-3 resize-none"
|
||||
className="border-2 bg-[var(--lavender-800)] border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-2xl min-h-[150px] px-4 py-3 resize-none"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
required
|
||||
/>
|
||||
@@ -196,9 +196,9 @@ const ContactUs = () => {
|
||||
id="consent"
|
||||
checked={formData.consent}
|
||||
onCheckedChange={handleConsentChange}
|
||||
className="mt-1 border-2 border-[#ddd8eb] data-[state=checked]:bg-[#664fa3] data-[state=checked]:border-[#664fa3]"
|
||||
className="mt-1 border-2 border-[var(--neutral-800)] data-[state=checked]:bg-[var(--purple-lavender)] data-[state=checked]:border-[var(--purple-lavender)]"
|
||||
/>
|
||||
<Label htmlFor="consent" className="text-[#48286e] text-sm font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Label htmlFor="consent" className="text-[var(--purple-deep)] text-sm font-normal cursor-pointer" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
I consent to LOAF storing my submitted information so they can respond to my inquiry <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
</div>
|
||||
@@ -207,7 +207,7 @@ const ContactUs = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#48286e] text-white rounded-full py-6 text-lg font-semibold disabled:opacity-50"
|
||||
className="w-full bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white rounded-full py-6 text-lg font-semibold disabled:opacity-50"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
{loading ? (
|
||||
@@ -225,22 +225,22 @@ const ContactUs = () => {
|
||||
{/* Contact Information */}
|
||||
<div className="space-y-6">
|
||||
{/* Message Card */}
|
||||
<Card className="p-8 bg-gradient-to-r from-[#664fa3] to-[#48286e] rounded-2xl shadow-lg text-white">
|
||||
<Card className="p-8 bg-gradient-to-r from-[var(--purple-lavender)] to-[var(--purple-deep)] rounded-2xl shadow-lg text-white">
|
||||
<p className="text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
If you have questions, or are interested in joining, we would love hearing from you.
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
{/* Email Card */}
|
||||
<Card className="p-6 bg-white rounded-2xl">
|
||||
<Card className="p-6 bg-background rounded-2xl">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex items-center justify-center flex-shrink-0">
|
||||
<Mail className="size-12 text-[#664fa3]" />
|
||||
<Mail className="size-12 text-[var(--purple-lavender)]" />
|
||||
</div>
|
||||
<div>
|
||||
<a
|
||||
href="mailto:info@loaftx.org"
|
||||
className="text-[#865edf] text-xl font-semibold hover:text-[#48286e] transition-colors"
|
||||
className="text-[var(--purple-electric)] text-xl font-semibold hover:text-[var(--purple-deep)] transition-colors"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
info@loaftx.org
|
||||
@@ -250,16 +250,16 @@ const ContactUs = () => {
|
||||
</Card>
|
||||
|
||||
{/* Address Card */}
|
||||
<Card className="p-6 bg-white rounded-2xl ">
|
||||
<Card className="p-6 bg-background rounded-2xl ">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex items-center justify-center flex-shrink-0">
|
||||
<PiMapTrifoldBold className="size-12 text-[#664fa3]" />
|
||||
<PiMapTrifoldBold className="size-12 text-[var(--purple-lavender)]" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[#48286e] text-[28px] font-semibold mb-1" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-[28px] font-semibold mb-1" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF
|
||||
</p>
|
||||
<p className="text-[#48286e] text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-[28px] font-semibold leading-relaxed" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
P.O. Box 7207<br />
|
||||
Houston, Texas 77248-7207
|
||||
</p>
|
||||
|
||||
@@ -7,8 +7,11 @@ import { Button } from '../components/ui/button';
|
||||
import { Badge } from '../components/ui/badge';
|
||||
import Navbar from '../components/Navbar';
|
||||
import MemberFooter from '../components/MemberFooter';
|
||||
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale } from 'lucide-react';
|
||||
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import TransactionHistory from '../components/TransactionHistory';
|
||||
import MemberBadge from '@/components/MemberBadge';
|
||||
import useMemberTiers from '../hooks/use-member-tiers'
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user, resendVerificationEmail, refreshUser } = useAuth();
|
||||
@@ -17,10 +20,16 @@ const Dashboard = () => {
|
||||
const [resendLoading, setResendLoading] = useState(false);
|
||||
const [eventActivity, setEventActivity] = useState(null);
|
||||
const [activityLoading, setActivityLoading] = useState(true);
|
||||
const [transactionsLoading, setTransactionsLoading] = useState(true);
|
||||
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
|
||||
const [activeTransactionTab, setActiveTransactionTab] = useState('all');
|
||||
const joinedDate = user?.member_since || user?.created_at;
|
||||
const { tiers, loading: tiersLoading } = useMemberTiers();
|
||||
|
||||
useEffect(() => {
|
||||
fetchUpcomingEvents();
|
||||
fetchEventActivity();
|
||||
fetchTransactions();
|
||||
}, []);
|
||||
|
||||
const fetchUpcomingEvents = async () => {
|
||||
@@ -46,6 +55,19 @@ const Dashboard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTransactions = async () => {
|
||||
try {
|
||||
setTransactionsLoading(true);
|
||||
const response = await api.get('/members/transactions');
|
||||
setTransactions(response.data);
|
||||
} catch (error) {
|
||||
console.error('Failed to load transactions:', error);
|
||||
// Don't show error toast - transactions are optional
|
||||
} finally {
|
||||
setTransactionsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResendVerification = async () => {
|
||||
setResendLoading(true);
|
||||
try {
|
||||
@@ -70,13 +92,14 @@ const Dashboard = () => {
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const getStatusBadge = (status) => {
|
||||
const statusConfig = {
|
||||
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
|
||||
pending_validation: { icon: Clock, label: 'Pending Validation', className: 'bg-gray-200 text-gray-700' },
|
||||
pre_validated: { icon: CheckCircle, label: 'Pre-Validated', className: 'bg-[#81B29A] text-white' },
|
||||
pre_validated: { icon: CheckCircle, label: 'Pre-Validated', className: 'bg-[var(--green-light)] text-white' },
|
||||
payment_pending: { icon: AlertCircle, label: 'Payment Pending', className: 'bg-orange-500 text-white' },
|
||||
active: { icon: CheckCircle, label: 'Active', className: 'bg-[#81B29A] text-white' },
|
||||
active: { icon: CheckCircle, label: 'Active', className: 'bg-[var(--green-light)] text-white' },
|
||||
inactive: { icon: AlertCircle, label: 'Inactive', className: 'bg-gray-400 text-white' },
|
||||
canceled: { icon: AlertCircle, label: 'Canceled', className: 'bg-red-100 text-red-700' },
|
||||
expired: { icon: Clock, label: 'Expired', className: 'bg-red-500 text-white' },
|
||||
@@ -110,31 +133,33 @@ const Dashboard = () => {
|
||||
return messages[status] || '';
|
||||
};
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Welcome Section */}
|
||||
<div className="mb-12">
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome Back, {user?.first_name}!
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Here's an overview of your membership status and upcoming events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Email Verification Alert */}
|
||||
{user && !user.email_verified && (
|
||||
<Card className="p-6 bg-[#f1eef9] border-2 border-[#664fa3] mb-8">
|
||||
<Card className="p-6 bg-[var(--lavender-300)] border-2 border-brand-purple mb-8">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="h-6 w-6 text-[#664fa3] flex-shrink-0 mt-1" />
|
||||
<AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Verify Your Email Address
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Please verify your email address to complete your registration.
|
||||
Check your inbox for the verification link.
|
||||
</p>
|
||||
@@ -142,7 +167,7 @@ const Dashboard = () => {
|
||||
onClick={handleResendVerification}
|
||||
disabled={resendLoading}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white"
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-2" />
|
||||
{resendLoading ? 'Sending...' : 'Resend Verification Email'}
|
||||
@@ -153,110 +178,132 @@ const Dashboard = () => {
|
||||
)}
|
||||
|
||||
{/* Status Card */}
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg mb-8" data-testid="status-card">
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg mb-8" data-testid="status-card">
|
||||
<div className="flex items-start justify-between flex-wrap gap-4">
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Membership Status
|
||||
</h2>
|
||||
<div className="mb-4">
|
||||
{getStatusBadge(user?.status)}
|
||||
</div>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getStatusMessage(user?.status)}
|
||||
</p>
|
||||
</div>
|
||||
<Link to="/profile">
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-6"
|
||||
className="btn-lavender"
|
||||
data-testid="view-profile-button"
|
||||
>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
View Profile
|
||||
Edit Profile
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Grid Layout */}
|
||||
<div className="grid lg:grid-cols-3 gap-8">
|
||||
<div className="grid lg:grid-cols-2 gap-8">
|
||||
{/* Quick Stats */}
|
||||
<Card className="p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="quick-stats-card">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Quick Info
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||
<p className="text-[#422268] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{user?.created_at ? new Date(user.created_at).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
{user?.subscription_start_date && user?.subscription_end_date && (
|
||||
<>
|
||||
<div className="pt-4 border-t border-[#ddd8eb]">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div className='space-y-8'>
|
||||
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Quick Info
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{/* member date and badge */}
|
||||
<div className='flex justify-between'>
|
||||
<div>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
||||
<p className="text-[#422268] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Member Since</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
{!tiersLoading && (
|
||||
<div className='lg:mr-10'>
|
||||
<MemberBadge memberSince={joinedDate} tiers={tiers} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* email */}
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Email</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.email}</p>
|
||||
</div>
|
||||
{/* role */}
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Role</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium capitalize" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{user?.role}</p>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
</Card>
|
||||
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Membership Info
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
{!user.subscription_end_date && !user.subscription_end_date && (
|
||||
<div>No subscriptions yet</div>
|
||||
)}
|
||||
{user?.subscription_start_date && user?.subscription_end_date && (
|
||||
<>
|
||||
<div className="pt-4 border-t border-[var(--neutral-800)]">
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p>
|
||||
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{Math.max(0, Math.ceil((new Date(user.subscription_end_date) - new Date()) / (1000 * 60 * 60 * 24)))} days
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Upcoming Events */}
|
||||
<Card className="lg:col-span-2 p-6 bg-white rounded-2xl border border-[#ddd8eb]" data-testid="upcoming-events-card">
|
||||
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="upcoming-events-card">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h3 className="text-xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Upcoming Events
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
My Event Activity
|
||||
</h3>
|
||||
<Link to="/events">
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3]"
|
||||
data-testid="view-all-events-button"
|
||||
>
|
||||
View All
|
||||
<Button className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-muted)] rounded-full dark:hover:bg-brand-lavender dark:hover:text-brand-dark-lavender px-6">
|
||||
<Calendar className="h-4 w-4 mr-2" />
|
||||
Browse Events
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
) : events.length > 0 ? (
|
||||
<div className="space-y-4">
|
||||
{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 cursor-pointer"
|
||||
className="p-4 border border-[var(--neutral-800)] rounded-xl hover:border-brand-purple hover:shadow-md transition-all cursor-pointer"
|
||||
data-testid={`event-card-${event.id}`}
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
<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" }}>
|
||||
<h4 className="font-semibold text-[var(--purple-ink)] mb-1" style={{ fontFamily: "'Inter', sans-serif" }}>{event.title}</h4>
|
||||
<p className="text-sm text-brand-purple 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>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -265,9 +312,9 @@ const Dashboard = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<Calendar className="h-16 w-16 text-[#ddd8eb] mx-auto mb-4" />
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p>
|
||||
<Calendar className="h-16 w-16 text-[var(--neutral-800)] mx-auto mb-4" />
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No upcoming events at the moment.</p>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Check back later for new events!</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -275,12 +322,12 @@ const Dashboard = () => {
|
||||
|
||||
{/* CTA Section */}
|
||||
{user?.status === 'pending_validation' && (
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
|
||||
<div className="text-center">
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Application Under Review
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your membership application is being reviewed by our admin team. You'll be notified once validated!
|
||||
</p>
|
||||
</div>
|
||||
@@ -289,20 +336,20 @@ const Dashboard = () => {
|
||||
|
||||
{/* Payment Prompt for payment_pending status */}
|
||||
{user?.status === 'payment_pending' && (
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border-2 border-[#664fa3]">
|
||||
<Card className="mt-8 p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border-2 border-brand-purple ">
|
||||
<div className="text-center">
|
||||
<div className="mb-4">
|
||||
<AlertCircle className="h-16 w-16 text-[#664fa3] mx-auto" />
|
||||
<AlertCircle className="h-16 w-16 text-brand-purple mx-auto" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Complete Your Payment
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Great news! Your membership application has been validated. Complete your payment to activate your membership and gain full access to all member benefits.
|
||||
</p>
|
||||
<Link to="/plans">
|
||||
<Button
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="complete-payment-cta"
|
||||
>
|
||||
<CheckCircle className="mr-2 h-5 w-5" />
|
||||
@@ -313,155 +360,18 @@ const Dashboard = () => {
|
||||
</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>
|
||||
)}
|
||||
{/* Transaction History Section */}
|
||||
<div className="mt-8">
|
||||
<TransactionHistory
|
||||
subscriptions={transactions.subscriptions}
|
||||
donations={transactions.donations}
|
||||
totalSubscriptionCents={transactions.total_subscription_amount_cents}
|
||||
totalDonationCents={transactions.total_donation_amount_cents}
|
||||
loading={transactionsLoading}
|
||||
isAdmin={false}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<MemberFooter />
|
||||
</div>
|
||||
|
||||
@@ -58,17 +58,17 @@ const Donate = () => {
|
||||
<div className="min-h-screen">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
{/* Hero Section */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-4xl mx-auto text-center h-full">
|
||||
<div className="flex justify-center mb-4">
|
||||
<img src={loafHearts} alt="Hearts" className="w-32 h-auto" onError={(e) => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[#48286e] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-bold text-[var(--purple-deep)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Donate
|
||||
</h1>
|
||||
<p className="text-xl text-[#48286e] font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<p className="text-xl text-[var(--purple-deep)] font-medium" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
We really appreciate your donations. You can make your donation online
|
||||
or send a check by mail.
|
||||
</p>
|
||||
@@ -79,96 +79,96 @@ const Donate = () => {
|
||||
<div className="py-12">
|
||||
<div className='grid grid-cols-1 items-stretch lg:grid-cols-[2fr_1fr] gap-8 lg:max-h-[450px]'>
|
||||
|
||||
{/* Donation Amount Buttons */}
|
||||
<section className="flex flex-col h-full">
|
||||
<div className="mx-auto flex-1 w-full h-full">
|
||||
<Card className="p-8 bg-white rounded-3xl w-full h-full content-center">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<CreditCard className="size-24 text-[#664fa3]" />
|
||||
<h2 className="text-3xl font-bold text-[#48286e]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Select Your Donation Amount
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* Donation Buttons Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{[25, 50, 100, 250].map(amount => (
|
||||
<Button
|
||||
key={amount}
|
||||
onClick={() => handleDonateAmount(amount * 100)}
|
||||
disabled={processingAmount === amount * 100}
|
||||
className="bg-[#664fa3] hover:bg-[#48286e] text-white text-xl py-8 rounded-full disabled:opacity-50"
|
||||
>
|
||||
{processingAmount === amount * 100 ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
`$${amount}`
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Amount Button */}
|
||||
<Button
|
||||
onClick={() => setCustomAmountDialogOpen(true)}
|
||||
disabled={processingAmount !== null}
|
||||
className="w-full bg-[#664fa3] hover:bg-[#48286e] text-white text-xl py-8 rounded-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
Donate Any Amount
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-[#664fa3] text-center mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Secure donation processing powered by Stripe
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Alternative Payment Methods */}
|
||||
<section className="flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
{/* Mail Check */}
|
||||
<Card className="p-8 bg-white rounded-3xl flex gap-4 items-center flex-1">
|
||||
<Mail className="size-24 text-[#664fa3]" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Mail a Check
|
||||
</h3>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our mailing address for checks:<br />
|
||||
<span className="font-semibold">LOAF</span><br />
|
||||
P.O. Box 7207<br />
|
||||
Houston, Texas 77248-7207
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Zelle */}
|
||||
<Card className="p-8 bg-white rounded-3xl flex gap-4 items-center flex-1">
|
||||
<div className="w-44">
|
||||
<img src={zelleLogo} alt="Zelle" className=" w-32" onError={(e) => e.target.style.display = 'none'} />
|
||||
{/* Donation Amount Buttons */}
|
||||
<section className="flex flex-col h-full">
|
||||
<div className="mx-auto flex-1 w-full h-full">
|
||||
<Card className="p-8 bg-background rounded-3xl w-full h-full content-center">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<CreditCard className="size-24 text-[var(--purple-lavender)]" />
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Select Your Donation Amount
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Pay with Zelle
|
||||
</h3>
|
||||
<p className="text-lg text-[#48286e] leading-relaxed mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If your bank allows the use of Zelle, please feel free to send money to:
|
||||
</p>
|
||||
<a href="mailto:LOAFHoustonTX@gmail.com"
|
||||
className="text-[#664fa3] text-lg font-bold underline hover:text-[#48286e] transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAFHoustonTX@gmail.com
|
||||
</a>
|
||||
{/* Donation Buttons Grid */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
|
||||
{[25, 50, 100, 250].map(amount => (
|
||||
<Button
|
||||
key={amount}
|
||||
onClick={() => handleDonateAmount(amount * 100)}
|
||||
disabled={processingAmount === amount * 100}
|
||||
className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white text-xl py-8 rounded-full disabled:opacity-50"
|
||||
>
|
||||
{processingAmount === amount * 100 ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
) : (
|
||||
`$${amount}`
|
||||
)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Custom Amount Button */}
|
||||
<Button
|
||||
onClick={() => setCustomAmountDialogOpen(true)}
|
||||
disabled={processingAmount !== null}
|
||||
className="w-full bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white text-xl py-8 rounded-full flex items-center justify-center gap-2"
|
||||
>
|
||||
<Heart className="h-6 w-6" />
|
||||
Donate Any Amount
|
||||
</Button>
|
||||
|
||||
<p className="text-sm text-[var(--purple-lavender)] text-center mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Secure donation processing powered by Stripe
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
|
||||
{/* Alternative Payment Methods */}
|
||||
<section className="flex flex-col">
|
||||
<div className="max-w-6xl mx-auto w-full">
|
||||
<div className="flex flex-col gap-8 w-full">
|
||||
{/* Mail Check */}
|
||||
<Card className="p-8 bg-background rounded-3xl flex gap-4 items-center flex-1">
|
||||
<Mail className="size-24 text-[var(--purple-lavender)]" />
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Mail a Check
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our mailing address for checks:<br />
|
||||
<span className="font-semibold">LOAF</span><br />
|
||||
P.O. Box 7207<br />
|
||||
Houston, Texas 77248-7207
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Zelle */}
|
||||
<Card className="p-8 bg-background rounded-3xl flex gap-4 items-center flex-1">
|
||||
<div className="w-44">
|
||||
<img src={zelleLogo} alt="Zelle" className=" w-32" onError={(e) => e.target.style.display = 'none'} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Pay with Zelle
|
||||
</h3>
|
||||
<p className="text-lg text-[var(--purple-deep)] leading-relaxed mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If your bank allows the use of Zelle, please feel free to send money to:
|
||||
</p>
|
||||
<a href="mailto:LOAFHoustonTX@gmail.com"
|
||||
className="text-[var(--purple-lavender)] text-lg font-bold underline hover:text-[var(--purple-deep)] transition-colors"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAFHoustonTX@gmail.com
|
||||
</a>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,23 +179,23 @@ const Donate = () => {
|
||||
|
||||
{/* Custom Amount Dialog */}
|
||||
<Dialog open={customAmountDialogOpen} onOpenChange={setCustomAmountDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[450px] bg-white rounded-3xl">
|
||||
<DialogContent className="sm:max-w-[450px] bg-background rounded-3xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl font-semibold text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Enter Donation Amount
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-[var(--purple-lavender)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Choose how much you'd like to donate to support our community
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label htmlFor="customAmount" className="text-[#422268] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Label htmlFor="customAmount" className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Amount (USD)
|
||||
</Label>
|
||||
<div className="relative mt-2">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[#664fa3] text-xl font-semibold">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[var(--purple-lavender)] text-xl font-semibold">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
@@ -206,7 +206,7 @@ const Donate = () => {
|
||||
value={customAmount}
|
||||
onChange={(e) => setCustomAmount(e.target.value)}
|
||||
placeholder="50.00"
|
||||
className="pl-10 h-14 text-xl border-2 border-[#ddd8eb] focus:border-[#664fa3] rounded-xl"
|
||||
className="pl-10 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-[var(--purple-lavender)] rounded-xl"
|
||||
onKeyPress={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleCustomDonate();
|
||||
@@ -214,13 +214,13 @@ const Donate = () => {
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-[var(--purple-lavender)] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum donation: $1.00
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] rounded-lg p-4">
|
||||
<p className="text-sm text-[#422268] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="bg-[var(--lavender-300)] rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--purple-ink)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Thank you for supporting LOAF!</strong><br />
|
||||
Your donation helps us continue our mission and provide meaningful experiences for our community.
|
||||
</p>
|
||||
@@ -232,14 +232,14 @@ const Donate = () => {
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setCustomAmountDialogOpen(false)}
|
||||
className="rounded-full border-2 border-[#ddd8eb]"
|
||||
className="rounded-full border-2 border-[var(--neutral-800)]"
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCustomDonate}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#48286e] rounded-full"
|
||||
className="bg-[var(--purple-lavender)] text-white hover:bg-[var(--purple-deep)] rounded-full"
|
||||
>
|
||||
Continue to Payment
|
||||
</Button>
|
||||
|
||||
@@ -11,12 +11,12 @@ const DonationSuccess = () => {
|
||||
const loafHearts = `${process.env.PUBLIC_URL}/loaf-hearts.png`;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-white to-[#f1eef9] px-6 py-20">
|
||||
<main className="bg-gradient-to-b from-white to-[var(--lavender-300)] px-6 py-20">
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<Card className="p-6 sm:p-8 md:p-12 bg-white rounded-2xl border-2 border-[#ddd8eb] shadow-xl text-center">
|
||||
<Card className="p-6 sm:p-8 md:p-12 bg-background rounded-2xl border-2 border-[var(--neutral-800)] shadow-xl text-center">
|
||||
{/* Success Icon */}
|
||||
<div className="flex justify-center mb-4">
|
||||
<img
|
||||
@@ -26,34 +26,34 @@ const DonationSuccess = () => {
|
||||
onError={(e) => e.target.style.display = 'none'}
|
||||
/>
|
||||
</div>
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-[#81B29A]/10 rounded-full mb-6">
|
||||
<CheckCircle className="h-12 w-12 text-[#81B29A]" />
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-[var(--green-light)]/10 rounded-full mb-6">
|
||||
<CheckCircle className="h-12 w-12 text-[var(--green-light)]" />
|
||||
</div>
|
||||
|
||||
{/* Title */}
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Thank You for Your Donation!
|
||||
</h1>
|
||||
|
||||
{/* Message */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<p className="text-xl text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xl text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your generous contribution helps support our community and continue our mission.
|
||||
</p>
|
||||
|
||||
<div className="bg-gradient-to-r from-[#f1eef9] to-[#DDD8EB]/30 rounded-2xl p-6 border-2 border-[#ddd8eb]">
|
||||
<div className="flex items-center justify-center gap-2 text-[#ff9e77] mb-2">
|
||||
<div className="bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 rounded-2xl p-6 border-2 border-[var(--neutral-800)]">
|
||||
<div className="flex items-center justify-center gap-2 text-[var(--orange-light)] mb-2">
|
||||
<Heart className="h-6 w-6" />
|
||||
<span className="text-lg font-semibold" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Your Support Makes a Difference
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
A receipt for your donation has been sent to your email address.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="text-base text-[#664fa3] pt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-base text-brand-purple pt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
We deeply appreciate your support and commitment to LOAF's mission of building a vibrant, inclusive community.
|
||||
</p>
|
||||
</div>
|
||||
@@ -62,7 +62,7 @@ const DonationSuccess = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate('/')}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full px-8 py-6 text-lg font-medium shadow-lg"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full px-8 py-6 text-lg font-medium shadow-lg"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Return to Home
|
||||
@@ -70,7 +70,7 @@ const DonationSuccess = () => {
|
||||
<Button
|
||||
onClick={() => navigate('/donate')}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#DDD8EB]/20 rounded-full px-8 py-6 text-lg font-medium"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-[var(--neutral-800)]/20 rounded-full px-8 py-6 text-lg font-medium"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Make Another Donation
|
||||
@@ -80,12 +80,12 @@ const DonationSuccess = () => {
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="mt-12 text-center">
|
||||
<p className="text-sm text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Have questions about your donation?
|
||||
</p>
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3] font-medium transition-colors"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium transition-colors"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Contact us at support@loaf.org
|
||||
|
||||
@@ -48,10 +48,10 @@ const EventDetails = () => {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
<div className="flex items-center justify-center min-h-[60vh]">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading event...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -62,34 +62,33 @@ const EventDetails = () => {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<button
|
||||
onClick={() => navigate('/events')}
|
||||
className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors mb-8"
|
||||
className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors mb-8"
|
||||
data-testid="back-to-events-button"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Events
|
||||
</button>
|
||||
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-4 mb-6">
|
||||
<div className="bg-[#DDD8EB]/20 p-4 rounded-xl">
|
||||
<Calendar className="h-10 w-10 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl">
|
||||
<Calendar className="h-10 w-10 text-brand-purple " />
|
||||
</div>
|
||||
{event.user_rsvp_status && (
|
||||
<Badge
|
||||
className={`px-4 py-2 rounded-full text-sm ${
|
||||
event.user_rsvp_status === 'yes'
|
||||
? 'bg-[#81B29A] text-white'
|
||||
: event.user_rsvp_status === 'no'
|
||||
className={`px-4 py-2 rounded-full text-sm ${event.user_rsvp_status === 'yes'
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: event.user_rsvp_status === 'no'
|
||||
? 'bg-gray-400 text-white'
|
||||
: 'bg-orange-100 text-orange-700'
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
{event.user_rsvp_status === 'yes' && 'Going'}
|
||||
{event.user_rsvp_status === 'no' && 'Not Going'}
|
||||
@@ -98,12 +97,12 @@ const EventDetails = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h1 className="text-4xl md:text-5xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h1>
|
||||
|
||||
<div className="space-y-4 text-lg">
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(event.start_at).toLocaleDateString('en-US', {
|
||||
@@ -114,18 +113,18 @@ const EventDetails = () => {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<Calendar className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{new Date(event.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })} -{' '}
|
||||
{new Date(event.end_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<MapPin className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 text-[#664fa3]">
|
||||
<div className="flex items-center gap-3 text-brand-purple ">
|
||||
<Users className="h-5 w-5" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.rsvp_count || 0} {event.rsvp_count === 1 ? 'person' : 'people'} attending
|
||||
@@ -136,29 +135,28 @@ const EventDetails = () => {
|
||||
</div>
|
||||
|
||||
{event.description && (
|
||||
<div className="mb-8 pb-8 border-b border-[#ddd8eb]">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="mb-8 pb-8 border-b border-[var(--neutral-800)]">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
About This Event
|
||||
</h2>
|
||||
<p className="text-[#664fa3] leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple leading-relaxed whitespace-pre-line" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.description}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
RSVP to This Event
|
||||
</h2>
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<Button
|
||||
onClick={() => handleRSVP('yes')}
|
||||
disabled={rsvpLoading}
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 ${
|
||||
event.user_rsvp_status === 'yes'
|
||||
? 'bg-[#81B29A] text-white'
|
||||
: 'bg-[#DDD8EB] text-[#422268] hover:bg-white'
|
||||
}`}
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 ${event.user_rsvp_status === 'yes'
|
||||
? 'bg-[var(--green-light)] text-white'
|
||||
: 'bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-brand-lavender'
|
||||
}`}
|
||||
data-testid="rsvp-yes-button"
|
||||
>
|
||||
<Check className="h-5 w-5" />
|
||||
@@ -168,11 +166,10 @@ const EventDetails = () => {
|
||||
onClick={() => handleRSVP('maybe')}
|
||||
disabled={rsvpLoading}
|
||||
variant="outline"
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${
|
||||
event.user_rsvp_status === 'maybe'
|
||||
? 'border-orange-400 bg-orange-100 text-orange-700'
|
||||
: 'border-[#664fa3] text-[#664fa3] hover:bg-[#f1eef9]'
|
||||
}`}
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${event.user_rsvp_status === 'maybe'
|
||||
? 'border-orange-400 bg-orange-100 text-orange-700'
|
||||
: 'border-brand-purple text-brand-purple hover:bg-[var(--lavender-300)]'
|
||||
}`}
|
||||
data-testid="rsvp-maybe-button"
|
||||
>
|
||||
<HelpCircle className="h-5 w-5" />
|
||||
@@ -182,11 +179,10 @@ const EventDetails = () => {
|
||||
onClick={() => handleRSVP('no')}
|
||||
disabled={rsvpLoading}
|
||||
variant="outline"
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${
|
||||
event.user_rsvp_status === 'no'
|
||||
? 'border-gray-400 bg-gray-100 text-gray-700'
|
||||
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
className={`rounded-full px-8 py-6 flex items-center gap-2 border-2 ${event.user_rsvp_status === 'no'
|
||||
? 'border-gray-400 bg-gray-100 text-gray-700'
|
||||
: 'border-gray-400 text-gray-600 hover:bg-gray-50'
|
||||
}`}
|
||||
data-testid="rsvp-no-button"
|
||||
>
|
||||
<X className="h-5 w-5" />
|
||||
@@ -195,11 +191,11 @@ const EventDetails = () => {
|
||||
</div>
|
||||
|
||||
{/* Add to Calendar Section */}
|
||||
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||
<h2 className="text-xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="mt-8 pt-8 border-t border-[var(--neutral-800)]">
|
||||
<h2 className="text-xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Add to Your Calendar
|
||||
</h2>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Never miss this event! Add it to your calendar app for reminders.
|
||||
</p>
|
||||
<AddToCalendarButton
|
||||
|
||||
@@ -30,7 +30,7 @@ const Events = () => {
|
||||
if (!rsvpStatus) return null;
|
||||
|
||||
const config = {
|
||||
yes: { label: 'Going', className: 'bg-[#81B29A] text-white' },
|
||||
yes: { label: 'Going', className: 'bg-[var(--green-light)] text-white' },
|
||||
no: { label: 'Not Going', className: 'bg-gray-400 text-white' },
|
||||
maybe: { label: 'Maybe', className: 'bg-orange-100 text-orange-700' }
|
||||
};
|
||||
@@ -46,67 +46,67 @@ const Events = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
<div className="mb-12">
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Upcoming Events
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Browse and RSVP to our community events.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading events...</p>
|
||||
</div>
|
||||
) : events.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6 sm:gap-8">
|
||||
{events.map((event) => (
|
||||
<Link to={`/events/${event.id}`} key={event.id}>
|
||||
<Card
|
||||
className="p-6 bg-white rounded-2xl border border-[#ddd8eb] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer h-full"
|
||||
className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg hover:-translate-y-1 transition-all cursor-pointer h-full"
|
||||
data-testid={`event-card-${event.id}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="bg-[#DDD8EB]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-3 rounded-lg">
|
||||
<Calendar className="h-6 w-6 text-brand-purple " />
|
||||
</div>
|
||||
{getRSVPBadge(event.user_rsvp_status)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-3" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{event.title}
|
||||
</h3>
|
||||
|
||||
{event.description && (
|
||||
<p className="text-[#664fa3] mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4 line-clamp-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{event.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<Calendar className="h-4 w-4" />
|
||||
<span 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' })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.location}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[#664fa3]">
|
||||
<div className="flex items-center gap-2 text-brand-purple ">
|
||||
<Users className="h-4 w-4" />
|
||||
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>{event.rsvp_count || 0} attending</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex items-center text-[#ff9e77] font-medium">
|
||||
<div className="mt-6 flex items-center text-[var(--orange-light)] font-medium">
|
||||
View Details
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</div>
|
||||
@@ -116,11 +116,11 @@ const Events = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<Calendar className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Calendar className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Events Available
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
There are no upcoming events at the moment. Check back later!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -32,28 +32,28 @@ const ForgotPassword = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<Link to="/login" className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors">
|
||||
<Link to="/login" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Login
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
{!submitted ? (
|
||||
<>
|
||||
<div className="mb-8 text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#f1eef9] mb-4">
|
||||
<Mail className="h-8 w-8 text-[#664fa3]" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--lavender-300)] mb-4">
|
||||
<Mail className="h-8 w-8 text-brand-purple " />
|
||||
</div>
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Forgot Password?
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
No worries! Enter your email and we'll send you reset instructions.
|
||||
</p>
|
||||
</div>
|
||||
@@ -69,22 +69,22 @@ const ForgotPassword = () => {
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="your.email@example.com"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
>
|
||||
{loading ? 'Sending...' : 'Send Reset Link'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Remember your password?{' '}
|
||||
<Link to="/login" className="text-[#ff9e77] hover:underline font-medium">
|
||||
<Link to="/login" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||
Login here
|
||||
</Link>
|
||||
</p>
|
||||
@@ -92,21 +92,21 @@ const ForgotPassword = () => {
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[#E8F5E9] mb-6">
|
||||
<CheckCircle className="h-8 w-8 text-[#4CAF50]" />
|
||||
<div className="inline-flex items-center justify-center w-16 h-16 rounded-full bg-[var(--green-bg)] mb-6">
|
||||
<CheckCircle className="h-8 w-8 text-[var(--green-success)]" />
|
||||
</div>
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Check Your Email
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If an account exists for <span className="font-medium text-[#422268]">{email}</span>,
|
||||
<p className="text-lg text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If an account exists for <span className="font-medium text-[var(--purple-ink)]">{email}</span>,
|
||||
you will receive a password reset link shortly.
|
||||
</p>
|
||||
<p className="text-sm text-[#664fa3] mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The link will expire in 1 hour. If you don't see the email, check your spam folder.
|
||||
</p>
|
||||
<Link to="/login">
|
||||
<Button className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform">
|
||||
<Button className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform">
|
||||
Return to Login
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { LuArrowDown } from "react-icons/lu";
|
||||
const CardSection = ({ children, className = '', arrow = true }) => (
|
||||
<section className={` ${className}`}>
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Card className="p-14 bg-white rounded-3xl">
|
||||
<Card className="p-14 bg-background rounded-3xl">
|
||||
{children}
|
||||
</Card>
|
||||
</div>
|
||||
@@ -23,13 +23,13 @@ const CardSection = ({ children, className = '', arrow = true }) => (
|
||||
);
|
||||
|
||||
const Title = ({ children }) => (
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{children}
|
||||
</h2>
|
||||
);
|
||||
|
||||
const Paragragh = ({ children }) => (
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{children}
|
||||
</p>
|
||||
);
|
||||
@@ -43,18 +43,18 @@ const History = () => {
|
||||
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-br from-[#F9FAFB] to-[#DCD7EA] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
<main className="bg-gradient-to-br from-[var(--neutral-100:)] to-[var(--neutral-700)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-8 sm:py-10 md:py-12">
|
||||
{/* Hero Section */}
|
||||
<section className="py-12">
|
||||
<div className="max-w-3xl mx-auto flex justify-around mb-12 flex-col gap-6 items-center lg:flex-row">
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-[#48286e] "
|
||||
<h1 className="text-3xl sm:text-4xl md:text-5xl font-extrabold text-[var(--purple-deep)] "
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
History of LOAF
|
||||
</h1>
|
||||
<div className="flex items-center justify-center gap-6 text-[#48286e]">
|
||||
<div className="flex items-center justify-center gap-6 text-[var(--purple-deep)]">
|
||||
<Pen className="size-7" />
|
||||
<p className="text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
By Arden Eversmeyer
|
||||
@@ -69,19 +69,19 @@ const History = () => {
|
||||
<div className="md:w-1/3 ">
|
||||
<img src={ardenCharlotteImg} alt="Arden Eversmeyer and Charlotte Avery"
|
||||
className="rounded-lg w-full" onError={(e) => e.target.style.display = 'none'} />
|
||||
<p className="text-sm text-[#48286e] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-[var(--purple-deep)] mt-2 text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Arden Eversmeyer and Charlotte Avery
|
||||
</p>
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<Title>Part 1</Title>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1985 my life partner of 33 years died. For many years we had been part of a large “friendship group”, many of whom had been together longer than we had. But I was the first to lose a partner. After a few months I began to feel the need to explore community. Already retired, the necessity of being closeted was gone. I soon discovered there was no group for mid-life an old lesbians in Houston, and began the search for such groups around the U.S.
|
||||
</p>
|
||||
<p className="text-md mb-4 text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md mb-4 text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In April of 1987 I loaded my camper and headed for California. I started in San Diego, and worked my way up the coast to San Francisco finding and visiting senior LGBT groups. I came home with much information, and some suggestions about organizing. One fact that was consistent with all the groups was that if the group was for both men and women - the women dropped out. The recommendation was to start a group for women only.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>I had become friends with some young lesbians here in Houston who knew the Gay community. We started meeting and brainstorming, and the group now known as LOAF was born.</p>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>I had become friends with some young lesbians here in Houston who knew the Gay community. We started meeting and brainstorming, and the group now known as LOAF was born.</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
@@ -90,13 +90,13 @@ const History = () => {
|
||||
{/* Part 2 */}
|
||||
<CardSection >
|
||||
<Title>Part 2</Title>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The Founding Mothers of LOAF are Ruth Sathre (nurse), JoAnn Beene (psychologist), Delores Nason (business woman), JoAnn Loulan (psychologist and writer, now living in Guerneville, CA), and Judy Peyton (social worker). We decided to form a group for Lesbians Over Fifty and began the search for others "like us."
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1993, we advertised in OutSmart Magazine, the Houston gay magazine, and invited interested women to join us at a local restaurant. Founding mothers and 19 other women came to that first meeting. Since then the group has gone through many evolutions.
|
||||
</p>
|
||||
<ul className="list-disc ml-6 mt-4 space-y-2 text-md text-[#48286e]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<ul className="list-disc ml-6 mt-4 space-y-2 text-md text-[var(--purple-deep)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>AGE OF PARTICIPANTS - We launched as LOAFF (Lesbians Over Age Fifty-Five) and quickly lowered the entry age to fifty so more women could join.</li>
|
||||
<li>NAME FOR THE GROUP - The acronym began as LOAFF, then we streamlined it to the now-familiar LOAF.</li>
|
||||
<li>AMOUNT OF STRUCTURING - Too many rules can smother a grassroots group, so we kept things loose and let participating women guide direction as needs evolved.</li>
|
||||
@@ -119,13 +119,13 @@ const History = () => {
|
||||
</div>
|
||||
<div className="">
|
||||
<Title>Part 3</Title>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The first meeting of LOAF was the third Sunday of October 1987 at Womynspace. There were six women besides myself at that first meeting. attending were Betty Rudnick, Billie Carter, Josephine Jones, Sylvia Porter, Marjorie Fulp, and Charlotte Avery. Of those six women, only Sylvia Porter and Charlotte Avery are still alive.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Much that we discussed that day is still the heart of the group today - decisions by consensus at monthly meetings and activities governed by needs and wishes of participating women. It was soon decided to make the age requirement for membership age 50, and we became LOAF.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In 1989 member Jo Stewart, social worker at Methodist Hospital, started urging LOAF to incorporate as a non-profit. The work began in 1990 with Moore & Hunt (Debbie Hunt) as our Corporate Attorneys. Jo died of cancer in 1990. The work for application of our 501(c)3 was done by Floi Ewing, Arden's sister, and our non-profit status was granted in January 1991. Loaf incorporated as a social networking and support group without a membership roll to protect the anonymity of the women in LOAF.
|
||||
</p>
|
||||
</div>
|
||||
@@ -137,7 +137,7 @@ const History = () => {
|
||||
<CardSection>
|
||||
<div className=" ">
|
||||
<Title>Part 4</Title>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Third Sunday meeting places have changed over the years. We moved from Womynspace to Autrey House near Rice University. We were there from November 1987 until May 1990 when the new Bishop dis-invited all GLBT groups because of homophobia. We spent a couple months at Montrose Counseling Center (on Lovett), and then moved to the Metropolitan Multi- Service Center on W. Gray. We met there from August 1990 until January 1993. We left because the city started closing the centers on Sunday, and we were not willing to change our meeting day. From February through June we met at Inklings Book Store . In July we started our long occupancy with Houston Mission Church, and met there until the church dissolved in April 2001. We then met at the Hollyfield Center for seven months. From there we went to the GLBT Community Center on Hawthorne where we stayed until July 2003. Attendance was dropping off, and some of the women were not comfortable in a gay identified place. So Third Sunday Meetings moved to Charlotte and Arden's home - and we met there from August 2003 until April 2011. Membership had grown until the meetings had reached critical mass and parking was a problem. So a team of board members started researching for a new home. And on the third Sunday of May 2011 LOAF started meeting at the Montrose Counseling Center. A new era had started.
|
||||
</p>
|
||||
</div>
|
||||
@@ -155,16 +155,16 @@ const History = () => {
|
||||
|
||||
<div className="w-full lg:w-1/2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Title>Part 5</Title>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" >
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" >
|
||||
The activities of the monthly meetings were decided by the participating women. Rules were very limited, and decisions were made monthly by the women attending. We soon decided to set up a quarterly meeting schedule of pot-luck, speaker, and games. We followed this schedule for at least ten years. New ideas/programs were added as time allowed. Our first speaker was Pokey Anderson, an icon in the GLBT community. She provided much information about our history in Houston. We also decided to ask Deb Hunt to talk to us about documents, and she has done this periodically over the years. She spoke to us well before we decided to incorporate, and she then became our Corporate Attorney.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In November 1988 we instituted our annual Benevolent Project. We collected items to contribute to Stone Soup Kitchen - a GLBT food pantry. We have done this every year since, and have contributed to groups such as Omega House, The Rose, Battered Women, local lesbian organizations, and some individuals.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
In June 1989 we entered our first Pride Parade. That year we had a convertible and a walking group. Several of the LOAF women participate with other groups in the parade, but we have participated every year since 1989. In 2010 we entered our first float and won a trophy for best representation of the theme.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
All of these decisions were made by the women at the Third Sunday meetings. There have never been rules instigated by the Board of Directors. Because many women don't want to attend meetings, we changed Third Sunday Meeting to Meet 'N Greet several years ago. And that is what we do - take care of any necessary business. But greet newcomers and socialize with our friends.
|
||||
</p>
|
||||
</div>
|
||||
@@ -176,23 +176,23 @@ const History = () => {
|
||||
{/* Part 6 */}
|
||||
<CardSection >
|
||||
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Part 6
|
||||
</h2>
|
||||
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Socials have always been a big part of the activities for LOAF, and having a social as well as the monthly “Third Sunday” meeting was always on the calendar. Pot lucks topped the list for many years, and they were hosted by women in their homes. That is more difficult now because of the size of the membership. Bev and Sandy have hosted a potluck since 1995, and Eva Geer for several years.
|
||||
</p>
|
||||
|
||||
<p className="text-md text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The ice cream socials started in 1989. There are still women who have never cranked or eaten home made ice cream.The “picnic in the park” started in 2000. We have held picnics in a couple State Parks as well as Tom Bass Park in recent years.In 1988 we started attending the TUTS Broadway Musical at Miller Theater in July. We bring a snack supper and a chair and sit on the hill.In 2000 we started eating at Sudie's Catfish House in January. A breather from a busy party season, but a good way to connect.From 1987 to 1994 we had “Second Tuesday Dancing”. First at The Ranch, and then at Ms B's, it was our way to celebrate birthdays of the month. It was well attended.
|
||||
</p>
|
||||
|
||||
<p className="text-md text-[#48286e] leading-relaxed mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
From 1989 to 1993 we had a five day Thanksgiving camp-out at a State Park. We roasted turkey and women came for potluck on Thanksgiving Day.We had from 10 to 20 campers, and maybe 25 for dinner.We have had a Christmas party every year since 1987.We have had several Port of Houston tours on the Sam Houston; several Houston Zoo tours; museum tours; and out-of town tours for bluebonnets, miniature horses, and Blue Bell ice cream. And occasionally, for lack of inspiration for an event social, we simply met for lunch at a local restaurant.
|
||||
</p>
|
||||
|
||||
<p className="text-md text-[#48286e] leading-relaxed mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mt-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has always been a social group. We have never had support groups or counseling. We are a place to meet other lesbians over fifty, make friends, and have fun.
|
||||
</p>
|
||||
|
||||
@@ -208,13 +208,13 @@ const History = () => {
|
||||
|
||||
</div>
|
||||
<div className="md:w-2/3">
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Part 7
|
||||
</h2>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The LOAF Library has been an important part of the offering to the women. It started about 1987 when Arden discovered there were books - both fiction and non-fiction - about lesbians. We had one bookstore then -” Wilde 'N Stein” - that had a limited selection of lesbian books. Then Arden discovered Womencraft Books, a mail order book company. This began the collection now in the library. Over the years women have donated books. At one time we took duplicate titles to our book stores (Inkilngs and Book Woman) and traded them for titles we didn't have on the shelf. When the last book store closed we started donating duplicate copies to HATCH, and they are building their library. We have a collection that includes feminist, fantasy/sci-fi, poetry, non-fiction, as well as fiction books. We have a collection of out-of-print periodicals, women's music, and a video library. We have some beautiful “coffee table” books. We have copies of many of the “pulp” books.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF also was gifted with a beautiful pianola player piano and about 150 piano rolls. Marie Mariano donated it several years ago. It was in the “Allison” flood, and when it was restored Arden was told it was a beautiful instrument, and quite valuable.
|
||||
</p>
|
||||
</div>
|
||||
@@ -225,16 +225,16 @@ const History = () => {
|
||||
{/* Part 8 */}
|
||||
<CardSection arrow={false} >
|
||||
|
||||
<h2 className="text-3xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<h2 className="text-3xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
Part 8
|
||||
</h2>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
LOAF has become a unique organization in that it is the oldest lesbian organization in Houston, and the only one of its kind in Texas. Over the years there has been quite a bit of exposure and promotion for LOAF.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
For 17 consecutive years, from 1987 to 2004, we had a Texas Lesbian Conference that rotated between Houston, San Antonio, Austin, and Dallas. LOAF presented workshops at five of these conferences. LOAF did a workshop at the National Lesbian Conference in Atlanta in 1991. We did a workshop at at the PFLAG “Healing the Hurt” conference in 1994. We did a program at the Silver Threads conference in St Petersburg, FL. We have done programs at three OLOC conferences. Charlotte and Arden participated in a live TV show about senior GLBT persons in Dallas. We participated in a documentary on GLBT seniors produced in Canada. And another documentary for Golden Threads at Cape Cod. We participated on a panel for the Women’s Studies Department at the University of Houston for their “Living archive” series. We have done several programs for the Women’s Group in Houston, and appeared on the After Hours radio show on KPFT several times.
|
||||
</p>
|
||||
<p className="text-md text-[#48286e] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-md text-[var(--purple-deep)] leading-relaxed" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
All of these appearances are documented and part of the LOAF Archives located at the University of Houston Special Collections Library. One result of these workshops and programs has been the formation of similar groups for mid-life and old lesbians throughout the country. But most important is the connection with other lesbians of our generation and avoiding isolation..
|
||||
</p>
|
||||
|
||||
@@ -243,32 +243,32 @@ const History = () => {
|
||||
</main>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="py-20 bg-[#48286e] mx-0">
|
||||
<section className="py-20 bg-[var(--purple-deep)] mx-0">
|
||||
<div className="max-w-7xl mx-auto px-8">
|
||||
<div className="flex gap-8 md:flex-row flex-col">
|
||||
<Card className="p-8 text-center bg-white rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Card className="p-8 text-center bg-background rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
A Life Remembered
|
||||
</h3>
|
||||
<p className="text-[#48286e] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Check out "A Life Remembered", a tribute dedicated to Arden Eversmeyer, one of the founding mothers of LOAF.
|
||||
</p>
|
||||
<a href="https://www.oldlesbianhistory.org/arden-eversmeyer" target="_blank" rel="noopener noreferrer">
|
||||
<Button className="bg-[#664fa3] hover:bg-[#48286e] text-white rounded-full px-6 py-3">
|
||||
<Button className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white rounded-full px-6 py-3">
|
||||
View Arden's Tribute
|
||||
</Button>
|
||||
</a>
|
||||
</Card>
|
||||
|
||||
<Card className="p-8 text-center bg-white rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[#48286e] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<Card className="p-8 text-center bg-background rounded-2xl shadow-lg hover:shadow-xl transition-shadow">
|
||||
<h3 className="text-2xl font-bold text-[var(--purple-deep)] mb-4" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
The Old Lesbian Oral Herstory Project
|
||||
</h3>
|
||||
<p className="text-[#48286e] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] mb-6" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Arden Eversmeyer was also involved with The Old Lesbian Oral Herstory Project, preserving the stories of old lesbians.
|
||||
</p>
|
||||
<a href="https://www.olohp.org" target="_blank" rel="noopener noreferrer">
|
||||
<Button className="bg-[#664fa3] hover:bg-[#48286e] text-white rounded-full px-6 py-3">
|
||||
<Button className="bg-[var(--purple-lavender)] hover:bg-[var(--purple-deep)] text-white rounded-full px-6 py-3">
|
||||
Learn More About OLOHP
|
||||
</Button>
|
||||
</a>
|
||||
|
||||
@@ -15,7 +15,7 @@ const Landing = () => {
|
||||
const heroLoaf = `${process.env.PUBLIC_URL}/hero-loaf.png`;
|
||||
const friendships = `${process.env.PUBLIC_URL}/friendships.png`;
|
||||
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-background 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">
|
||||
<img
|
||||
src={iconSrc}
|
||||
@@ -24,7 +24,7 @@ const Landing = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className="p-6 flex flex-col pt-10 gap-4.5 w-full">
|
||||
<h5 className="text-[#48286e] text-[28px] leading-10 pb-10 font-semibold text-center" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
<h5 className="text-[var(--purple-deep)] text-[28px] leading-10 pb-10 font-semibold text-center" style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
{infoTitle}
|
||||
</h5>
|
||||
{description}
|
||||
@@ -37,7 +37,7 @@ const Landing = () => {
|
||||
iconSrc: iconMeetGreet,
|
||||
infoTitle: 'Meet and Greet',
|
||||
description: (
|
||||
<p className="text-[#48286e] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
The MEET and GREETs provide opportunities for prospective members to get acquainted with LOAF, have conversations
|
||||
with members, and ask the board of directors questions. They are held the 3rd Sunday of the month and usually
|
||||
take place at a restaurant or other fun places conducive to its purpose. Please email{' '}
|
||||
@@ -49,7 +49,7 @@ const Landing = () => {
|
||||
iconSrc: iconSocials,
|
||||
infoTitle: 'Socials',
|
||||
description: (
|
||||
<p className="text-[#48286e] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Our social events provide opportunities for members to explore Houston and connect with other lesbians. Past
|
||||
social events include bowling, museums, painting lessons, sporting events, Miller Outdoor Theater, bingo and board
|
||||
games, pool parties, putt putt golf, camping and holiday get togethers. No matter your age or ability, there is
|
||||
@@ -61,7 +61,7 @@ const Landing = () => {
|
||||
iconSrc: iconActive,
|
||||
infoTitle: 'Active LOAFers',
|
||||
description: (
|
||||
<p className="text-[#48286e] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-[var(--purple-deep)] text-lg text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
ActiveLOAFers events provide members with opportunities to be physically active. Past activities have included
|
||||
hiking/walking in the park, swimming (or floating), pickleball, kayaking, bike riding, axe throwing, and strolling
|
||||
through the botanic gardens or the Arboretum.
|
||||
@@ -71,17 +71,17 @@ const Landing = () => {
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
{/* Hero Section */}
|
||||
<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">
|
||||
<section className="relative bg-gradient-to-b from-[var(--purple-deep)] to-[var(--purple-lavender)] 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">
|
||||
{/* 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>
|
||||
<div className="absolute inset-0 z-[1] bg-background/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">
|
||||
@@ -89,7 +89,7 @@ const Landing = () => {
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 items-center justify-center w-full max-w-[339px]">
|
||||
<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-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full px-6 py-6 sm:py-[32px] text-base sm:text-lg font-medium w-full transition-colors">
|
||||
Become a Member
|
||||
</Button>
|
||||
</Link>
|
||||
@@ -105,9 +105,9 @@ const Landing = () => {
|
||||
</section>
|
||||
|
||||
{/* About Section */}
|
||||
<section id="about" className="bg-gradient-to-b pb-10 lg:pb-44 from-white to-[#f1eef9] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 pt-4 sm:pt-16 md:pt-20 lg:pt-30 flex flex-col">
|
||||
<section id="about" className="bg-gradient-to-b pb-10 lg:pb-44 from-white to-[var(--lavender-300)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 pt-4 sm:pt-16 md:pt-20 lg:pt-30 flex flex-col">
|
||||
<div className="flex flex-col items-center pt-4">
|
||||
<h3 className="text-[#48286e] px-4 pb-6 md:py-8 text-4xl leading-[60px] md:text-5xl lg:text-6xl font-extrabold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-[var(--purple-deep)] px-4 pb-6 md:py-8 text-4xl leading-[60px] md:text-5xl lg:text-6xl font-extrabold text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to LOAF
|
||||
</h3>
|
||||
</div>
|
||||
@@ -118,17 +118,17 @@ const Landing = () => {
|
||||
</section>
|
||||
|
||||
{/* Feature Cards Section */}
|
||||
<section className="bg-gradient-to-b pb-20 from-[#f1eef9] to-[#ddd8eb] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-4 md:py-20 lg:py-30 flex flex-col sm:w-full lg:flex-row gap-40 md:gap-64 lg:gap-8 items-stretch justify-center">
|
||||
<section className="bg-gradient-to-b pb-20 from-[var(--lavender-300)] to-[var(--neutral-800)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-4 md:py-20 lg:py-30 flex flex-col sm:w-full lg:flex-row gap-40 md:gap-64 lg:gap-8 items-stretch justify-center">
|
||||
{infoCardData.map((card) => (
|
||||
<InfoCard key={card.infoTitle} {...card} />
|
||||
))}
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-gradient-to-b from-[#644c9f] to-[#48286e] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex items-center justify-center">
|
||||
<section className="bg-gradient-to-b from-[var(--purple-amethyst)] to-[var(--purple-deep)] px-4 sm:px-6 md:px-8 lg:px-12 xl:px-16 py-12 sm:py-16 md:py-20 lg:py-30 flex items-center justify-center">
|
||||
<div className="flex flex-col-reverse md:flex-col lg:flex-row gap-8 sm:gap-10 md:gap-12 items-center justify-center w-full max-w-6xl">
|
||||
<Link to="/register" className="w-full sm:w-auto flex items-center justify-center">
|
||||
<Button className="bg-[#DDD8EB] hover:bg-white text-[#422268] rounded-full
|
||||
<Button className="bg-[var(--neutral-800)] hover:bg-background text-[var(--purple-ink)] rounded-full
|
||||
py-8 text-xl font-normal px-12 sm:w-[392px] transition-colors ">
|
||||
Become a Member
|
||||
</Button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useNavigate, Link, useLocation, useSearchParams } from 'react-router-dom';
|
||||
import { useAuth } from '../context/AuthContext';
|
||||
import { Button } from '../components/ui/button';
|
||||
import { Input } from '../components/ui/input';
|
||||
@@ -13,13 +13,40 @@ import { ArrowRight, ArrowLeft } from 'lucide-react';
|
||||
|
||||
const Login = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { login } = useAuth();
|
||||
const basename = process.env.REACT_APP_BASENAME || '';
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
password: ''
|
||||
});
|
||||
|
||||
// Show session expiry message on mount
|
||||
useEffect(() => {
|
||||
const sessionParam = searchParams.get('session');
|
||||
const stateMessage = location.state?.message;
|
||||
|
||||
if (sessionParam === 'expired') {
|
||||
toast.info('Your session has expired. Please log in again.', {
|
||||
duration: 5000,
|
||||
});
|
||||
// Clean up URL (respect basename for subpath deployments)
|
||||
window.history.replaceState({}, '', `${basename}/login`);
|
||||
} else if (sessionParam === 'idle') {
|
||||
toast.info('You were logged out due to inactivity. Please log in again.', {
|
||||
duration: 5000,
|
||||
});
|
||||
// Clean up URL (respect basename for subpath deployments)
|
||||
window.history.replaceState({}, '', `${basename}/login`);
|
||||
} else if (stateMessage) {
|
||||
toast.info(stateMessage, {
|
||||
duration: 5000,
|
||||
});
|
||||
}
|
||||
}, [searchParams, location.state, basename]);
|
||||
|
||||
const handleInputChange = (e) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
@@ -55,23 +82,23 @@ const Login = () => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<div className="max-w-md mx-auto px-6 py-12">
|
||||
<div className="mb-8">
|
||||
<Link to="/" className="inline-flex items-center text-[#664fa3] hover:text-[#ff9e77] transition-colors">
|
||||
<Link to="/" className="inline-flex items-center text-brand-purple hover:text-[var(--orange-light)] transition-colors">
|
||||
<ArrowLeft className="h-4 w-4 mr-2" />
|
||||
Back to Home
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<Card className="p-8 md:p-12 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg">
|
||||
<Card className="p-8 md:p-12 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg">
|
||||
<div className="mb-8 text-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-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome Back
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Login to access your member dashboard.
|
||||
</p>
|
||||
</div>
|
||||
@@ -87,15 +114,15 @@ const Login = () => {
|
||||
value={formData.email}
|
||||
onChange={handleInputChange}
|
||||
placeholder="your.email@example.com"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
data-testid="login-email-input"
|
||||
className="h-14 rounded-xl border-2 focus:border-brand-purple "
|
||||
data-testid="login-email-input "
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Link to="/forgot-password" className="text-sm text-[#ff9e77] hover:underline">
|
||||
<Link to="/forgot-password" className="text-sm text-[var(--orange-light)] hover:underline">
|
||||
Forgot password?
|
||||
</Link>
|
||||
</div>
|
||||
@@ -106,7 +133,7 @@ const Login = () => {
|
||||
value={formData.password}
|
||||
onChange={handleInputChange}
|
||||
placeholder="Enter your password"
|
||||
className="h-14 rounded-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="h-14 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
data-testid="login-password-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -114,16 +141,16 @@ const Login = () => {
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-medium shadow-lg hover:scale-105 transition-transform disabled:opacity-50"
|
||||
className="w-full py-6 text-lg font-medium shadow-lg hover:scale-105 disabled:opacity-50 btn-lavender"
|
||||
data-testid="login-submit-button"
|
||||
>
|
||||
{loading ? 'Logging in...' : 'Login'}
|
||||
<ArrowRight className="ml-2 h-5 w-5" />
|
||||
</Button>
|
||||
|
||||
<p className="text-center text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-center text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Don't have an account?{' '}
|
||||
<Link to="/register" className="text-[#ff9e77] hover:underline font-medium">
|
||||
<Link to="/register" className="text-[var(--orange-light)] hover:underline font-medium">
|
||||
Register here
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
@@ -2,19 +2,21 @@ import React from 'react';
|
||||
import PublicNavbar from '../components/PublicNavbar';
|
||||
import PublicFooter from '../components/PublicFooter';
|
||||
import { Card } from '../components/ui/card';
|
||||
import { useThemeConfig } from '../context/ThemeConfigContext';
|
||||
|
||||
const MissionValues = () => {
|
||||
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`;
|
||||
const { getLogoUrl } = useThemeConfig();
|
||||
const loafLogo = getLogoUrl();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<PublicNavbar />
|
||||
|
||||
<main className="bg-gradient-to-b from-[#f9fafb] to-[#ddd8eb] px-4 sm:px-6 py-8 sm:py-12 md:py-20">
|
||||
<main className="bg-gradient-to-b from-[var(--neutral-100:)] to-[var(--neutral-800)] px-4 sm:px-6 py-8 sm:py-12 md:py-20">
|
||||
<div className="max-w-[1400px] mx-auto">
|
||||
<div className="flex md:flex-row flex-col gap-10 items-stretch">
|
||||
{/* Left Card - Mission (Purple Gradient) */}
|
||||
<Card className=" bg-gradient-to-br from-[#664fa3] to-[#48286e] p-16 rounded-2xl shadow-lg flex flex-col items-center justify-between flex-1 w-full md:w-1/2 ">
|
||||
<Card className=" bg-gradient-to-br from-[var(--purple-lavender)] to-[var(--purple-deep)] p-16 rounded-2xl shadow-lg flex flex-col items-center justify-between flex-1 w-full md:w-1/2 ">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white text-center mb-6"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF Mission
|
||||
@@ -29,12 +31,12 @@ const MissionValues = () => {
|
||||
</Card>
|
||||
|
||||
{/* Right Card - Values */}
|
||||
<Card className="bg-white p-16 rounded-2xl shadow-lg flex-1 w-full md:w-1/2 ">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#48286e] text-center mb-6"
|
||||
<Card className="bg-background p-16 rounded-2xl shadow-lg flex-1 w-full md:w-1/2 ">
|
||||
<h2 className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-deep)] text-center mb-6"
|
||||
style={{ fontFamily: "'Poppins', sans-serif" }}>
|
||||
LOAF Values
|
||||
</h2>
|
||||
<ol className="list-decimal list-inside space-y-8 text-lg text-[#48286e]"
|
||||
<ol className="list-decimal list-inside space-y-8 text-lg text-[var(--purple-deep)]"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<li>Safe environments for lesbians to gather for a variety of social activities and interaction.</li>
|
||||
<li>Social support for lesbians.</li>
|
||||
|
||||
@@ -8,32 +8,32 @@ 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">
|
||||
<div className="min-h-screen bg-gradient-to-br from-[var(--lavender-700)] to-white flex items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl p-12 bg-background rounded-2xl border border-[var(--neutral-800)] 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"
|
||||
className="text-[180px] font-bold text-transparent bg-clip-text bg-gradient-to-br from-[var(--neutral-800)] to-[var(--lavender-700)] 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" />
|
||||
<Search className="h-24 w-24 text-brand-purple opacity-30" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Message */}
|
||||
<h2
|
||||
className="text-3xl font-semibold text-[#422268] mb-4"
|
||||
className="text-3xl font-semibold text-[var(--purple-ink)] mb-4"
|
||||
style={{ fontFamily: "'Inter', sans-serif" }}
|
||||
>
|
||||
Page Not Found
|
||||
</h2>
|
||||
<p
|
||||
className="text-lg text-[#664fa3] mb-8 max-w-md mx-auto"
|
||||
className="text-lg text-brand-purple 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.
|
||||
@@ -44,14 +44,14 @@ const NotFound = () => {
|
||||
<Button
|
||||
onClick={() => navigate(-1)}
|
||||
variant="outline"
|
||||
className="rounded-xl border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#f9f8fb] px-6 py-6"
|
||||
className="rounded-xl border-2 border-brand-purple text-brand-purple hover:bg-[var(--lavender-700)] 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"
|
||||
className="rounded-xl bg-gradient-to-r from-brand-purple to-[var(--purple-ink)] hover:from-[var(--purple-ink)] hover:to-brand-purple text-white px-6 py-6"
|
||||
>
|
||||
<Home className="h-5 w-5 mr-2" />
|
||||
Back to Home
|
||||
@@ -59,15 +59,15 @@ const NotFound = () => {
|
||||
</div>
|
||||
|
||||
{/* Help Text */}
|
||||
<div className="mt-8 pt-8 border-t border-[#ddd8eb]">
|
||||
<div className="mt-8 pt-8 border-t border-[var(--neutral-800)]">
|
||||
<p
|
||||
className="text-sm text-[#664fa3]"
|
||||
className="text-sm text-brand-purple "
|
||||
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"
|
||||
className="text-brand-purple hover:text-[var(--purple-ink)] font-semibold underline"
|
||||
>
|
||||
support@loaftx.org
|
||||
</a>
|
||||
|
||||
@@ -9,7 +9,7 @@ const PaymentCancel = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
@@ -22,48 +22,48 @@ const PaymentCancel = () => {
|
||||
</div>
|
||||
|
||||
{/* Cancel Message */}
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Cancelled
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your payment was cancelled. No charges have been made to your account.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Info Card */}
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
What Happened?
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-8">
|
||||
<p className="text-[#664fa3] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You cancelled the payment process or closed the checkout page. Your membership has not been activated yet.
|
||||
</p>
|
||||
|
||||
<div className="bg-[#DDD8EB]/20 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="bg-[var(--neutral-800)]/20 p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Ready to Complete Your Membership?
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-3">
|
||||
<CreditCard className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CreditCard className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Return to the plans page to complete your subscription
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<Mail className="h-5 w-5 text-[#664fa3] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<Mail className="h-5 w-5 text-brand-purple flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Contact us if you experienced any issues during checkout
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="bg-[#f1eef9] p-6 rounded-xl">
|
||||
<p className="text-sm text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[#422268]">Note:</span>{' '}
|
||||
<div className="bg-[var(--lavender-300)] p-6 rounded-xl">
|
||||
<p className="text-sm text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Note:</span>{' '}
|
||||
Your membership application is still validated. You can complete payment whenever you're ready.
|
||||
</p>
|
||||
</div>
|
||||
@@ -73,7 +73,7 @@ const PaymentCancel = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate('/plans')}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="try-again-button"
|
||||
>
|
||||
<CreditCard className="mr-2 h-5 w-5" />
|
||||
@@ -82,7 +82,7 @@ const PaymentCancel = () => {
|
||||
<Button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="back-to-dashboard-button"
|
||||
>
|
||||
<ArrowLeft className="mr-2 h-5 w-5" />
|
||||
@@ -92,17 +92,17 @@ const PaymentCancel = () => {
|
||||
</Card>
|
||||
|
||||
{/* Support Section */}
|
||||
<Card className="p-6 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-3 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-6 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-3 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Need Assistance?
|
||||
</h3>
|
||||
<p className="text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If you encountered any technical issues or have questions about the payment process, our support team is here to help.
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3] font-medium text-lg"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium text-lg"
|
||||
>
|
||||
support@loaf.org
|
||||
</a>
|
||||
|
||||
@@ -20,60 +20,60 @@ const PaymentSuccess = () => {
|
||||
}, [refreshUser]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-4xl mx-auto px-6 py-12">
|
||||
<div className="text-center mb-12">
|
||||
{/* Success Icon */}
|
||||
<div className="mb-8">
|
||||
<div className="bg-[#81B29A] rounded-full w-24 h-24 mx-auto flex items-center justify-center">
|
||||
<div className="bg-[var(--green-light)] rounded-full w-24 h-24 mx-auto flex items-center justify-center">
|
||||
<CheckCircle className="h-12 w-12 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Success Message */}
|
||||
<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-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Payment Successful!
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple max-w-2xl mx-auto mb-8" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Thank you for your payment. Your LOAF membership is now active!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Card */}
|
||||
<Card className="p-8 bg-white rounded-2xl border border-[#ddd8eb] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] shadow-lg mb-8">
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Welcome to the LOAF Community!
|
||||
</h2>
|
||||
|
||||
<div className="space-y-6 mb-8">
|
||||
<div className="bg-[#f1eef9] p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="bg-[var(--lavender-300)] p-6 rounded-xl">
|
||||
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
What's Next?
|
||||
</h3>
|
||||
<ul className="space-y-3">
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Your membership is now active and you have full access to all member benefits
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You can now RSVP and attend members-only events
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Access the community directory and connect with other members
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
You'll receive our newsletter with exclusive updates and announcements
|
||||
</span>
|
||||
</li>
|
||||
@@ -81,12 +81,12 @@ const PaymentSuccess = () => {
|
||||
</div>
|
||||
|
||||
{sessionId && (
|
||||
<div className="bg-[#DDD8EB]/20 p-4 rounded-xl">
|
||||
<p className="text-sm text-[#664fa3] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[#422268]">Transaction ID:</span>{' '}
|
||||
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-xl">
|
||||
<p className="text-sm text-brand-purple text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<span className="font-medium text-[var(--purple-ink)]">Transaction ID:</span>{' '}
|
||||
<span className="font-mono text-xs">{sessionId}</span>
|
||||
</p>
|
||||
<p className="text-xs text-[#664fa3] text-center mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-xs text-brand-purple text-center mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
A confirmation email has been sent to your registered email address.
|
||||
</p>
|
||||
</div>
|
||||
@@ -97,7 +97,7 @@ const PaymentSuccess = () => {
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Button
|
||||
onClick={() => navigate('/dashboard')}
|
||||
className="bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="go-to-dashboard-button"
|
||||
>
|
||||
<User className="mr-2 h-5 w-5" />
|
||||
@@ -106,7 +106,7 @@ const PaymentSuccess = () => {
|
||||
<Button
|
||||
onClick={() => navigate('/events')}
|
||||
variant="outline"
|
||||
className="border-2 border-[#664fa3] text-[#664fa3] hover:bg-[#664fa3] hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
className="border-2 border-brand-purple text-brand-purple hover:bg-brand-purple hover:text-white rounded-full px-8 py-6 text-lg font-semibold"
|
||||
data-testid="browse-events-button"
|
||||
>
|
||||
<Calendar className="mr-2 h-5 w-5" />
|
||||
@@ -117,11 +117,11 @@ const PaymentSuccess = () => {
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Need help? Contact us at{' '}
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3] font-medium"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
|
||||
>
|
||||
support@loaf.org
|
||||
</a>
|
||||
|
||||
@@ -208,36 +208,36 @@ const Plans = () => {
|
||||
const breakdown = getAmountBreakdown();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="min-h-screen bg-background">
|
||||
<Navbar />
|
||||
|
||||
<div className="max-w-7xl mx-auto px-6 py-12">
|
||||
{/* Header */}
|
||||
<div className="mb-12 text-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-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Membership Plans
|
||||
</h1>
|
||||
<p className="text-lg text-[#664fa3] max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-lg text-brand-purple max-w-2xl mx-auto" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Choose the membership plan that works best for you and become part of our vibrant community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Status Banner */}
|
||||
{statusInfo && statusInfo.title && (
|
||||
<Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[#f1eef9] to-[#DDD8EB]/30 border-2 border-[#664fa3]">
|
||||
<Card className="max-w-3xl mx-auto mb-8 p-6 bg-gradient-to-r from-[var(--lavender-300)] to-[var(--neutral-800)]/30 border-2 border-brand-purple ">
|
||||
<div className="flex items-start gap-4">
|
||||
<AlertCircle className="h-6 w-6 text-[#664fa3] flex-shrink-0 mt-1" />
|
||||
<AlertCircle className="h-6 w-6 text-brand-purple flex-shrink-0 mt-1" />
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{statusInfo.title}
|
||||
</h3>
|
||||
<p className="text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{statusInfo.message}
|
||||
</p>
|
||||
{statusInfo.action && statusInfo.actionLink && (
|
||||
<Button
|
||||
onClick={() => navigate(statusInfo.actionLink)}
|
||||
className="bg-[#664fa3] text-white hover:bg-[#422268] rounded-full"
|
||||
className="bg-brand-purple text-white hover:bg-[var(--purple-ink)] rounded-full"
|
||||
>
|
||||
{statusInfo.action}
|
||||
</Button>
|
||||
@@ -249,17 +249,16 @@ const Plans = () => {
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-20">
|
||||
<Loader2 className="h-12 w-12 text-[#664fa3] mx-auto mb-4 animate-spin" />
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
<Loader2 className="h-12 w-12 text-brand-purple mx-auto mb-4 animate-spin" />
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading plans...</p>
|
||||
</div>
|
||||
) : plans.length > 0 ? (
|
||||
<div className={`grid gap-6 sm:gap-8 mx-auto ${
|
||||
plans.length === 1
|
||||
? 'grid-cols-1 max-w-md'
|
||||
: plans.length === 2
|
||||
<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) => {
|
||||
const minimumPrice = plan.minimum_price_cents || plan.price_cents || 3000;
|
||||
const suggestedPrice = plan.suggested_price_cents || minimumPrice;
|
||||
@@ -267,19 +266,19 @@ const Plans = () => {
|
||||
return (
|
||||
<Card
|
||||
key={plan.id}
|
||||
className="p-8 bg-white rounded-2xl border-2 border-[#ddd8eb] hover:border-[#664fa3] hover:shadow-xl transition-all"
|
||||
className="p-8 bg-background rounded-2xl border-2 border-[var(--neutral-800)] hover:border-brand-purple hover:shadow-xl transition-all"
|
||||
data-testid={`plan-card-${plan.id}`}
|
||||
>
|
||||
{/* Plan Header */}
|
||||
<div className="text-center mb-6">
|
||||
<div className="bg-[#DDD8EB]/20 p-4 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<CreditCard className="h-8 w-8 text-[#664fa3]" />
|
||||
<div className="bg-[var(--neutral-800)]/20 p-4 rounded-full w-16 h-16 mx-auto mb-4 flex items-center justify-center">
|
||||
<CreditCard className="h-8 w-8 text-brand-purple " />
|
||||
</div>
|
||||
<h2 className="text-2xl font-semibold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{plan.name}
|
||||
</h2>
|
||||
{plan.description && (
|
||||
<p className="text-sm text-[#664fa3] mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{plan.description}
|
||||
</p>
|
||||
)}
|
||||
@@ -287,22 +286,22 @@ const Plans = () => {
|
||||
|
||||
{/* Pricing */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="text-sm text-[#664fa3] mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple mb-1" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Starting at
|
||||
</div>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[#422268] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<div className="text-2xl sm:text-3xl md:text-4xl font-bold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
{formatPrice(minimumPrice)}
|
||||
</div>
|
||||
{suggestedPrice > minimumPrice && (
|
||||
<div className="text-sm text-[#664fa3] mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Suggested: {formatPrice(suggestedPrice)}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{getBillingCycleLabel(plan.billing_cycle)}
|
||||
</p>
|
||||
{plan.allow_donation && (
|
||||
<div className="mt-2 flex items-center justify-center gap-1 text-xs text-[#ff9e77]">
|
||||
<div className="mt-2 flex items-center justify-center gap-1 text-xs text-[var(--orange-light)]">
|
||||
<Heart className="h-3 w-3" />
|
||||
<span>Donations welcome</span>
|
||||
</div>
|
||||
@@ -312,20 +311,20 @@ const Plans = () => {
|
||||
{/* Features */}
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Access to all member events</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Access to all member events</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Community directory access</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Community directory access</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Exclusive member benefits</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Exclusive member benefits</span>
|
||||
</div>
|
||||
<div className="flex items-start gap-3">
|
||||
<CheckCircle className="h-5 w-5 text-[#81B29A] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[#422268]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Newsletter subscription</span>
|
||||
<CheckCircle className="h-5 w-5 text-[var(--green-light)] flex-shrink-0 mt-0.5" />
|
||||
<span className="text-sm text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Newsletter subscription</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -333,7 +332,7 @@ const Plans = () => {
|
||||
<Button
|
||||
onClick={() => handleSelectPlan(plan)}
|
||||
disabled={processingPlanId === plan.id || (statusInfo && !statusInfo.canSubscribe)}
|
||||
className="w-full bg-[#DDD8EB] text-[#422268] hover:bg-white rounded-full py-6 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
className="w-full bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background rounded-full py-6 text-lg font-semibold disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
data-testid={`subscribe-button-${plan.id}`}
|
||||
>
|
||||
{processingPlanId === plan.id ? (
|
||||
@@ -353,11 +352,11 @@ const Plans = () => {
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-20">
|
||||
<CreditCard className="h-20 w-20 text-[#ddd8eb] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[#422268] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<CreditCard className="h-20 w-20 text-[var(--neutral-800)] mx-auto mb-6" />
|
||||
<h3 className="text-2xl font-semibold text-[var(--purple-ink)] mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
No Plans Available
|
||||
</h3>
|
||||
<p className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Membership plans are not currently available. Please check back later!
|
||||
</p>
|
||||
</div>
|
||||
@@ -365,17 +364,17 @@ const Plans = () => {
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-16 max-w-3xl mx-auto">
|
||||
<Card className="p-8 bg-gradient-to-br from-[#DDD8EB]/20 to-[#f1eef9]/20 rounded-2xl border border-[#ddd8eb]">
|
||||
<h3 className="text-xl font-semibold text-[#422268] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<Card className="p-8 bg-gradient-to-br from-[var(--neutral-800)]/20 to-[var(--lavender-300)]/20 rounded-2xl border border-[var(--neutral-800)]">
|
||||
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-4 text-center" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Need Help Choosing?
|
||||
</h3>
|
||||
<p className="text-[#664fa3] text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-brand-purple text-center mb-4" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
If you have any questions about our membership plans or need assistance, please contact us.
|
||||
</p>
|
||||
<div className="text-center">
|
||||
<a
|
||||
href="mailto:support@loaf.org"
|
||||
className="text-[#ff9e77] hover:text-[#664fa3] font-medium"
|
||||
className="text-[var(--orange-light)] hover:text-brand-purple font-medium"
|
||||
>
|
||||
support@loaf.org
|
||||
</a>
|
||||
@@ -388,10 +387,10 @@ const Plans = () => {
|
||||
<Dialog open={amountDialogOpen} onOpenChange={setAmountDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-2xl text-[#422268]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
<DialogTitle className="text-2xl text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
|
||||
Choose Your Amount
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[#664fa3]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<DialogDescription className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
{selectedPlan?.name} - {getBillingCycleLabel(selectedPlan?.billing_cycle)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
@@ -399,11 +398,11 @@ const Plans = () => {
|
||||
<div className="space-y-6">
|
||||
{/* Amount Input */}
|
||||
<div>
|
||||
<Label htmlFor="amount" className="text-[#422268]">
|
||||
<Label htmlFor="amount" className="text-[var(--purple-ink)]">
|
||||
Amount (USD) *
|
||||
</Label>
|
||||
<div className="relative mt-2">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-[#664fa3] text-lg font-semibold">
|
||||
<span className="absolute left-4 top-1/2 transform -translate-y-1/2 text-brand-purple text-lg font-semibold">
|
||||
$
|
||||
</span>
|
||||
<Input
|
||||
@@ -413,25 +412,25 @@ const Plans = () => {
|
||||
min={selectedPlan ? (selectedPlan.minimum_price_cents / 100).toFixed(2) : "30.00"}
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
className="pl-8 h-14 text-xl border-2 border-[#ddd8eb] focus:border-[#664fa3]"
|
||||
className="pl-8 h-14 text-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple "
|
||||
placeholder="50.00"
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-[#664fa3] mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<p className="text-sm text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
Minimum: {selectedPlan ? formatPrice(selectedPlan.minimum_price_cents || 3000) : '$30.00'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Breakdown Display */}
|
||||
{breakdown && breakdown.total >= breakdown.base && (
|
||||
<Card className="p-4 bg-[#f9f5ff] border border-[#DDD8EB]">
|
||||
<Card className="p-4 bg-[var(--lavender-400)] border border-[var(--neutral-800)]">
|
||||
<div className="space-y-2 text-sm" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="flex justify-between text-[#422268]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)]">
|
||||
<span>Membership Fee:</span>
|
||||
<span className="font-semibold">{formatPrice(breakdown.base)}</span>
|
||||
</div>
|
||||
{breakdown.donation > 0 && (
|
||||
<div className="flex justify-between text-[#ff9e77]">
|
||||
<div className="flex justify-between text-[var(--orange-light)]">
|
||||
<span className="flex items-center gap-1">
|
||||
<Heart className="h-4 w-4" />
|
||||
Additional Donation:
|
||||
@@ -439,7 +438,7 @@ const Plans = () => {
|
||||
<span className="font-semibold">{formatPrice(breakdown.donation)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-between text-[#422268] font-bold text-base pt-2 border-t border-[#DDD8EB]">
|
||||
<div className="flex justify-between text-[var(--purple-ink)] font-bold text-base pt-2 border-t border-[var(--neutral-800)]">
|
||||
<span>Total:</span>
|
||||
<span>{formatPrice(breakdown.total)}</span>
|
||||
</div>
|
||||
@@ -449,8 +448,8 @@ const Plans = () => {
|
||||
|
||||
{/* Donation Message */}
|
||||
{selectedPlan?.allow_donation && (
|
||||
<div className="bg-[#DDD8EB]/20 rounded-lg p-4">
|
||||
<p className="text-sm text-[#422268] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<div className="bg-[var(--neutral-800)]/20 rounded-lg p-4">
|
||||
<p className="text-sm text-[var(--purple-ink)] text-center" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
|
||||
<strong>Thank you for supporting our community!</strong><br />
|
||||
Your donation helps us continue our mission and provide meaningful experiences for all members.
|
||||
</p>
|
||||
@@ -470,7 +469,7 @@ const Plans = () => {
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCheckout}
|
||||
className="flex-1 bg-[#DDD8EB] text-[#422268] hover:bg-white"
|
||||
className="flex-1 bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background"
|
||||
>
|
||||
Continue to Checkout
|
||||
</Button>
|
||||
|
||||
@@ -7,15 +7,15 @@ export default function PrivacyPolicy() {
|
||||
return (
|
||||
<>
|
||||
<PublicNavbar />
|
||||
<main className="bg-gradient-to-bl from-[#F9FAFB] to-[#DDD8EB] text-[#48286E]">
|
||||
<main className="bg-gradient-to-bl from-[var(--neutral-100:)] to-[var(--neutral-800)] text-[var(--purple-deep)]">
|
||||
<div className="mx-auto w-full max-w-5xl px-4 sm:px-6 lg:px-8 py-10">
|
||||
<header className="border-b pb-6">
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight" style={{fontFamily: 'Poppins'}}>
|
||||
<h1 className="text-3xl sm:text-4xl font-bold tracking-tight" style={{ fontFamily: 'Poppins' }}>
|
||||
LOAFers, Inc. Website Privacy Policy
|
||||
</h1>
|
||||
</header>
|
||||
|
||||
<div className="prose text-[#48286E] max-w-none prose-h2:mt-10 prose-h2:scroll-mt-24 prose-h3:mt-6">
|
||||
<div className="prose text-[var(--purple-deep)] max-w-none prose-h2:mt-10 prose-h2:scroll-mt-24 prose-h3:mt-6">
|
||||
<section className="mt-8">
|
||||
<p>
|
||||
This Privacy Policy ("Policy") applies to Membership Applications, and LOAFers, Inc. ("Company") and
|
||||
@@ -31,7 +31,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="user-data" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">What User Data We Collect</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">What User Data We Collect</h2>
|
||||
<p>When you visit the Site, we may collect the following data:</p>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>Your IP address.</li>
|
||||
@@ -63,7 +63,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="why-collect" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Why We Collect Your Data</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Why We Collect Your Data</h2>
|
||||
<ul className="list-disc pl-6 space-y-1">
|
||||
<li>
|
||||
To send you announcement emails containing the information about our events and information we think you
|
||||
@@ -75,7 +75,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="third-parties" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Sharing Information with Third Parties</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Sharing Information with Third Parties</h2>
|
||||
<p>The Company does not sell, rent, or lease personal data to third parties.</p>
|
||||
<p>
|
||||
The Company may share data with trusted partners to help perform statistical analysis, provide customer
|
||||
@@ -89,7 +89,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="safeguarding" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Safeguarding and Securing the Data</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Safeguarding and Securing the Data</h2>
|
||||
<p>
|
||||
LOAFers, Inc. is committed to securing your data and keeping it confidential. LOAFers, Inc. has done all
|
||||
in its power to prevent data theft, unauthorized access, and disclosure by implementing the latest
|
||||
@@ -98,7 +98,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="cookies" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Our Cookie Policy</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Our Cookie Policy</h2>
|
||||
<p>
|
||||
Once you agree to allow our blog to use cookies, you also agree to use the data it collects regarding your
|
||||
online behavior (analyze web traffic, web pages you visit and spend the most time on).
|
||||
@@ -123,7 +123,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="other-sites" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Links to Other Websites</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Links to Other Websites</h2>
|
||||
<p>
|
||||
Our blog contains links that lead to other websites. If you click on these links LOAFers, Inc. is not held
|
||||
responsible for your data and privacy protection. Visiting those websites is not governed by this privacy
|
||||
@@ -133,7 +133,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="restricting" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">
|
||||
Restricting the Collection of your Personal Data
|
||||
</h2>
|
||||
<p>
|
||||
@@ -150,12 +150,12 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="children" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Children Under Thirteen</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Children Under Thirteen</h2>
|
||||
<p>The Company does not knowingly collect information from children under the age of 13.</p>
|
||||
</section>
|
||||
|
||||
<section id="changes" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Changes to this Statement</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Changes to this Statement</h2>
|
||||
<p>
|
||||
The Company may make changes to this Policy. When this occurs the effective date of this policy will be
|
||||
updated.
|
||||
@@ -163,7 +163,7 @@ export default function PrivacyPolicy() {
|
||||
</section>
|
||||
|
||||
<section id="contact" className="scroll-mt-24">
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[#48286E]">Contact Information</h2>
|
||||
<h2 className="text-xl sm:text-2xl font-bold text-[var(--purple-deep)]">Contact Information</h2>
|
||||
<p>If you have any question, please contact LOAFers, Inc. at:</p>
|
||||
<div className="not-prose mt-4">
|
||||
<p className="font-semibold mb-2">LOAFers, Inc.</p>
|
||||
@@ -184,7 +184,7 @@ export default function PrivacyPolicy() {
|
||||
<div className="mt-8 text-center">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-[#664fa3] hover:text-[#422268] font-semibold transition-colors inline-flex items-center gap-2"
|
||||
className="text-[var(--purple-lavender)] hover:text-[var(--purple-ink)] font-semibold transition-colors inline-flex items-center gap-2"
|
||||
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
|
||||
>
|
||||
<span>←</span> Back to Home
|
||||
|
||||
1065
src/pages/Profile.js
1065
src/pages/Profile.js
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user