21 Commits

Author SHA1 Message Date
kayela
7eef62560e feat: staff can edit registration responses 2026-01-29 21:49:25 -06:00
kayela
f70a133e18 feat: tabs layout for edit profile 2026-01-29 20:49:13 -06:00
kayela
d5152609b6 Donation status badge upates, admin validation tootips 2026-01-29 19:37:41 -06:00
kayela
de719d9d69 refactor subscriptionsTable.jsx 2026-01-29 18:46:16 -06:00
kayela
27d5c48805 Componentized subscription table 2026-01-29 18:36:13 -06:00
kayela
64d631d890 Members -removed un used selection options. Profile added back button 2026-01-29 18:12:48 -06:00
kayela
a77fbc47e3 styling improvements 2026-01-28 19:08:13 -06:00
kayela
d638afcdb2 add column for email expiry date
Members > Invite member says invite Staff in dialog
 resend email button
 Update form member form to say member and not staff
 review application function
 manual payment functionality
 basic implementation of theme
 actions dropdown
2026-01-28 18:59:19 -06:00
kayela
a247ac5219 feat: added theme success and warning colors
fix: invite member dialog box
feat: email expiry date column
feat: resend email verification button
fix: select item text can be seen
2026-01-28 15:03:46 -06:00
kayela
01722edad9 updated badge glitch 2026-01-27 17:30:50 -06:00
kayela
378b909398 removed transaction history from Profile.js 2026-01-27 16:38:21 -06:00
kayela
4ad1997bd5 Member tiers implementation intact. Icons updated to be Lucide React. Create/edit member tiers. Display member badge. Transaction history now in My profile dashboard. Adjusted Icons for badges. Added badges on my profile page 2026-01-27 16:30:26 -06:00
kayela
0d7e3a1286 tweaked statcard for better styling when digits are greater than 2 2026-01-27 15:19:19 -06:00
kayela
0c3d4a4edd Updated stat cards to be consistent with rest of codebase 2026-01-27 15:11:25 -06:00
kayela
97aa7860a9 feat: integrate TransactionHistory component into Dashboard and update styles for better UI consistency 2026-01-27 14:33:36 -06:00
Koncept Kit
467f34b42a - - New ThemeConfigContext provider that fetches theme on app load and applies it to the DOM (title, meta description, favicon, CSS variables,
theme-color)/- - Admin Theme settings page under Settings > Theme tab/- All logo references (5 components) now pull from the theme config with fallback to default
2026-01-27 21:32:22 +07:00
Koncept Kit
85070cf77b Update Footer to get current year 2026-01-27 16:44:56 +07:00
9dcb8e3185 Merge pull request 'theme-provider' (#20) from theme-provider into dev
Reviewed-on: #20
2026-01-27 08:41:49 +00:00
kayela
a88388ed5d Merge branch 'dev' into theme-provider 2026-01-27 00:53:27 -06:00
Koncept Kit
002ef5c897 Fixes 2026-01-24 23:56:15 +07:00
7d0c207f1b Merge pull request 'theme-provider' (#18) from theme-provider into dev
Reviewed-on: #18
2026-01-21 04:58:57 +00:00
47 changed files with 5263 additions and 1800 deletions

75
.dockerignore Normal file
View 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/

49
Dockerfile Normal file
View 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;"]

44
nginx.conf Normal file
View 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;
}
}

View File

@@ -25,6 +25,7 @@ import AdminPermissions from './pages/admin/AdminPermissions';
import AdminSettings from './pages/admin/AdminSettings'; import AdminSettings from './pages/admin/AdminSettings';
import AdminMemberTiers from './pages/admin/AdminMemberTiers'; import AdminMemberTiers from './pages/admin/AdminMemberTiers';
import AdminRoles from './pages/admin/AdminRoles'; import AdminRoles from './pages/admin/AdminRoles';
import AdminTheme from './pages/admin/AdminTheme';
import AdminEvents from './pages/admin/AdminEvents'; import AdminEvents from './pages/admin/AdminEvents';
import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminEventAttendance from './pages/admin/AdminEventAttendance';
import AdminValidations from './pages/admin/AdminValidations'; import AdminValidations from './pages/admin/AdminValidations';
@@ -302,6 +303,7 @@ function App() {
<Route path="stripe" element={<AdminSettings />} /> <Route path="stripe" element={<AdminSettings />} />
<Route path="permissions" element={<AdminRoles />} /> <Route path="permissions" element={<AdminRoles />} />
<Route path="member-tiers" element={<AdminMemberTiers />} /> <Route path="member-tiers" element={<AdminMemberTiers />} />
<Route path="theme" element={<AdminTheme />} />
</Route> </Route>
{/* 404 - Catch all undefined routes */} {/* 404 - Catch all undefined routes */}

View File

@@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom'; import { Link, useLocation, useNavigate } from 'react-router-dom';
import { useTheme } from 'next-themes'; import { useTheme } from 'next-themes';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import api from '../utils/api'; import api from '../utils/api';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { import {
@@ -32,6 +33,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const { theme, setTheme } = useTheme(); const { theme, setTheme } = useTheme();
const [pendingCount, setPendingCount] = useState(0); const [pendingCount, setPendingCount] = useState(0);
const [storageUsed, setStorageUsed] = useState(0); const [storageUsed, setStorageUsed] = useState(0);
@@ -281,7 +283,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
<div className="flex items-center justify-between p-4 border-b border-[var(--neutral-800)]"> <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"> <Link to="/" className="flex items-center gap-3 group flex-1 min-w-0">
<img <img
src={`${process.env.PUBLIC_URL}/loaf-logo.png`} src={getLogoUrl()}
alt="LOAF Logo" alt="LOAF Logo"
className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8' className={`object-contain transition-all duration-200 ${isOpen ? 'h-10 w-10' : 'h-8 w-8'
}`} }`}

View 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;

View 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;

View File

@@ -2,15 +2,15 @@
import React from 'react'; import React from 'react';
import { Badge } from './ui/badge'; import { Badge } from './ui/badge';
import { getTierForMember } from '../utils/member-tiers'; import { getTierForMember } from '../utils/member-tiers';
import { MEMBER_TIER_ICONS } from '../config/memberTierIcons'; import { getTierIcon } from '../config/memberTierIcons';
const MemberBadge = ({ memberSince, tiers }) => { const MemberBadge = ({ memberSince, tiers }) => {
const tier = getTierForMember(memberSince, tiers); const tier = getTierForMember(memberSince, tiers);
const Icon = MEMBER_TIER_ICONS[tier.icon] || MEMBER_TIER_ICONS.FaUser; const Icon = getTierIcon(tier.iconKey);
return ( return (
<Badge className={`px-3 py-1 rounded-md text-sm flex items-center gap-2 ${tier.badgeClass}`}> <Badge className={`px-3 py-2 rounded-md text-sm flex items-center gap-2 border hover:text-white ${tier.badgeClass}`}>
<Icon className="h-4 w-4" /> <Icon className="size-6" />
{tier.label} {tier.label}
</Badge> </Badge>
); );

View File

@@ -2,6 +2,7 @@ import React from 'react'
import { Card } from './ui/card'; import { Card } from './ui/card';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react'; import { Heart, Calendar, Mail, Phone, MapPin, Facebook, Instagram, Twitter, Linkedin, UserCircle } from 'lucide-react';
import MemberBadge from './MemberBadge';
// Helper function to get initials // Helper function to get initials
const getInitials = (firstName, lastName) => { const getInitials = (firstName, lastName) => {
@@ -17,13 +18,13 @@ const getSocialMediaLink = (url) => {
return url; return url;
}; };
const MemberCard = ({ member, onViewProfile }) => { const MemberCard = ({ member, onViewProfile, tiers }) => {
const joinedDate = member.created_at; const memberSince = member.member_since || member.created_at;
return ( return (
<Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full"> <Card className="p-6 bg-background rounded-3xl border border-[var(--neutral-800)] hover:shadow-lg transition-all h-full">
{/* Profile Photo */} {/* Member Tier Badge */}
<div className='flex justify-end items-center'> <div className='flex justify-end items-center mb-2'>
member since badge <MemberBadge memberSince={memberSince} tiers={tiers} />
</div> </div>
<div className="flex justify-center mb-4"> <div className="flex justify-center mb-4">
{member.profile_photo_url ? ( {member.profile_photo_url ? (
@@ -64,11 +65,11 @@ const MemberCard = ({ member, onViewProfile }) => {
)} )}
{/* Member Since */} {/* Member Since */}
{joinedDate && ( {memberSince && (
<div className="flex items-center justify-center gap-2 mb-4"> <div className="flex items-center justify-center gap-2 mb-4">
<Calendar className="h-4 w-4 text-brand-purple " /> <Calendar className="h-4 w-4 text-brand-purple " />
<span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Member since {new Date(joinedDate).toLocaleDateString('en-US', { Member since {new Date(memberSince).toLocaleDateString('en-US', {
month: 'long', month: 'long',
year: 'numeric' year: 'numeric'
})} })}

View File

@@ -111,7 +111,7 @@ const MemberFooter = () => {
<a href="/membership/terms-of-service" className="hover:text-white transition-colors">Terms of Service</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> <a href="/membership/privacy-policy" className="hover:text-white transition-colors">Privacy Policy</a>
</div> </div>
<p>© 2025 LOAF. All rights reserved.</p> <p>© {new Date().getFullYear()} LOAF. All rights reserved.</p>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,6 +1,7 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
@@ -12,11 +13,12 @@ import {
const Navbar = () => { const Navbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleLogout = () => { const handleLogout = () => {
logout(); logout();

View File

@@ -1,9 +1,11 @@
import React from 'react'; import React from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useThemeConfig } from '../context/ThemeConfigContext';
const PublicFooter = () => { const PublicFooter = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<> <>
@@ -60,7 +62,7 @@ const PublicFooter = () => {
</Link> </Link>
</nav> </nav>
<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" }}> <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" }}>
© 2025 LOAF. All Rights Reserved. © {new Date().getFullYear()} LOAF. All Rights Reserved.
</p> </p>
<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" }}> <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{' '} Designed and Managed by{' '}

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { Link, useNavigate, useLocation } from 'react-router-dom'; import { Link, useNavigate, useLocation } from 'react-router-dom';
import { Button } from './ui/button'; import { Button } from './ui/button';
import { useAuth } from '../context/AuthContext'; import { useAuth } from '../context/AuthContext';
import { useThemeConfig } from '../context/ThemeConfigContext';
import { ChevronDown, Menu, X } from 'lucide-react'; import { ChevronDown, Menu, X } from 'lucide-react';
import { import {
DropdownMenu, DropdownMenu,
@@ -12,6 +13,7 @@ import {
const PublicNavbar = () => { const PublicNavbar = () => {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const { getLogoUrl } = useThemeConfig();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
@@ -30,8 +32,8 @@ const PublicNavbar = () => {
return location.pathname.startsWith('/about'); return location.pathname.startsWith('/about');
}; };
// LOAF logo (local) // Get logo URL from theme config (with fallback to default)
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const loafLogo = getLogoUrl();
const handleAuthAction = () => { const handleAuthAction = () => {
if (user) { if (user) {

View File

@@ -1,46 +1,45 @@
import React from 'react'; import React from 'react';
import { NavLink } from 'react-router-dom'; import { NavLink, useLocation } from 'react-router-dom';
import { CreditCard, Shield, Settings, Star } from 'lucide-react'; import { CreditCard, Shield, Star, Palette } from 'lucide-react';
const settingsItems = [ const settingsItems = [
{ label: 'Stripe Integration', path: '/admin/settings/stripe', icon: CreditCard }, { label: 'Stripe', path: '/admin/settings/stripe', icon: CreditCard },
{ label: 'Permissions', path: '/admin/settings/permissions', icon: Shield }, { label: 'Permissions', path: '/admin/settings/permissions', icon: Shield },
{ label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star }, { label: 'Member Tiers', path: '/admin/settings/member-tiers', icon: Star },
{ label: 'Theme', path: '/admin/settings/theme', icon: Palette },
]; ];
const SettingsSidebar = () => { const SettingsTabs = () => {
const location = useLocation();
return ( return (
<aside className="w-full lg:w-64 shrink-0 bg-background border border-[var(--neutral-800)] rounded-2xl p-4 lg:sticky lg:top-8 h-max"> <div className="w-full border-b border-border">
<div className="flex items-center gap-2 px-2 pb-3 border-b border-[var(--neutral-800)]"> <nav className="flex gap-1 overflow-x-auto pb-px -mb-px" aria-label="Settings tabs">
<Settings className="h-4 w-4 text-[var(--purple-ink)]" />
<span className="text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Settings
</span>
</div>
<nav className="mt-3 space-y-1">
{settingsItems.map((item) => { {settingsItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path;
return ( return (
<NavLink <NavLink
key={item.label} key={item.label}
to={item.path} to={item.path}
className={({ isActive }) => className={`
[ flex items-center gap-2 px-4 py-3 text-sm font-medium whitespace-nowrap
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors', border-b-2 transition-all duration-200
isActive ${isActive
? 'bg-[var(--orange-light)]/10 text-[var(--purple-ink)]' ? 'border-primary text-primary bg-primary/5'
: 'text-[var(--purple-ink)] hover:bg-[var(--neutral-800)]/10', : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
].join(' ') }
} `}
> >
<Icon className="h-4 w-4" /> <Icon className={`h-4 w-4 ${isActive ? 'text-primary' : ''}`} />
<span>{item.label}</span> <span>{item.label}</span>
</NavLink> </NavLink>
); );
})} })}
</nav> </nav>
</aside> </div>
); );
}; };
export default SettingsSidebar; export default SettingsTabs;

View File

@@ -12,27 +12,10 @@ export const StatCard = ({
const digitCount = const digitCount =
valueString.replace(/\D/g, "").length || valueString.length; valueString.replace(/\D/g, "").length || valueString.length;
/**
*
const getValueFontSize = () => {
if (digitCount <= 3) {
// 3.75rem for 3 or fewer digits
return "3.75rem";
} else if (digitCount <= 6) {
// Scale down for more digits
return "clamp(2rem, 5cqi, 3rem)";
} else if (digitCount <= 9) {
return "clamp(1.5rem, 4cqi, 2.5rem)";
} else {
return "clamp(1.25rem, 3cqi, 2rem)";
}
};
* */
const getValueFontSize = () => { const getValueFontSize = () => {
switch (true) { switch (true) {
case digitCount <= 3: case digitCount <= 2:
// 3.75rem for 3 or fewer digits // 3.75rem for 3 or fewer digits
return "3.75rem"; return "3.75rem";
case digitCount <= 6: case digitCount <= 6:
@@ -68,8 +51,8 @@ const getValueFontSize = () => {
</p> </p>
</div> </div>
<div className={`${iconBgClass} p-3 rounded-lg `}> <div className={`${iconBgClass} px-3 py-2 rounded-lg `}>
<Icon className="size-8" /> <Icon className="size-[valueFontSize]" />
</div> </div>
</div> </div>
<p <p

View File

@@ -6,7 +6,6 @@ const STATUS_BADGE_CONFIG = {
//status-based badges //status-based badges
pending_email: { label: 'Pending Email', variant: 'orange2' }, pending_email: { label: 'Pending Email', variant: 'orange2' },
pending_validation: { label: 'Pending Validation', variant: 'gray' }, pending_validation: { label: 'Pending Validation', variant: 'gray' },
pre_validated: { label: 'Pre-Validated', variant: 'green' },
payment_pending: { label: 'Payment Pending', variant: 'orange' }, payment_pending: { label: 'Payment Pending', variant: 'orange' },
active: { label: 'Active', variant: 'green' }, active: { label: 'Active', variant: 'green' },
inactive: { label: 'Inactive', variant: 'gray2' }, inactive: { label: 'Inactive', variant: 'gray2' },
@@ -23,7 +22,12 @@ const STATUS_BADGE_CONFIG = {
admin: { label: 'Admin', variant: 'purple' }, admin: { label: 'Admin', variant: 'purple' },
moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' }, moderator: { label: 'Moderator', variant: 'bg-[var(--neutral-800)] text-[var(--purple-ink)]' },
staff: { label: 'Staff', variant: 'gray' }, staff: { label: 'Staff', variant: 'gray' },
media: { label: 'Media', variant: 'gray2' } 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 //todo: make shield icon dynamic based on status

View 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;

View 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;

View 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;

View File

@@ -9,7 +9,7 @@ const badgeVariants = cva(
variants: { variants: {
variant: { variant: {
default: default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80", "border-transparent bg-primary text-primary-foreground shadow ",
secondary: secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80", "border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive: destructive:

View File

@@ -83,16 +83,16 @@ const SelectItem = React.forwardRef(({ className, children, ...props }, ref) =>
<SelectPrimitive.Item <SelectPrimitive.Item
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props}> {...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> <SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" /> <Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator> </SelectPrimitive.ItemIndicator>
</span> </span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> <SelectPrimitive.ItemText className="">{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item> </SelectPrimitive.Item>
)) ))
SelectItem.displayName = SelectPrimitive.Item.displayName SelectItem.displayName = SelectPrimitive.Item.displayName

View File

@@ -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) => ( const Table = React.forwardRef(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto"> <div className="relative w-full overflow-auto">
<table <table ref={ref} className={cn("w-full", className)} {...props} />
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div> </div>
)) ));
Table.displayName = "Table" Table.displayName = "Table";
const TableHeader = React.forwardRef(({ className, ...props }, ref) => ( const TableHeader = React.forwardRef(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} /> <thead
)) ref={ref}
TableHeader.displayName = "TableHeader" className={cn(
"bg-[var(--lavender-300)] border-b border-[var(--neutral-800)]",
className,
)}
{...props}
/>
));
TableHeader.displayName = "TableHeader";
const TableBody = React.forwardRef(({ className, ...props }, ref) => ( const TableBody = React.forwardRef(({ className, ...props }, ref) => (
<tbody <tbody
ref={ref} ref={ref}
className={cn("[&_tr:last-child]:border-0", className)} className={cn("[&_tr:last-child]:border-0", className)}
{...props} /> {...props}
)) />
TableBody.displayName = "TableBody" ));
TableBody.displayName = "TableBody";
const TableFooter = React.forwardRef(({ className, ...props }, ref) => ( const TableFooter = React.forwardRef(({ className, ...props }, ref) => (
<tfoot <tfoot
ref={ref} ref={ref}
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)} className={cn(
{...props} /> "border-t border-[var(--neutral-800)] font-medium [&>tr]:last:border-b-0",
)) className,
TableFooter.displayName = "TableFooter" )}
{...props}
/>
));
TableFooter.displayName = "TableFooter";
const TableRow = React.forwardRef(({ className, ...props }, ref) => ( const TableRow = React.forwardRef(({ className, ...props }, ref) => (
<tr <tr
ref={ref} ref={ref}
className={cn( className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted", "border-b border-[var(--neutral-800)] transition-colors hover:bg-[var(--lavender-400)]",
className className,
)} )}
{...props} /> {...props}
)) />
TableRow.displayName = "TableRow" ));
TableRow.displayName = "TableRow";
const TableHead = React.forwardRef(({ className, ...props }, ref) => ( const TableHead = React.forwardRef(({ className, ...props }, ref) => (
<th <th
ref={ref} ref={ref}
className={cn( 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]", "p-4 text-left align-middle font-semibold text-[var(--purple-ink)] [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
className className,
)} )}
{...props} /> {...props}
)) />
TableHead.displayName = "TableHead" ));
TableHead.displayName = "TableHead";
const TableCell = React.forwardRef(({ className, ...props }, ref) => ( const TableCell = React.forwardRef(({ className, ...props }, ref) => (
<td <td
ref={ref} ref={ref}
className={cn( className={cn(
"p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]", "p-4 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px] ",
className className,
)} )}
{...props} /> {...props}
)) />
TableCell.displayName = "TableCell" ));
TableCell.displayName = "TableCell";
const TableCaption = React.forwardRef(({ className, ...props }, ref) => ( const TableCaption = React.forwardRef(({ className, ...props }, ref) => (
<caption <caption
ref={ref} ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)} className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} /> {...props}
)) />
TableCaption.displayName = "TableCaption" ));
TableCaption.displayName = "TableCaption";
export { export {
Table, Table,
@@ -83,4 +96,4 @@ export {
TableRow, TableRow,
TableCell, TableCell,
TableCaption, TableCaption,
} };

View File

@@ -1,27 +1,70 @@
// src/config/memberTiers.js // 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 = [ export const DEFAULT_MEMBER_TIERS = [
{ {
id: 'new', id: 'new_member',
label: 'New Member', label: 'New Member',
minDays: 0, minYears: 0,
maxDays: 364, // < 1 year maxYears: 0.999,
icon: 'FaSeedling', iconKey: 'sparkle',
badgeClass: 'bg-[var(--lavender-300)] text-[var(--purple-ink)] hover:text-white', badgeClass: 'bg-blue-100 text-blue-800 border-blue-200',
}, },
{ {
id: 'silver', id: 'member_1_year',
label: 'Silver Member', label: '1 Year Member',
minDays: 365, minYears: 1,
maxDays: 729, maxYears: 2.999,
icon: 'FaMedal', iconKey: 'star',
badgeClass: 'bg-slate-200 text-slate-900 hover:text-white', badgeClass: 'bg-green-100 text-green-800 border-green-200',
}, },
{ {
id: 'gold', id: 'member_3_year',
label: 'Gold Member', label: '3+ Year Member',
minDays: 730, minYears: 3,
maxDays: null, // open-ended maxYears: 4.999,
icon: 'FaCrown', iconKey: 'award',
badgeClass: 'bg-amber-200 text-amber-900 hover:text-white', 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' },
];

View File

@@ -1,9 +1,29 @@
// src/config/memberTierIcons.js // src/config/memberTierIcons.js
import { FaSeedling, FaMedal, FaCrown, FaUser } from 'react-icons/fa'; 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 = { export const MEMBER_TIER_ICONS = {
FaSeedling, // Primary tier icons
FaMedal, sparkle: Sparkles,
FaCrown, sparkles: Sparkles,
FaUser, 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;
}; };

View 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;

View 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;

View File

@@ -1,6 +1,7 @@
import React from 'react'; import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { ThemeProvider } from 'next-themes'; import { ThemeProvider } from 'next-themes';
import { ThemeConfigProvider } from './context/ThemeConfigContext';
import '@fontsource/fraunces/600.css'; import '@fontsource/fraunces/600.css';
import '@fontsource/dm-sans/400.css'; import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/700.css'; import '@fontsource/dm-sans/700.css';
@@ -16,7 +17,9 @@ root.render(
enableSystem={false} enableSystem={false}
storageKey="admin-theme" storageKey="admin-theme"
> >
<App /> <ThemeConfigProvider>
<App />
</ThemeConfigProvider>
</ThemeProvider> </ThemeProvider>
</React.StrictMode> </React.StrictMode>
); );

View File

@@ -1,12 +1,27 @@
import React from 'react'; import React from 'react';
import { Outlet } from 'react-router-dom'; import { Outlet } from 'react-router-dom';
import SettingsSidebar from '../components/SettingsSidebar'; import SettingsTabs from '../components/SettingsSidebar';
import { Settings } from 'lucide-react';
const SettingsLayout = () => { const SettingsLayout = () => {
return ( return (
<div className="flex flex-col gap-6 lg:flex-row"> <div className="space-y-6">
<SettingsSidebar /> {/* Header */}
<div className="flex-1 min-w-0"> <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 /> <Outlet />
</div> </div>
</div> </div>

View File

@@ -9,6 +9,9 @@ import Navbar from '../components/Navbar';
import MemberFooter from '../components/MemberFooter'; import MemberFooter from '../components/MemberFooter';
import { Calendar, User, CheckCircle, Clock, AlertCircle, Mail, Users, Image, FileText, DollarSign, Scale, Receipt, Heart, CreditCard } 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 { toast } from 'sonner';
import TransactionHistory from '../components/TransactionHistory';
import MemberBadge from '@/components/MemberBadge';
import useMemberTiers from '../hooks/use-member-tiers'
const Dashboard = () => { const Dashboard = () => {
const { user, resendVerificationEmail, refreshUser } = useAuth(); const { user, resendVerificationEmail, refreshUser } = useAuth();
@@ -17,12 +20,16 @@ const Dashboard = () => {
const [resendLoading, setResendLoading] = useState(false); const [resendLoading, setResendLoading] = useState(false);
const [eventActivity, setEventActivity] = useState(null); const [eventActivity, setEventActivity] = useState(null);
const [activityLoading, setActivityLoading] = useState(true); const [activityLoading, setActivityLoading] = useState(true);
const [transactionsLoading, setTransactionsLoading] = useState(true);
const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [activeTransactionTab, setActiveTransactionTab] = useState('all'); const [activeTransactionTab, setActiveTransactionTab] = useState('all');
const joinedDate = user?.member_since || user?.created_at; const joinedDate = user?.member_since || user?.created_at;
const { tiers, loading: tiersLoading } = useMemberTiers();
useEffect(() => { useEffect(() => {
fetchUpcomingEvents(); fetchUpcomingEvents();
fetchEventActivity(); fetchEventActivity();
fetchTransactions();
}, []); }, []);
const fetchUpcomingEvents = async () => { const fetchUpcomingEvents = async () => {
@@ -48,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 () => { const handleResendVerification = async () => {
setResendLoading(true); setResendLoading(true);
try { try {
@@ -72,6 +92,7 @@ const Dashboard = () => {
} }
}; };
const getStatusBadge = (status) => { const getStatusBadge = (status) => {
const statusConfig = { const statusConfig = {
pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' }, pending_email: { icon: Clock, label: 'Pending Email', className: 'bg-orange-100 text-orange-700' },
@@ -112,6 +133,8 @@ const Dashboard = () => {
return messages[status] || ''; return messages[status] || '';
}; };
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<Navbar /> <Navbar />
@@ -183,67 +206,68 @@ const Dashboard = () => {
{/* Grid Layout */} {/* Grid Layout */}
<div className="grid lg:grid-cols-2 gap-8"> <div className="grid lg:grid-cols-2 gap-8">
{/* Quick Stats */} {/* Quick Stats */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" data-testid="quick-stats-card"> <div className='space-y-8'>
<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">
<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>
<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>
<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>
{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>
<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_start_date && user?.subscription_end_date && ( <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" }}>
<div className="pt-4 border-t border-[var(--neutral-800)]"> Quick Info
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Membership Period</p> </h3>
<p className="text-[var(--purple-ink)] font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <div className="space-y-4">
{new Date(user.subscription_start_date).toLocaleDateString()} - {new Date(user.subscription_end_date).toLocaleDateString()} {/* member date and badge */}
</p> <div className='flex justify-between'>
</div>
<div> <div>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Days Remaining</p> <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" }}> <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 {joinedDate ? new Date(joinedDate).toLocaleDateString() : 'N/A'}
</p> </p>
</div> </div>
</> {!tiersLoading && (
)} <div className='lg:mr-10'>
</div> <MemberBadge memberSince={joinedDate} tiers={tiers} />
</Card> </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 */} {/* Upcoming Events */}
<Card className="lg:col-span-1 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]" 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">
@@ -337,127 +361,16 @@ const Dashboard = () => {
)} )}
{/* Transaction History Section */} {/* Transaction History Section */}
<Card className="mt-12 p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> <div className="mt-8">
{/* Header */} <TransactionHistory
<div className="flex items-center gap-3 mb-6"> subscriptions={transactions.subscriptions}
<div className="p-2 bg-[var(--green-light)] rounded-lg"> donations={transactions.donations}
<Receipt className="h-5 w-5 text-white" /> totalSubscriptionCents={transactions.total_subscription_amount_cents}
</div> totalDonationCents={transactions.total_donation_amount_cents}
<h2 className="text-2xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> loading={transactionsLoading}
Transaction History isAdmin={false}
</h2> />
</div> </div>
{/* Stats Row */}
<div className="grid grid-cols-2 gap-4 mb-6">
<div className="p-4 bg-[var(--lavender-300)]/30 rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 mb-2">
<CreditCard className="h-4 w-4 text-[var(--green-light)]" />
<span className="text-sm text-[var(--green-light)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Subscriptions
</span>
</div>
<p className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
$30.00
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
1 payment(s)
</p>
</div>
<div className="p-4 bg-[var(--lavender-300)]/30 rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-center gap-2 mb-2">
<Heart className="h-4 w-4 text-[var(--coral)]" />
<span className="text-sm text-[var(--coral)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Total Donations
</span>
</div>
<p className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
$0.00
</p>
<p className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
0 donation(s)
</p>
</div>
</div>
{/* Filter Tabs */}
<div className="flex gap-2 mb-6">
<button
onClick={() => setActiveTransactionTab('all')}
className={`px-6 py-2 rounded-full font-medium transition-all ${
activeTransactionTab === 'all'
? 'bg-[var(--purple-lavender)] text-white'
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
All (1)
</button>
<button
onClick={() => setActiveTransactionTab('subscriptions')}
className={`px-6 py-2 rounded-full font-medium transition-all ${
activeTransactionTab === 'subscriptions'
? 'bg-[var(--purple-lavender)] text-white'
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Subscriptions (1)
</button>
<button
onClick={() => setActiveTransactionTab('donations')}
className={`px-6 py-2 rounded-full font-medium transition-all ${
activeTransactionTab === 'donations'
? 'bg-[var(--purple-lavender)] text-white'
: 'border-2 border-[var(--purple-lavender)] text-[var(--purple-lavender)] bg-transparent hover:bg-[var(--lavender-300)]/30'
}`}
style={{ fontFamily: "'Nunito Sans', sans-serif" }}
>
Donations (0)
</button>
</div>
{/* Transaction List */}
<div className="space-y-4">
{(activeTransactionTab === 'all' || activeTransactionTab === 'subscriptions') && (
<div className="flex items-center justify-between p-4 border border-[var(--neutral-800)] rounded-xl">
<div className="flex items-center gap-4">
<div className="w-12 h-12 bg-[var(--purple-lavender)] rounded-lg"></div>
<div>
<div className="flex items-center gap-2 mb-1">
<span className="font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Annual Membership
</span>
<Badge className="bg-[var(--green-light)] text-white text-xs px-2 py-0.5 rounded">
active
</Badge>
</div>
<div className="flex items-center gap-2 text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
<Calendar className="h-3.5 w-3.5" />
<span>Dec 16, 2025</span>
<span></span>
<span>Custom</span>
</div>
<span className="text-sm text-[var(--coral)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Manual Payment
</span>
</div>
</div>
<span className="text-xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
$30.00
</span>
</div>
)}
{activeTransactionTab === 'donations' && (
<div className="text-center py-8">
<Heart className="h-12 w-12 text-[var(--neutral-800)] mx-auto mb-3" />
<p className="text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
No donations yet
</p>
</div>
)}
</div>
</Card>
</div> </div>
<MemberFooter /> <MemberFooter />

View File

@@ -2,9 +2,11 @@ import React from 'react';
import PublicNavbar from '../components/PublicNavbar'; import PublicNavbar from '../components/PublicNavbar';
import PublicFooter from '../components/PublicFooter'; import PublicFooter from '../components/PublicFooter';
import { Card } from '../components/ui/card'; import { Card } from '../components/ui/card';
import { useThemeConfig } from '../context/ThemeConfigContext';
const MissionValues = () => { const MissionValues = () => {
const loafLogo = `${process.env.PUBLIC_URL}/loaf-logo.png`; const { getLogoUrl } = useThemeConfig();
const loafLogo = getLogoUrl();
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ import { useAuth } from '../../context/AuthContext';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import StatusBadge from '@/components/StatusBadge';
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -17,6 +18,14 @@ import {
DropdownMenuTrigger, DropdownMenuTrigger,
} from '../../components/ui/dropdown-menu'; } from '../../components/ui/dropdown-menu';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '../../components/ui/table';
import api from '../../utils/api'; import api from '../../utils/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
@@ -184,15 +193,8 @@ const AdminDonations = () => {
toast.error('Failed to copy to clipboard'); toast.error('Failed to copy to clipboard');
} }
}; };
/*
const getStatusBadgeVariant = (status) => { */
const variants = {
completed: 'default',
pending: 'secondary',
failed: 'destructive'
};
return variants[status] || 'outline';
};
const getTypeBadgeColor = (type) => { const getTypeBadgeColor = (type) => {
return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple '; return type === 'member' ? 'bg-[var(--green-light)]' : 'bg-brand-purple ';
@@ -392,51 +394,37 @@ const AdminDonations = () => {
{/* Donations Table */} {/* Donations Table */}
<Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden"> <Card className="bg-background rounded-2xl border-2 border-[var(--neutral-800)] overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <Table>
<thead className="bg-[var(--lavender-300)] border-b-2 border-[var(--neutral-800)]"> <TableHeader>
<tr> <TableRow>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead>Donor</TableHead>
Donor <TableHead>Type</TableHead>
</th> <TableHead>Amount</TableHead>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead>Status</TableHead>
Type <TableHead>Date</TableHead>
</th> <TableHead>Payment Method</TableHead>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableHead className="text-center">Details</TableHead>
Amount </TableRow>
</th> </TableHeader>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <TableBody>
Status
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Date
</th>
<th className="px-6 py-4 text-left text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Payment Method
</th>
<th className="px-6 py-4 text-center text-sm font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
Details
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--neutral-800)]">
{filteredDonations.length === 0 ? ( {filteredDonations.length === 0 ? (
<tr> <TableRow>
<td colSpan="7" className="px-6 py-12 text-center"> <TableCell colSpan={7} className="p-12 text-center">
<div className="flex flex-col items-center gap-3"> <div className="flex flex-col items-center gap-3">
<Heart className="h-12 w-12 text-[var(--neutral-800)]" /> <Heart className="h-12 w-12 text-[var(--neutral-800)]" />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{donations.length === 0 ? 'No donations yet' : 'No donations match your filters'} {donations.length === 0 ? 'No donations yet' : 'No donations match your filters'}
</p> </p>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
) : ( ) : (
filteredDonations.map((donation) => { filteredDonations.map((donation) => {
const isExpanded = expandedRows.has(donation.id); const isExpanded = expandedRows.has(donation.id);
return ( return (
<React.Fragment key={donation.id}> <React.Fragment key={donation.id}>
<tr className="hover:bg-[var(--lavender-400)] transition-colors"> <TableRow>
<td className="px-6 py-4"> <TableCell>
<div> <div>
<p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-medium text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.donor_name || 'Anonymous'} {donation.donor_name || 'Anonymous'}
@@ -445,39 +433,37 @@ const AdminDonations = () => {
{donation.donor_email || 'No email'} {donation.donor_email || 'No email'}
</p> </p>
</div> </div>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<Badge <Badge
className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none rounded-full px-3 py-1`} className={`${getTypeBadgeColor(donation.donation_type)} text-white border-none px-3 py-1`}
style={{ fontFamily: "'Inter', sans-serif" }} style={{ fontFamily: "'Inter', sans-serif" }}
> >
{donation.donation_type === 'member' ? 'Member' : 'Public'} {donation.donation_type === 'member' ? 'Member' : 'Public'}
</Badge> </Badge>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}> <p className="font-semibold text-[var(--purple-ink)] text-lg" style={{ fontFamily: "'Inter', sans-serif" }}>
{donation.amount} {donation.amount}
</p> </p>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<Badge variant={getStatusBadgeVariant(donation.status)} className="rounded-full"> <StatusBadge status={donation.status} />
{donation.status.charAt(0).toUpperCase() + donation.status.slice(1)} </TableCell>
</Badge> <TableCell>
</td>
<td className="px-6 py-4">
<div className="flex items-center gap-2 text-brand-purple "> <div className="flex items-center gap-2 text-brand-purple ">
<Calendar className="h-4 w-4" /> <Calendar className="h-4 w-4" />
<span style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <span style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDate(donation.created_at)} {formatDate(donation.created_at)}
</span> </span>
</div> </div>
</td> </TableCell>
<td className="px-6 py-4"> <TableCell>
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif capitalize" }}>
{donation.payment_method || 'N/A'} {donation.payment_method || 'N/A'}
</p> </p>
</td> </TableCell>
<td className="px-6 py-4 text-center"> <TableCell className="text-center">
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -486,12 +472,11 @@ const AdminDonations = () => {
> >
{isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />} {isExpanded ? <ChevronUp className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
</Button> </Button>
</td> </TableCell>
</tr> </TableRow>
{/* Expandable Details Row */}
{isExpanded && ( {isExpanded && (
<tr className="bg-[var(--lavender-400)]/30"> <TableRow className="bg-[var(--lavender-400)]/30">
<td colSpan="7" className="px-6 py-6"> <TableCell colSpan={7} className="p-6">
<div className="space-y-4"> <div className="space-y-4">
<h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}> <h4 className="font-semibold text-[var(--purple-ink)] text-lg mb-4" style={{ fontFamily: "'Inter', sans-serif" }}>
Transaction Details Transaction Details
@@ -601,15 +586,15 @@ const AdminDonations = () => {
</div> </div>
</div> </div>
</div> </div>
</td> </TableCell>
</tr> </TableRow>
)} )}
</React.Fragment> </React.Fragment>
); );
}) })
)} )}
</tbody> </TableBody>
</table> </Table>
</div> </div>
</Card> </Card>

View File

@@ -191,10 +191,9 @@ const AdminFinancials = () => {
<div className="space-y-4"> <div className="space-y-4">
{reports.map(report => ( {reports.map(report => (
<Card key={report.id} className="p-6"> <Card key={report.id} className="p-6">
<div className="flex items-center gap-6"> <div className="flex items-center gap-3">
<div className="bg-light-lavender p-4 rounded-xl min-w-[100px] text-center"> <div className="bg-light-lavender p-3 rounded-xl self-center">
<DollarSign className="h-6 w-6 mx-auto mb-1" /> <DollarSign className="size-8 " />
<div className="text-2xl font-bold">{report.year}</div>
</div> </div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">

View File

@@ -1,48 +1,361 @@
import React from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { Card } from '../../components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Badge } from '../../components/ui/badge'; import { Badge } from '../../components/ui/badge';
import { DEFAULT_MEMBER_TIERS } from '../../config/MemberTiers'; import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '../../components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { useAuth } from '../../context/AuthContext';
import useMemberTiers from '../../hooks/use-member-tiers';
import { TIER_ICON_OPTIONS, BADGE_COLOR_PRESETS } from '../../config/MemberTiers';
import { getTierIcon } from '../../config/memberTierIcons';
import { Save, RotateCcw, Plus, Trash2, GripVertical, AlertTriangle, Users } from 'lucide-react';
const AdminMemberTiers = () => { const AdminMemberTiers = () => {
const { user } = useAuth();
const { tiers, loading, saving, updateTiers, resetToDefaults } = useMemberTiers({ isAdmin: true });
const [editedTiers, setEditedTiers] = useState([]);
const [hasChanges, setHasChanges] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const isSuperAdmin = user?.role === 'superadmin';
// Initialize edited tiers when tiers load
useEffect(() => {
if (tiers && tiers.length > 0) {
setEditedTiers(JSON.parse(JSON.stringify(tiers)));
setHasChanges(false);
}
}, [tiers]);
// Check for changes
useEffect(() => {
if (tiers && editedTiers.length > 0) {
const changed = JSON.stringify(tiers) !== JSON.stringify(editedTiers);
setHasChanges(changed);
}
}, [tiers, editedTiers]);
const handleTierChange = useCallback((index, field, value) => {
setEditedTiers(prev => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
}, []);
const handleAddTier = useCallback(() => {
const newTier = {
id: `tier_${Date.now()}`,
label: 'New Tier',
minYears: editedTiers.length > 0
? Math.max(...editedTiers.map(t => t.maxYears || 0)) + 0.001
: 0,
maxYears: 999,
iconKey: 'star',
badgeClass: 'bg-gray-100 text-gray-800 border-gray-200',
};
setEditedTiers(prev => [...prev, newTier]);
}, [editedTiers]);
const handleRemoveTier = useCallback((index) => {
if (editedTiers.length <= 1) {
toast.error('You must have at least one tier');
return;
}
setEditedTiers(prev => prev.filter((_, i) => i !== index));
}, [editedTiers.length]);
const validateTiers = useCallback(() => {
for (let i = 0; i < editedTiers.length; i++) {
const tier = editedTiers[i];
if (!tier.label?.trim()) {
toast.error(`Tier ${i + 1} must have a label`);
return false;
}
if (tier.minYears < 0) {
toast.error(`Tier "${tier.label}" has invalid minimum years`);
return false;
}
if (tier.maxYears <= tier.minYears) {
toast.error(`Tier "${tier.label}" max years must be greater than min years`);
return false;
}
}
// Check for overlapping ranges
const sorted = [...editedTiers].sort((a, b) => a.minYears - b.minYears);
for (let i = 0; i < sorted.length - 1; i++) {
if (sorted[i].maxYears >= sorted[i + 1].minYears) {
toast.error(`Tier ranges overlap between "${sorted[i].label}" and "${sorted[i + 1].label}"`);
return false;
}
}
return true;
}, [editedTiers]);
const handleSave = async () => {
if (!validateTiers()) return;
const success = await updateTiers(editedTiers);
if (success) {
toast.success('Member tiers saved successfully');
} else {
toast.error('Failed to save member tiers');
}
};
const handleReset = async () => {
const success = await resetToDefaults();
if (success) {
toast.success('Member tiers reset to defaults');
setShowResetDialog(false);
} else {
toast.error('Failed to reset member tiers');
}
};
const handleDiscardChanges = () => {
setEditedTiers(JSON.parse(JSON.stringify(tiers)));
setHasChanges(false);
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> {/* Header and Actions */}
<h1 className="text-3xl font-bold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
Member Tiers <p className="text-muted-foreground">
</h1> Configure tier names, time ranges, and badges displayed in the members directory.
<p className="text-brand-purple mt-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
Configure tier names, time ranges, and badges used in the members directory.
</p> </p>
<div className="flex items-center gap-2">
{hasChanges && (
<Button variant="outline" onClick={handleDiscardChanges}>
Discard
</Button>
)}
{isSuperAdmin && (
<Button
variant="outline"
onClick={() => setShowResetDialog(true)}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset
</Button>
)}
<Button onClick={handleSave} disabled={saving || !hasChanges}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div> </div>
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)]"> {/* Tier Cards */}
<div className="space-y-4"> <div className="space-y-4">
{DEFAULT_MEMBER_TIERS.map((tier) => { {editedTiers.map((tier, index) => {
const rangeLabel = tier.maxDays == null const IconComponent = getTierIcon(tier.iconKey);
? `${tier.minDays}+ days`
: `${tier.minDays}${tier.maxDays} days`;
return ( return (
<div <Card key={tier.id} className="bg-background">
key={tier.id} <CardContent className="pt-6">
className="flex flex-wrap items-center justify-between gap-4 border border-[var(--neutral-800)] rounded-xl p-4" <div className="flex flex-col lg:flex-row gap-6">
> {/* Drag Handle & Remove */}
<div> <div className="flex lg:flex-col items-center gap-2 lg:pt-6">
<div className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <GripVertical className="h-5 w-5 text-muted-foreground cursor-move" />
{tier.label} <Button
variant="ghost"
size="sm"
onClick={() => handleRemoveTier(index)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
disabled={editedTiers.length <= 1}
>
<Trash2 className="h-4 w-4" />
</Button>
</div> </div>
<div className="text-sm text-brand-purple" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{rangeLabel} {/* Tier Configuration */}
<div className="flex-1 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Label */}
<div className="space-y-2">
<Label htmlFor={`tier-label-${index}`}>Label</Label>
<Input
id={`tier-label-${index}`}
value={tier.label}
onChange={(e) => handleTierChange(index, 'label', e.target.value)}
placeholder="Tier Name"
/>
</div>
{/* Min Years */}
<div className="space-y-2">
<Label htmlFor={`tier-min-${index}`}>Min Years</Label>
<Input
id={`tier-min-${index}`}
type="number"
step="0.001"
min="0"
value={tier.minYears}
onChange={(e) => handleTierChange(index, 'minYears', parseFloat(e.target.value) || 0)}
/>
</div>
{/* Max Years */}
<div className="space-y-2">
<Label htmlFor={`tier-max-${index}`}>Max Years</Label>
<Input
id={`tier-max-${index}`}
type="number"
step="0.001"
min="0"
value={tier.maxYears}
onChange={(e) => handleTierChange(index, 'maxYears', parseFloat(e.target.value) || 0)}
/>
</div>
{/* Icon */}
<div className="space-y-2">
<Label htmlFor={`tier-icon-${index}`}>Icon</Label>
<Select
value={tier.iconKey}
onValueChange={(value) => handleTierChange(index, 'iconKey', value)}
>
<SelectTrigger id={`tier-icon-${index}`}>
<SelectValue placeholder="Select icon" />
</SelectTrigger>
<SelectContent>
{TIER_ICON_OPTIONS.map((option) => {
const OptionIcon = getTierIcon(option.key);
return (
<SelectItem key={option.key} value={option.key}>
<div className="flex items-center gap-2">
<OptionIcon className="h-4 w-4" />
{option.label}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
{/* Badge Color */}
<div className="space-y-2 md:col-span-2">
<Label htmlFor={`tier-badge-${index}`}>Badge Style</Label>
<Select
value={tier.badgeClass}
onValueChange={(value) => handleTierChange(index, 'badgeClass', value)}
>
<SelectTrigger id={`tier-badge-${index}`}>
<SelectValue placeholder="Select color" />
</SelectTrigger>
<SelectContent>
{BADGE_COLOR_PRESETS.map((preset) => (
<SelectItem key={preset.label} value={preset.badgeClass}>
<div className="flex items-center gap-2">
<div className={`w-4 h-4 rounded ${preset.badgeClass}`} />
{preset.label}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Preview */}
<div className="space-y-2 md:col-span-2 flex flex-col">
<Label>Preview</Label>
<div className="flex-1 flex items-center">
<Badge className={`px-3 py-1.5 rounded-md text-sm flex items-center gap-2 border ${tier.badgeClass}`}>
<IconComponent className="h-4 w-4" />
{tier.label || 'Tier Name'}
</Badge>
</div>
</div>
</div> </div>
</div> </div>
<Badge className={`px-3 py-1 rounded-md text-sm ${tier.badgeClass}`}> </CardContent>
{tier.icon} </Card>
</Badge> );
</div> })}
); </div>
})}
</div> {/* Add Tier Button */}
<Button variant="outline" onClick={handleAddTier} className="w-full border-dashed">
<Plus className="h-4 w-4 mr-2" />
Add Tier
</Button>
{/* Info Card */}
<Card className="bg-muted/50">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Users className="h-5 w-5" />
How Member Tiers Work
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-2">
<p>
Member tiers are automatically assigned based on how long a member has been active.
The tier badge appears on member profiles and in the member directory.
</p>
<ul className="list-disc list-inside space-y-1">
<li>Tiers are matched based on membership duration in years</li>
<li>Each tier should have non-overlapping year ranges</li>
<li>The last tier typically uses a high max value (e.g., 999) to catch all long-term members</li>
</ul>
</CardContent>
</Card> </Card>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Reset Tiers to Defaults?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all custom tier configurations and restore the default member tiers.
This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleReset}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Reset to Defaults
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div> </div>
); );
}; };

View File

@@ -17,7 +17,7 @@ import { Users, Search, User, CreditCard, Eye, CheckCircle, Calendar, AlertCircl
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import CreateMemberDialog from '../../components/CreateMemberDialog'; import CreateMemberDialog from '../../components/CreateMemberDialog';
import InviteStaffDialog from '../../components/InviteStaffDialog'; import InviteMemberDialog from '../../components/InviteMemberDialog';
import WordPressImportWizard from '../../components/WordPressImportWizard'; import WordPressImportWizard from '../../components/WordPressImportWizard';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import { StatCard } from '@/components/StatCard'; import { StatCard } from '@/components/StatCard';
@@ -323,11 +323,9 @@ const AdminMembers = () => {
<SelectItem value="active">Active</SelectItem> <SelectItem value="active">Active</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="payment_pending">Payment Pending</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem> <SelectItem value="pending_validation">Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem>
<SelectItem value="inactive">Inactive</SelectItem> <SelectItem value="inactive">Inactive</SelectItem>
<SelectItem value="canceled">Canceled</SelectItem> <SelectItem value="canceled">Canceled</SelectItem>
<SelectItem value="expired">Expired</SelectItem> <SelectItem value="expired">Expired</SelectItem>
<SelectItem value="abandoned">Abandoned</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -523,7 +521,7 @@ const AdminMembers = () => {
onSuccess={refetch} onSuccess={refetch}
/> />
<InviteStaffDialog <InviteMemberDialog
open={inviteDialogOpen} open={inviteDialogOpen}
onOpenChange={setInviteDialogOpen} onOpenChange={setInviteDialogOpen}
onSuccess={refetch} onSuccess={refetch}

View File

@@ -223,10 +223,13 @@ const AdminNewsletters = () => {
<Calendar className="h-5 w-5" /> <Calendar className="h-5 w-5" />
{year} {year}
</h2> </h2>
<div className="grid gap-4"> <div className="grid gap-3">
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6"> <Card key={newsletter.id} className="p-6">
<div className="flex items-start justify-between"> <div className="flex items-start justify-between ">
<div className="bg-light-lavender p-3 mr-4 rounded-xl self-center">
<FileText className="size-8 " />
</div>
<div className="flex-1"> <div className="flex-1">
<h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2"> <h3 className="text-lg font-semibold text-[var(--purple-ink)] mb-2">
{newsletter.title} {newsletter.title}

View File

@@ -205,16 +205,13 @@ const AdminRoles = () => {
const groupedPermissions = groupPermissionsByModule(); const groupedPermissions = groupPermissionsByModule();
return ( return (
<div className="space-y-6 "> <div className="space-y-6">
{/* Header */} {/* Action Bar */}
<div className="flex justify-between items-center "> <div className="flex justify-between items-center">
<div> <p className="text-muted-foreground">
<h1 className="text-3xl font-bold text-[var(--purple-ink)]">Role Management</h1> Create and manage custom roles with specific permissions
<p className="text-gray-600 mt-1"> </p>
Create and manage custom roles with specific permissions <Button className="btn-lavender" onClick={() => setShowCreateModal(true)}>
</p>
</div>
<Button className="btn-lavender " onClick={() => setShowCreateModal(true)}>
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-4 h-4 mr-2" />
Create Role Create Role
</Button> </Button>

View File

@@ -126,18 +126,7 @@ export default function AdminSettings() {
} }
return ( return (
<div className="container mx-auto p-6 max-w-4xl"> <div className="space-y-6">
{/* Header */}
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 flex items-center gap-3">
<SettingsIcon className="h-8 w-8 text-brand-purple" />
Settings
</h1>
<p className="text-gray-600 mt-2">
Manage system configuration and integrations
</p>
</div>
{/* Stripe Integration Card */} {/* Stripe Integration Card */}
<Card className="border-2 border-[var(--lavender-200)] shadow-sm"> <Card className="border-2 border-[var(--lavender-200)] shadow-sm">
<CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]"> <CardHeader className="bg-gradient-to-r from-[var(--lavender-100)] to-white border-b-2 border-[var(--lavender-200)]">

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,743 @@
import React, { useEffect, useState, useCallback } from 'react';
import api from '../../utils/api';
import { useAuth } from '../../context/AuthContext';
import { useThemeConfig } from '../../context/ThemeConfigContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input';
import { Label } from '../../components/ui/label';
import { Textarea } from '../../components/ui/textarea';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '../../components/ui/alert-dialog';
import { toast } from 'sonner';
import { Palette, Upload, Trash2, RotateCcw, Save, Image, Globe, AlertTriangle } from 'lucide-react';
const AdminTheme = () => {
const { user } = useAuth();
const { refreshTheme, DEFAULT_THEME } = useThemeConfig();
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [uploadingLogo, setUploadingLogo] = useState(false);
const [uploadingFavicon, setUploadingFavicon] = useState(false);
const [showResetDialog, setShowResetDialog] = useState(false);
const [themeData, setThemeData] = useState({
site_name: '',
site_short_name: '',
site_description: '',
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'
});
const [originalData, setOriginalData] = useState(null);
const [metadata, setMetadata] = useState({
is_default: true,
updated_at: null,
updated_by: null
});
const isSuperAdmin = user?.role === 'superadmin';
const fetchThemeSettings = useCallback(async () => {
try {
setLoading(true);
const response = await api.get('/admin/settings/theme');
const { config, is_default, updated_at, updated_by } = response.data;
setThemeData(config);
setOriginalData(config);
setMetadata({ is_default, updated_at, updated_by });
} catch (error) {
toast.error('Failed to fetch theme settings');
console.error('Fetch theme error:', error);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchThemeSettings();
}, [fetchThemeSettings]);
const handleInputChange = (field, value) => {
setThemeData(prev => ({
...prev,
[field]: value
}));
};
const handleColorChange = (colorKey, value) => {
setThemeData(prev => ({
...prev,
colors: {
...prev.colors,
[colorKey]: value
}
}));
};
const handleSaveSettings = async () => {
try {
setSaving(true);
// Build update payload with only changed fields
const payload = {};
if (themeData.site_name !== originalData?.site_name) {
payload.site_name = themeData.site_name;
}
if (themeData.site_short_name !== originalData?.site_short_name) {
payload.site_short_name = themeData.site_short_name;
}
if (themeData.site_description !== originalData?.site_description) {
payload.site_description = themeData.site_description;
}
if (JSON.stringify(themeData.colors) !== JSON.stringify(originalData?.colors)) {
payload.colors = themeData.colors;
}
if (themeData.meta_theme_color !== originalData?.meta_theme_color) {
payload.meta_theme_color = themeData.meta_theme_color;
}
if (Object.keys(payload).length === 0) {
toast.info('No changes to save');
return;
}
await api.put('/admin/settings/theme', payload);
toast.success('Theme settings saved successfully');
// Refresh theme context and re-fetch settings
await refreshTheme();
await fetchThemeSettings();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to save theme settings');
console.error('Save theme error:', error);
} finally {
setSaving(false);
}
};
const handleLogoUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
toast.error('Invalid file type. Please upload PNG, JPEG, WebP, or SVG.');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error('File too large. Maximum size is 5MB.');
return;
}
try {
setUploadingLogo(true);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/admin/settings/theme/logo', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setThemeData(prev => ({
...prev,
logo_url: response.data.logo_url
}));
toast.success('Logo uploaded successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload logo');
console.error('Upload logo error:', error);
} finally {
setUploadingLogo(false);
// Reset the input
event.target.value = '';
}
};
const handleFaviconUpload = async (event) => {
const file = event.target.files?.[0];
if (!file) return;
// Validate file type
const allowedTypes = ['image/x-icon', 'image/vnd.microsoft.icon', 'image/png', 'image/svg+xml'];
if (!allowedTypes.includes(file.type)) {
toast.error('Invalid file type. Please upload ICO, PNG, or SVG.');
return;
}
// Validate file size (1MB)
if (file.size > 1 * 1024 * 1024) {
toast.error('File too large. Maximum size is 1MB.');
return;
}
try {
setUploadingFavicon(true);
const formData = new FormData();
formData.append('file', file);
const response = await api.post('/admin/settings/theme/favicon', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
setThemeData(prev => ({
...prev,
favicon_url: response.data.favicon_url
}));
toast.success('Favicon uploaded successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to upload favicon');
console.error('Upload favicon error:', error);
} finally {
setUploadingFavicon(false);
// Reset the input
event.target.value = '';
}
};
const handleDeleteLogo = async () => {
try {
await api.delete('/admin/settings/theme/logo');
setThemeData(prev => ({
...prev,
logo_url: null
}));
toast.success('Logo deleted successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete logo');
}
};
const handleDeleteFavicon = async () => {
try {
await api.delete('/admin/settings/theme/favicon');
setThemeData(prev => ({
...prev,
favicon_url: null
}));
toast.success('Favicon deleted successfully');
await refreshTheme();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to delete favicon');
}
};
const handleResetToDefaults = async () => {
try {
await api.post('/admin/settings/theme/reset');
toast.success('Theme reset to defaults');
setShowResetDialog(false);
await refreshTheme();
await fetchThemeSettings();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to reset theme');
}
};
// Convert HSL string to approximate hex for color picker
const hslToHex = (hslString) => {
if (!hslString) return '#000000';
const parts = hslString.split(' ');
if (parts.length !== 3) return '#000000';
const h = parseFloat(parts[0]) / 360;
const s = parseFloat(parts[1]) / 100;
const l = parseFloat(parts[2]) / 100;
const hue2rgb = (p, q, t) => {
if (t < 0) t += 1;
if (t > 1) t -= 1;
if (t < 1/6) return p + (q - p) * 6 * t;
if (t < 1/2) return q;
if (t < 2/3) return p + (q - p) * (2/3 - t) * 6;
return p;
};
let r, g, b;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hue2rgb(p, q, h + 1/3);
g = hue2rgb(p, q, h);
b = hue2rgb(p, q, h - 1/3);
}
const toHex = (x) => {
const hex = Math.round(x * 255).toString(16);
return hex.length === 1 ? '0' + hex : hex;
};
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
};
// Convert hex to HSL string
const hexToHsl = (hex) => {
if (!hex) return '0 0% 0%';
// Remove # if present
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0, 2), 16) / 255;
const g = parseInt(hex.substring(2, 4), 16) / 255;
const b = parseInt(hex.substring(4, 6), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
let h, s, l = (max + min) / 2;
if (max === min) {
h = s = 0;
} else {
const d = max - min;
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
switch (max) {
case r: h = ((g - b) / d + (g < b ? 6 : 0)) / 6; break;
case g: h = ((b - r) / d + 2) / 6; break;
case b: h = ((r - g) / d + 4) / 6; break;
default: h = 0;
}
}
return `${Math.round(h * 360)} ${Math.round(s * 100)}% ${Math.round(l * 100)}%`;
};
if (loading) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-primary"></div>
</div>
);
}
const colorFields = [
{ key: 'primary', label: 'Primary Color', description: 'Main brand color used for buttons and highlights' },
{ key: 'primary_foreground', label: 'Primary Foreground', description: 'Text color on primary backgrounds' },
{ key: 'accent', label: 'Accent Color', description: 'Secondary highlight color' },
{ key: 'brand_purple', label: 'Brand Purple', description: 'Purple brand color' },
{ key: 'brand_orange', label: 'Brand Orange', description: 'Orange brand color' },
{ key: 'brand_lavender', label: 'Brand Lavender', description: 'Lavender brand color' }
];
return (
<div className="space-y-6">
{/* Action Buttons */}
<div className="flex items-center justify-between">
<p className="text-muted-foreground">
Customize the appearance of your membership site
</p>
<div className="flex items-center gap-2">
{isSuperAdmin && (
<Button
variant="outline"
onClick={() => setShowResetDialog(true)}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="h-4 w-4 mr-2" />
Reset to Defaults
</Button>
)}
<Button onClick={handleSaveSettings} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving ? 'Saving...' : 'Save Changes'}
</Button>
</div>
</div>
{/* Metadata Banner */}
{!metadata.is_default && metadata.updated_at && (
<div className="bg-muted/50 border rounded-lg px-4 py-3 text-sm text-muted-foreground">
Last updated {new Date(metadata.updated_at).toLocaleDateString()}
{metadata.updated_by && ` by ${metadata.updated_by}`}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Branding Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Globe className="h-5 w-5" />
Branding
</CardTitle>
<CardDescription>
Configure your site name and brand identity
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Site Name */}
<div className="space-y-2">
<Label htmlFor="site_name">Site Name</Label>
<Input
id="site_name"
value={themeData.site_name}
onChange={(e) => handleInputChange('site_name', e.target.value)}
placeholder="LOAF - Lesbians Over Age Fifty"
maxLength={200}
/>
<p className="text-xs text-muted-foreground">
Displayed in the browser title and navigation
</p>
</div>
{/* Short Name */}
<div className="space-y-2">
<Label htmlFor="site_short_name">Short Name</Label>
<Input
id="site_short_name"
value={themeData.site_short_name}
onChange={(e) => handleInputChange('site_short_name', e.target.value)}
placeholder="LOAF"
maxLength={50}
/>
<p className="text-xs text-muted-foreground">
Used for PWA home screen icon label
</p>
</div>
{/* Site Description */}
<div className="space-y-2">
<Label htmlFor="site_description">Site Description</Label>
<Textarea
id="site_description"
value={themeData.site_description}
onChange={(e) => handleInputChange('site_description', e.target.value)}
placeholder="A community organization for lesbians over age fifty..."
maxLength={500}
rows={3}
/>
<p className="text-xs text-muted-foreground">
Used for SEO meta description tag ({themeData.site_description?.length || 0}/500)
</p>
</div>
{/* Meta Theme Color */}
<div className="space-y-2">
<Label htmlFor="meta_theme_color">Browser Theme Color</Label>
<div className="flex items-center gap-2">
<input
type="color"
id="meta_theme_color"
value={themeData.meta_theme_color}
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
className="h-10 w-14 rounded border cursor-pointer"
/>
<Input
value={themeData.meta_theme_color}
onChange={(e) => handleInputChange('meta_theme_color', e.target.value)}
placeholder="#664fa3"
className="flex-1"
maxLength={7}
/>
</div>
<p className="text-xs text-muted-foreground">
Color shown in mobile browser address bar (PWA)
</p>
</div>
</CardContent>
</Card>
{/* Logo & Favicon Section */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Image className="h-5 w-5" />
Logo & Favicon
</CardTitle>
<CardDescription>
Upload your organization's logo and favicon
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Logo Upload */}
<div className="space-y-3">
<Label>Logo</Label>
<div className="flex items-start gap-4">
<div className="w-24 h-24 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
{themeData.logo_url ? (
<img
src={themeData.logo_url}
alt="Logo"
className="w-full h-full object-contain"
/>
) : (
<Image className="h-10 w-10 text-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/svg+xml"
onChange={handleLogoUpload}
className="hidden"
disabled={uploadingLogo}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingLogo}
asChild
>
<span>
<Upload className="h-4 w-4 mr-2" />
{uploadingLogo ? 'Uploading...' : 'Upload'}
</span>
</Button>
</label>
{themeData.logo_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteLogo}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
PNG, JPEG, WebP, or SVG. Max 5MB.
</p>
</div>
</div>
</div>
{/* Favicon Upload */}
<div className="space-y-3">
<Label>Favicon</Label>
<div className="flex items-start gap-4">
<div className="w-16 h-16 bg-muted rounded-lg flex items-center justify-center overflow-hidden border">
{themeData.favicon_url ? (
<img
src={themeData.favicon_url}
alt="Favicon"
className="w-8 h-8 object-contain"
/>
) : (
<Globe className="h-6 w-6 text-muted-foreground" />
)}
</div>
<div className="flex-1 space-y-2">
<div className="flex items-center gap-2">
<label className="cursor-pointer">
<input
type="file"
accept="image/x-icon,image/vnd.microsoft.icon,image/png,image/svg+xml"
onChange={handleFaviconUpload}
className="hidden"
disabled={uploadingFavicon}
/>
<Button
type="button"
variant="outline"
size="sm"
disabled={uploadingFavicon}
asChild
>
<span>
<Upload className="h-4 w-4 mr-2" />
{uploadingFavicon ? 'Uploading...' : 'Upload'}
</span>
</Button>
</label>
{themeData.favicon_url && (
<Button
type="button"
variant="outline"
size="sm"
onClick={handleDeleteFavicon}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
<p className="text-xs text-muted-foreground">
ICO, PNG, or SVG. Max 1MB.
</p>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Color Scheme Section */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Color Scheme
</CardTitle>
<CardDescription>
Customize the color palette used throughout the site. Colors are stored as HSL values.
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
{colorFields.map((field) => (
<div key={field.key} className="space-y-2">
<Label htmlFor={`color_${field.key}`}>{field.label}</Label>
<div className="flex items-center gap-2">
<input
type="color"
id={`color_${field.key}`}
value={hslToHex(themeData.colors[field.key])}
onChange={(e) => handleColorChange(field.key, hexToHsl(e.target.value))}
className="h-10 w-14 rounded border cursor-pointer"
/>
<Input
value={themeData.colors[field.key]}
onChange={(e) => handleColorChange(field.key, e.target.value)}
placeholder="280 47% 27%"
className="flex-1 font-mono text-sm"
/>
</div>
<p className="text-xs text-muted-foreground">
{field.description}
</p>
</div>
))}
</div>
</CardContent>
</Card>
{/* Preview Section */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Preview</CardTitle>
<CardDescription>
See how your theme changes will look
</CardDescription>
</CardHeader>
<CardContent>
<div
className="rounded-lg border p-6 space-y-4"
style={{
'--preview-primary': themeData.colors.primary,
'--preview-accent': themeData.colors.accent,
'--preview-brand-purple': themeData.colors.brand_purple,
'--preview-brand-orange': themeData.colors.brand_orange,
'--preview-brand-lavender': themeData.colors.brand_lavender,
}}
>
<div className="flex items-center gap-4">
{themeData.logo_url ? (
<img src={themeData.logo_url} alt="Preview Logo" className="h-16 w-16 object-contain" />
) : (
<div className="h-16 w-16 bg-muted rounded-lg flex items-center justify-center">
<Image className="h-8 w-8 text-muted-foreground" />
</div>
)}
<div>
<h3 className="text-xl font-bold">{themeData.site_name || 'Site Name'}</h3>
<p className="text-sm text-muted-foreground">{themeData.site_short_name || 'Short Name'}</p>
{themeData.site_description && (
<p className="text-xs text-muted-foreground mt-1 max-w-md">{themeData.site_description}</p>
)}
</div>
</div>
<div className="flex flex-wrap gap-3 pt-4">
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.primary})` }}
>
Primary
</div>
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.accent})` }}
>
Accent
</div>
<div
className="px-4 py-2 rounded-lg text-white font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_purple})` }}
>
Brand Purple
</div>
<div
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_orange})` }}
>
Brand Orange
</div>
<div
className="px-4 py-2 rounded-lg font-medium"
style={{ backgroundColor: `hsl(${themeData.colors.brand_lavender})` }}
>
Brand Lavender
</div>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Reset Confirmation Dialog */}
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
Reset Theme to Defaults?
</AlertDialogTitle>
<AlertDialogDescription>
This will delete all custom theme settings including uploaded logo and favicon.
The site will revert to the default LOAF theme. This action cannot be undone.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleResetToDefaults}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Reset to Defaults
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
};
export default AdminTheme;

View File

@@ -11,6 +11,7 @@ import { toast } from 'sonner';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import ChangeRoleDialog from '../../components/ChangeRoleDialog'; import ChangeRoleDialog from '../../components/ChangeRoleDialog';
import StatusBadge from '../../components/StatusBadge'; import StatusBadge from '../../components/StatusBadge';
import TransactionHistory from '../../components/TransactionHistory';
const AdminUserView = () => { const AdminUserView = () => {
const { userId } = useParams(); const { userId } = useParams();
@@ -19,8 +20,8 @@ const AdminUserView = () => {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [resetPasswordLoading, setResetPasswordLoading] = useState(false); const [resetPasswordLoading, setResetPasswordLoading] = useState(false);
const [resendVerificationLoading, setResendVerificationLoading] = useState(false); const [resendVerificationLoading, setResendVerificationLoading] = useState(false);
const [subscriptions, setSubscriptions] = useState([]); const [transactions, setTransactions] = useState({ subscriptions: [], donations: [] });
const [subscriptionsLoading, setSubscriptionsLoading] = useState(true); const [transactionsLoading, setTransactionsLoading] = useState(true);
const [confirmDialogOpen, setConfirmDialogOpen] = useState(false); const [confirmDialogOpen, setConfirmDialogOpen] = useState(false);
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [uploadingPhoto, setUploadingPhoto] = useState(false); const [uploadingPhoto, setUploadingPhoto] = useState(false);
@@ -69,7 +70,7 @@ const AdminUserView = () => {
useEffect(() => { useEffect(() => {
fetchConfig(); fetchConfig();
fetchUserProfile(); fetchUserProfile();
fetchSubscriptions(); fetchTransactions();
}, [userId]); }, [userId]);
useEffect(() => { useEffect(() => {
@@ -90,14 +91,15 @@ const AdminUserView = () => {
} }
}; };
const fetchSubscriptions = async () => { const fetchTransactions = async () => {
try { try {
const response = await api.get(`/admin/subscriptions?user_id=${userId}`); setTransactionsLoading(true);
setSubscriptions(response.data); const response = await api.get(`/admin/users/${userId}/transactions`);
setTransactions(response.data);
} catch (error) { } catch (error) {
console.error('Failed to fetch subscriptions:', error); console.error('Failed to fetch transactions:', error);
} finally { } finally {
setSubscriptionsLoading(false); setTransactionsLoading(false);
} }
}; };
@@ -484,91 +486,17 @@ const AdminUserView = () => {
</div> </div>
</Card> </Card>
{/* Subscription Info (if applicable) */} {/* Transaction History */}
{user.role === 'member' && ( <div className="mt-8">
<Card className="p-8 bg-background rounded-2xl border border-[var(--neutral-800)] mt-8"> <TransactionHistory
<h2 className="text-2xl font-semibold text-[var(--purple-ink)] mb-6" style={{ fontFamily: "'Inter', sans-serif" }}> subscriptions={transactions.subscriptions}
Subscription Information donations={transactions.donations}
</h2> totalSubscriptionCents={transactions.total_subscription_amount_cents}
totalDonationCents={transactions.total_donation_amount_cents}
{subscriptionsLoading ? ( loading={transactionsLoading}
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Loading subscriptions...</p> isAdmin={true}
) : subscriptions.length === 0 ? ( />
<p className="text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>No subscriptions found for this member.</p> </div>
) : (
<div className="space-y-6">
{subscriptions.map((sub) => (
<div key={sub.id} className="p-6 bg-[var(--lavender-500)] rounded-xl border border-[var(--neutral-800)]">
<div className="flex items-start justify-between mb-4">
<div>
<h3 className="text-lg font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}>
{sub.plan.name}
</h3>
<p className="text-sm text-brand-purple " style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.plan.billing_cycle}
</p>
</div>
<StatusBadge status={sub.status} />
</div>
<div className="grid md:grid-cols-2 gap-4 text-sm">
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Start Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDateDisplayValue(sub.start_date)}
</p>
</div>
{sub.end_date && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>End Date</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{formatDateDisplayValue(sub.end_date)}
</p>
</div>
)}
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Base Amount</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.base_subscription_cents / 100).toFixed(2)}
</p>
</div>
{sub.donation_cents > 0 && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Donation</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.donation_cents / 100).toFixed(2)}
</p>
</div>
)}
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Paid</label>
<p className="text-[var(--purple-ink)] font-semibold" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
${(sub.amount_paid_cents / 100).toFixed(2)}
</p>
</div>
{sub.payment_method && (
<div>
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Method</label>
<p className="text-[var(--purple-ink)]" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.payment_method}
</p>
</div>
)}
{sub.stripe_subscription_id && (
<div className="md:col-span-2">
<label className="text-brand-purple font-medium" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Stripe Subscription ID</label>
<p className="text-[var(--purple-ink)] text-xs font-mono" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>
{sub.stripe_subscription_id}
</p>
</div>
)}
</div>
</div>
))}
</div>
)}
</Card>
)}
{/* Admin Action Confirmation Dialog */} {/* Admin Action Confirmation Dialog */}
<ConfirmationDialog <ConfirmationDialog

View File

@@ -2,7 +2,6 @@ import React, { useEffect, useState } from 'react';
import { useAuth } from '../../context/AuthContext'; import { useAuth } from '../../context/AuthContext';
import api from '../../utils/api'; import api from '../../utils/api';
import { Card } from '../../components/ui/card'; import { Card } from '../../components/ui/card';
import { Button } from '../../components/ui/button';
import { Input } from '../../components/ui/input'; import { Input } from '../../components/ui/input';
import { import {
Select, Select,
@@ -19,6 +18,12 @@ import {
TableRow, TableRow,
TableCell, TableCell,
} from '../../components/ui/table'; } from '../../components/ui/table';
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '../../components/ui/tooltip';
import { import {
Pagination, Pagination,
PaginationContent, PaginationContent,
@@ -29,11 +34,14 @@ import {
PaginationEllipsis, PaginationEllipsis,
} from '../../components/ui/pagination'; } from '../../components/ui/pagination';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X } from 'lucide-react'; import { CheckCircle, Clock, Search, ArrowUp, ArrowDown, X, FileText, XCircle } from 'lucide-react';
import PaymentActivationDialog from '../../components/PaymentActivationDialog'; import PaymentActivationDialog from '../../components/PaymentActivationDialog';
import ConfirmationDialog from '../../components/ConfirmationDialog'; import ConfirmationDialog from '../../components/ConfirmationDialog';
import RejectionDialog from '../../components/RejectionDialog'; import RejectionDialog from '../../components/RejectionDialog';
import StatusBadge from '@/components/StatusBadge'; import StatusBadge from '@/components/StatusBadge';
import { StatCard } from '@/components/StatCard';
import { Button } from '@/components/ui/button';
import ViewRegistrationDialog from '@/components/ViewRegistrationDialog';
const AdminValidations = () => { const AdminValidations = () => {
const { hasPermission } = useAuth(); const { hasPermission } = useAuth();
@@ -47,6 +55,8 @@ const AdminValidations = () => {
const [pendingAction, setPendingAction] = useState(null); const [pendingAction, setPendingAction] = useState(null);
const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false); const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false);
const [userToReject, setUserToReject] = useState(null); const [userToReject, setUserToReject] = useState(null);
const [viewRegistrationDialogOpen, setViewRegistrationDialogOpen] = useState(false);
const [selectedUserForView, setSelectedUserForView] = useState(null);
// Filtering state // Filtering state
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
@@ -60,6 +70,9 @@ const AdminValidations = () => {
const [sortBy, setSortBy] = useState('created_at'); const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc'); const [sortOrder, setSortOrder] = useState('desc');
// Resend email state
const [resendLoading, setResendLoading] = useState(null);
useEffect(() => { useEffect(() => {
fetchPendingUsers(); fetchPendingUsers();
}, []); }, []);
@@ -235,6 +248,24 @@ const AdminValidations = () => {
} }
}; };
const handleRegistrationDialog = (user) => {
setSelectedUserForView(user);
setViewRegistrationDialogOpen(true);
};
// Resend Email Handler
const handleResendVerification = async (user) => {
setResendLoading(user.id);
try {
await api.post(`/admin/users/${user.id}/resend-verification`);
toast.success(`Verification email sent to ${user.email}`);
fetchPendingUsers();
} catch (error) {
toast.error(error.response?.data?.detail || 'Failed to send verification email');
} finally {
setResendLoading(null);
}
};
const handleSort = (column) => { const handleSort = (column) => {
@@ -260,6 +291,37 @@ const AdminValidations = () => {
<ArrowDown className="h-4 w-4 inline ml-1" />; <ArrowDown className="h-4 w-4 inline ml-1" />;
}; };
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 handleActionSelect = (user, action) => {
switch (action) {
case 'validate':
handleValidateRequest(user);
break;
case 'bypass_validate':
handleBypassAndValidateRequest(user);
break;
case 'resend_email':
handleResendVerification(user);
break;
case 'activate_payment':
handleActivatePayment(user);
break;
case 'reactivate':
handleReactivateUser(user);
break;
default:
break;
}
};
return ( return (
<> <>
{/* Header */} {/* Header */}
@@ -272,45 +334,54 @@ const AdminValidations = () => {
</p> </p>
</div> </div>
{/* Stats Card */} {/* Stats Card */}
<Card className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] mb-8"> <Card className="rounded-3xl bg-brand-lavender/10 p-8 mb-8">
<div className=' text-2xl text-[var(--purple-ink)] pb-8 font-semibold'>
Quick Overview
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4"> <div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div> <StatCard
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Total Pending</p> title="Total Pending"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={loading ? '-' : pendingUsers.length}
{pendingUsers.length} icon={CheckCircle}
</p> iconBgClass="text-brand-purple"
</div> dataTestId="stat-total-users"
<div> />
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Awaiting Email</p>
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> <StatCard
{pendingUsers.filter(u => u.status === 'pending_email').length} title="Awaiting Email"
</p> value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_email').length}
</div> icon={CheckCircle}
<div> iconBgClass="text-brand-purple"
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pending Validation</p> dataTestId="stat-total-users"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> />
{pendingUsers.filter(u => u.status === 'pending_validation').length}
</p> <StatCard
</div> title="Pending Validation"
<div> value={loading ? '-' : pendingUsers.filter(u => u.status === 'pending_validation').length}
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Pre-Validated</p> icon={CheckCircle}
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> iconBgClass="text-brand-purple"
{pendingUsers.filter(u => u.status === 'pre_validated').length} dataTestId="stat-pending-validation"
</p> />
</div>
<div> <StatCard
<p className="text-sm text-brand-purple mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Payment Pending</p> title="Payment Pending"
<p className="text-3xl font-semibold text-[var(--purple-ink)]" style={{ fontFamily: "'Inter', sans-serif" }}> value={loading ? '-' : pendingUsers.filter(u => u.status === 'payment_pending').length}
{pendingUsers.filter(u => u.status === 'payment_pending').length} icon={CheckCircle}
</p> iconBgClass="text-brand-purple"
</div> dataTestId="stat-payment-pending"
<div> />
<p className="text-sm text-red-600 mb-2" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>Rejected</p>
<p className="text-3xl font-semibold text-red-800" style={{ fontFamily: "'Inter', sans-serif" }}> <StatCard
{pendingUsers.filter(u => u.status === 'rejected').length} title="Rejected"
</p> value={loading ? '-' : pendingUsers.filter(u => u.status === 'rejected').length}
</div> icon={XCircle}
iconBgClass="text-red-600"
dataTestId="stat-rejected"
/>
</div> </div>
</Card> </Card>
@@ -331,13 +402,12 @@ const AdminValidations = () => {
<SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]"> <SelectTrigger className="h-14 rounded-xl border-2 border-[var(--neutral-800)]">
<SelectValue placeholder="Filter by status" /> <SelectValue placeholder="Filter by status" />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent className="">
<SelectItem value="all">All Statuses</SelectItem> <SelectItem value="all">All Statuses</SelectItem>
<SelectItem value="pending_email">Awaiting Email</SelectItem> <SelectItem value="pending_email" >Awaiting Email</SelectItem>
<SelectItem value="pending_validation">Pending Validation</SelectItem> <SelectItem value="pending_validation" >Pending Validation</SelectItem>
<SelectItem value="pre_validated">Pre-Validated</SelectItem> <SelectItem value="payment_pending" >Payment Pending</SelectItem>
<SelectItem value="payment_pending">Payment Pending</SelectItem> <SelectItem value="rejected" >Rejected</SelectItem>
<SelectItem value="rejected">Rejected</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
@@ -353,14 +423,13 @@ const AdminValidations = () => {
<Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden"> <Card className="bg-background rounded-2xl border border-[var(--neutral-800)] overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow className="text-md">
<TableHead <TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20" className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('first_name')} onClick={() => handleSort('first_name')}
> >
Name {renderSortIcon('first_name')} Member {renderSortIcon('first_name')}
</TableHead> </TableHead>
<TableHead>Email</TableHead>
<TableHead>Phone</TableHead> <TableHead>Phone</TableHead>
<TableHead <TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20" className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
@@ -374,6 +443,13 @@ const AdminValidations = () => {
> >
Registered {renderSortIcon('created_at')} Registered {renderSortIcon('created_at')}
</TableHead> </TableHead>
<TableHead
className="cursor-pointer hover:bg-[var(--neutral-800)]/20"
onClick={() => handleSort('email_verification_expires_at')}
>
{/* TODO: change ' ' */}
Validation Expiry {renderSortIcon('email_verification_expires_at')}
</TableHead>
<TableHead>Referred By</TableHead> <TableHead>Referred By</TableHead>
<TableHead>Actions</TableHead> <TableHead>Actions</TableHead>
</TableRow> </TableRow>
@@ -381,105 +457,116 @@ const AdminValidations = () => {
<TableBody> <TableBody>
{paginatedUsers.map((user) => ( {paginatedUsers.map((user) => (
<TableRow key={user.id}> <TableRow key={user.id}>
<TableCell className="font-medium"> <TableCell className=" ">
{user.first_name} {user.last_name} <div className='font-semibold'>
{user.first_name} {user.last_name}
</div>
<div className='text-brand-purple'>
{user.email}
</div>
</TableCell> </TableCell>
<TableCell>{user.email}</TableCell> <TableCell>{formatPhoneNumber(user.phone)}</TableCell>
<TableCell>{user.phone}</TableCell>
<TableCell><StatusBadge status={user.status} /></TableCell> <TableCell><StatusBadge status={user.status} /></TableCell>
<TableCell> <TableCell>
{new Date(user.created_at).toLocaleDateString()} {new Date(user.created_at).toLocaleDateString()}
</TableCell> </TableCell>
<TableCell>
{user.email_verification_expires_at
? new Date(user.email_verification_expires_at).toLocaleString()
: '—'}
</TableCell>
<TableCell> <TableCell>
{user.referred_by_member_name || '-'} {user.referred_by_member_name || '-'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex gap-2"> <div className='flex gap-2 justify-between'>
{user.status === 'rejected' ? (
<Button <Select
onClick={() => handleReactivateUser(user)} value=""
disabled={actionLoading === user.id} onValueChange={(action) => handleActionSelect(user, action)}
size="sm" disabled={actionLoading === user.id || resendLoading === user.id}
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]" >
> <SelectTrigger className="w-[100px] h-9 border-[var(--neutral-800)]">
{actionLoading === user.id ? 'Reactivating...' : 'Reactivate'} <SelectValue placeholder={actionLoading === user.id || resendLoading === user.id ? 'Processing...' : 'Action'} />
</Button> </SelectTrigger>
) : user.status === 'pending_email' ? ( <SelectContent>
<> {user.status === 'rejected' ? (
{hasPermission('users.approve') && ( <SelectItem value="reactivate">Reactivate</SelectItem>
<Button ) : user.status === 'pending_email' ? (
onClick={() => handleBypassAndValidateRequest(user)} <>
disabled={actionLoading === user.id} {hasPermission('users.approve') && (
size="sm" <SelectItem value="bypass_validate">Bypass & Validate</SelectItem>
className="bg-[var(--neutral-800)] text-[var(--purple-ink)] hover:bg-background" )}
> {hasPermission('users.approve') && (
{actionLoading === user.id ? 'Validating...' : 'Bypass & Validate'} <SelectItem value="resend_email">Resend Email</SelectItem>
</Button> )}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
) : user.status === 'payment_pending' ? (
<>
{hasPermission('subscriptions.activate') && (
<SelectItem value="activate_payment">Activate Payment</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
) : (
<>
{hasPermission('users.approve') && (
<SelectItem value="validate">Validate</SelectItem>
)}
{/* {hasPermission('users.approve') && (
<SelectItem value="reject">Reject</SelectItem>
)} */}
</>
)} )}
{hasPermission('users.approve') && ( </SelectContent>
</Select>
<TooltipProvider>
{/* view registration */}
<Tooltip>
<TooltipTrigger asChild>
<Button <Button
onClick={() => handleRejectUser(user)} onClick={() => handleRegistrationDialog(user)}
disabled={actionLoading === user.id} disabled={actionLoading === user.id}
size="sm" size="sm"
variant="outline" variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" className="border-2 border-primary text-primary hover:bg-red-50 dark:hover:bg-red-500/10"
> >
<X className="h-4 w-4 mr-1" /> <FileText className="size-4" />
Reject
</Button> </Button>
)} </TooltipTrigger>
</> <TooltipContent>
) : user.status === 'payment_pending' ? ( View registration
<> </TooltipContent>
{hasPermission('subscriptions.activate') && ( </Tooltip>
<Button
onClick={() => handleActivatePayment(user)} {/* reject */}
size="sm" {hasPermission('users.approve') && (
className="btn-light-lavender" <Tooltip>
> <TooltipTrigger asChild>
<CheckCircle className="h-4 w-4 mr-1" /> <Button
Activate Payment onClick={() => handleRejectUser(user)}
</Button> disabled={actionLoading === user.id}
)} size="sm"
{hasPermission('users.approve') && ( variant="outline"
<Button className="border-2 mr-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
onClick={() => handleRejectUser(user)} >
disabled={actionLoading === user.id} X
size="sm" </Button>
variant="outline" </TooltipTrigger>
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10" <TooltipContent>
> Reject user
<X className="h-4 w-4 mr-1" /> </TooltipContent>
Reject </Tooltip>
</Button> )}
)} </TooltipProvider>
</>
) : (
<>
{hasPermission('users.approve') && (
<Button
onClick={() => handleValidateRequest(user)}
disabled={actionLoading === user.id}
size="sm"
className="bg-[var(--green-light)] text-white hover:bg-[var(--green-mint)]"
>
{actionLoading === user.id ? 'Validating...' : 'Validate'}
</Button>
)}
{hasPermission('users.approve') && (
<Button
onClick={() => handleRejectUser(user)}
disabled={actionLoading === user.id}
size="sm"
variant="outline"
className="border-2 border-red-500 text-red-500 hover:bg-red-50 dark:hover:bg-red-500/10"
>
<X className="h-4 w-4 mr-1" />
Reject
</Button>
)}
</>
)}
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
@@ -604,6 +691,13 @@ const AdminValidations = () => {
user={userToReject} user={userToReject}
loading={actionLoading !== null} loading={actionLoading !== null}
/> />
{/* View Registration Dialog */}
<ViewRegistrationDialog
open={viewRegistrationDialogOpen}
onOpenChange={setViewRegistrationDialogOpen}
user={selectedUserForView}
/>
</> </>
); );
}; };

View File

@@ -15,9 +15,10 @@ import {
} from '../../components/ui/dialog'; } from '../../components/ui/dialog';
import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react'; import { User, Search, Mail, MapPin, Phone, Heart, Facebook, Instagram, Twitter, Linkedin, UserCircle, Calendar } from 'lucide-react';
import { useToast } from '../../hooks/use-toast'; import { useToast } from '../../hooks/use-toast';
import StatusBadge from '@/components/StatusBadge';
import MemberCard from '../../components/MemberCard'; import MemberCard from '../../components/MemberCard';
import MemberBadge from '../../components/MemberBadge';
import useMembers from '../../hooks/use-members'; import useMembers from '../../hooks/use-members';
import useMemberTiers from '../../hooks/use-member-tiers';
const MembersDirectory = () => { const MembersDirectory = () => {
const [selectedMember, setSelectedMember] = useState(null); const [selectedMember, setSelectedMember] = useState(null);
@@ -25,7 +26,27 @@ const MembersDirectory = () => {
const { toast } = useToast(); const { toast } = useToast();
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 12; const pageSize = 12;
const { tiers } = useMemberTiers();
const allowedRoles = useMemo(() => [], []); const allowedRoles = useMemo(() => [], []);
const normalizeStatus = useCallback((status) => {
if (typeof status === 'string') {
return status.toLowerCase();
}
return status;
}, []);
const normalizeMembers = useCallback(
(data) => {
const list = Array.isArray(data)
? data
: data?.members || data?.results || data?.items || data?.data || [];
return list.map((member) => ({
...member,
status: normalizeStatus(member.status ?? member.membership_status ?? member.member_status),
}));
},
[normalizeStatus]
);
const searchAccessor = useCallback( const searchAccessor = useCallback(
(member) => [ (member) => [
`${member.first_name} ${member.last_name}`, `${member.first_name} ${member.last_name}`,
@@ -47,12 +68,14 @@ const MembersDirectory = () => {
loading, loading,
searchQuery, searchQuery,
setSearchQuery, setSearchQuery,
filterValue,
} = useMembers({ } = useMembers({
endpoint: '/members/directory', endpoint: '/members/directory',
initialFilter: 'active', initialFilter: 'all',
filterKey: 'status', filterKey: 'status',
allowedRoles, allowedRoles,
searchAccessor, searchAccessor,
transform: normalizeMembers,
fetchErrorMessage: 'Failed to load members directory. Please try again.', fetchErrorMessage: 'Failed to load members directory. Please try again.',
onFetchError: handleFetchError onFetchError: handleFetchError
}); });
@@ -67,7 +90,12 @@ const MembersDirectory = () => {
const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize); const paginatedMembers = filteredMembers.slice(pageStart, pageStart + pageSize);
const totalMembers = members.length; const totalMembers = useMemo(() => {
if (!filterValue || filterValue === 'all') {
return members.length;
}
return members.filter((member) => member.status === filterValue).length;
}, [members, filterValue]);
@@ -158,7 +186,7 @@ const MembersDirectory = () => {
) : filteredMembers.length > 0 ? ( ) : filteredMembers.length > 0 ? (
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8"> <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{paginatedMembers.map((member) => ( {paginatedMembers.map((member) => (
<MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} /> <MemberCard key={member.id} member={member} onViewProfile={handleViewProfile} tiers={tiers} />
))} ))}
</div> </div>
) : ( ) : (
@@ -210,7 +238,7 @@ const MembersDirectory = () => {
<DialogHeader> <DialogHeader>
<DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}> <DialogTitle className="text-3xl font-semibold text-[var(--purple-ink)] flex items-center justify-between mr-8" style={{ fontFamily: "'Inter', sans-serif" }}>
{selectedMember.first_name} {selectedMember.last_name} {selectedMember.first_name} {selectedMember.last_name}
<StatusBadge status={selectedMember.membership_status || selectedMember.status} /> <MemberBadge memberSince={selectedMember.member_since || selectedMember.created_at} tiers={tiers} />
</DialogTitle> </DialogTitle>
{selectedMember.directory_partner_name && ( {selectedMember.directory_partner_name && (
<DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}> <DialogDescription className="flex items-center gap-2 text-lg" style={{ fontFamily: "'Nunito Sans', sans-serif" }}>

View File

@@ -167,9 +167,9 @@ export default function NewsletterArchive() {
{groupedNewsletters[year].map(newsletter => ( {groupedNewsletters[year].map(newsletter => (
<Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow"> <Card key={newsletter.id} className="p-6 bg-background rounded-2xl border border-[var(--neutral-800)] hover:shadow-lg transition-shadow">
<div className="flex items-start gap-4"> <div className="flex items-start gap-4">
<div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl"> <div className="bg-gradient-to-br from-brand-purple to-[var(--purple-ink)] p-4 rounded-xl">
<FileText className="h-8 w-8 text-white" /> <FileText className="h-8 w-8 text-white" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}> <h3 className="text-xl font-semibold text-[var(--purple-ink)] mb-2" style={{ fontFamily: "'Inter', sans-serif" }}>
{newsletter.title} {newsletter.title}

View File

@@ -2,32 +2,6 @@
@layer base { @layer base {
:root { :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: var(--brand-orange);
--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;
/* ========================= /* =========================
Brand Colors Brand Colors
========================= */ ========================= */
@@ -47,7 +21,7 @@
/* /*
========================== ==========================
Color Patch Social Media Colors
========================== ==========================
*/ */
@@ -55,6 +29,50 @@
--blue-facebook: #1877f2; --blue-facebook: #1877f2;
--blue-twitter: #1da1f2; --blue-twitter: #1da1f2;
--red-instagram: #e4405f; --red-instagram: #e4405f;
/* =========================
Theme Colors
========================= */
--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: var(--brand-lavender);
--secondary-foreground: 280 47% 27%;
--muted: 268 43% 95%;
--muted-foreground: 268 35% 47%;
--accent: var(--brand-orange);
--accent-foreground: 280 47% 27%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--success: 147 23% 46%;
--success-foreground: 0 0% 98%;
--warning: var(--brand-orange);
--warning-foreground: 0 0% 10%;
--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;
--purple-ink: #422268; --purple-ink: #422268;
--purple-ink-2: #422268; --purple-ink-2: #422268;
--purple-deep: #48286e; --purple-deep: #48286e;

View File

@@ -1,23 +1,56 @@
// src/utils/member-tiers.js // src/utils/member-tiers.js
import { differenceInDays } from 'date-fns'; import { differenceInDays } from 'date-fns';
import { DEFAULT_MEMBER_TIERS } from '../config/memberTiers'; import { DEFAULT_MEMBER_TIERS } from '../config/MemberTiers';
export const getTenureDays = (memberSince) => { /**
* Calculate tenure in years (with decimal precision)
* @param {string|Date} memberSince - The date the member joined
* @returns {number|null} - Years of membership or null if invalid
*/
export const getTenureYears = (memberSince) => {
if (!memberSince) return null; if (!memberSince) return null;
const since = new Date(memberSince); const since = new Date(memberSince);
if (Number.isNaN(since.getTime())) return null; if (Number.isNaN(since.getTime())) return null;
return Math.max(0, differenceInDays(new Date(), since));
const now = new Date();
const days = differenceInDays(now, since);
// Convert to years with decimal precision
return Math.max(0, days / 365.25);
}; };
/**
* Get the tier for a member based on their membership duration
* @param {string|Date} memberSince - The date the member joined
* @param {Array} tiers - Array of tier configurations
* @returns {Object} - The matching tier object
*/
export const getTierForMember = (memberSince, tiers = DEFAULT_MEMBER_TIERS) => { export const getTierForMember = (memberSince, tiers = DEFAULT_MEMBER_TIERS) => {
const days = getTenureDays(memberSince); const years = getTenureYears(memberSince);
if (days == null) return tiers[0]; if (years == null) return tiers[0];
const match = tiers.find( const match = tiers.find(
(tier) => (tier) =>
days >= tier.minDays && years >= tier.minYears &&
(tier.maxDays == null || days <= tier.maxDays) (tier.maxYears == null || years <= tier.maxYears)
); );
return match || tiers[0]; return match || tiers[0];
}; };
/**
* Format tenure for display
* @param {string|Date} memberSince - The date the member joined
* @returns {string} - Human-readable tenure string
*/
export const formatTenure = (memberSince) => {
const years = getTenureYears(memberSince);
if (years == null) return 'Unknown';
if (years < 1) {
const months = Math.floor(years * 12);
return months <= 1 ? '< 1 month' : `${months} months`;
}
const wholeYears = Math.floor(years);
return wholeYears === 1 ? '1 year' : `${wholeYears} years`;
};

View File

@@ -49,6 +49,10 @@ module.exports = {
DEFAULT: 'hsl(var(--success))', DEFAULT: 'hsl(var(--success))',
foreground: 'hsl(var(--success-foreground))' foreground: 'hsl(var(--success-foreground))'
}, },
warning: {
DEFAULT: 'hsl(var(--warning))',
foreground: 'hsl(var(--warning-foreground))'
},
border: 'hsl(var(--border))', border: 'hsl(var(--border))',
input: 'hsl(var(--input))', input: 'hsl(var(--input))',
ring: 'hsl(var(--ring))', ring: 'hsl(var(--ring))',