diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ecf8441 --- /dev/null +++ b/.dockerignore @@ -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/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e550e13 --- /dev/null +++ b/Dockerfile @@ -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;"] diff --git a/README.md b/README.md index 3c2f651..c620aaf 100644 --- a/README.md +++ b/README.md @@ -999,3 +999,182 @@ api.interceptors.response.use( **Last Updated**: December 18, 2024 **Version**: 1.0.0 **Maintainer**: LOAF Development Team + +**Backend API** + +**Auth** +- POST `/api/auth/register` +- GET `/api/auth/verify-email` +- POST `/api/auth/resend-verification-email` +- POST `/api/auth/login` +- POST `/api/auth/forgot-password` +- POST `/api/auth/reset-password` +- GET `/api/auth/me` +- GET `/api/auth/permissions` + +**Users** +- PUT `/api/users/change-password` +- GET `/api/users/profile` +- PUT `/api/users/profile` + +**Members** +- GET `/api/members/directory` (defined twice in code) +- GET `/api/members/directory/{user_id}` +- GET `/api/members/profile` +- PUT `/api/members/profile` +- POST `/api/members/profile/upload-photo` +- DELETE `/api/members/profile/delete-photo` +- GET `/api/members/calendar/events` +- GET `/api/members/gallery` +- GET `/api/members/event-activity` + +**Events (public/member)** +- GET `/api/events` +- GET `/api/events/{event_id}` +- GET `/api/events/{event_id}/gallery` +- POST `/api/events/{event_id}/rsvp` +- GET `/api/events/{event_id}/download.ics` + +**Calendars** +- GET `/api/calendars/subscribe.ics` +- GET `/api/calendars/all-events.ics` + +**Newsletters (public)** +- GET `/api/newsletters` +- GET `/api/newsletters/years` + +**Financials (public)** +- GET `/api/financials` + +**Bylaws (public)** +- GET `/api/bylaws/current` +- GET `/api/bylaws/history` + +**Config/Diagnostics** +- GET `/api/config` +- GET `/api/config/limits` +- GET `/api/diagnostics/cors` + +**Invitations** +- GET `/api/invitations/verify/{token}` +- POST `/api/invitations/accept` + +**Subscriptions** +- GET `/api/subscriptions/plans` +- POST `/api/subscriptions/checkout` + +**Donations** +- POST `/api/donations/checkout` + +**Contact** +- POST `/api/contact` + +**Admin – Calendar** +- POST `/api/admin/calendar/sync/{event_id}` +- DELETE `/api/admin/calendar/unsync/{event_id}` + +**Admin – Event Gallery** +- POST `/api/admin/events/{event_id}/gallery` +- DELETE `/api/admin/event-gallery/{image_id}` +- PUT `/api/admin/event-gallery/{image_id}` + +**Admin – Events** +- POST `/api/admin/events` +- PUT `/api/admin/events/{event_id}` +- GET `/api/admin/events/{event_id}` +- GET `/api/admin/events/{event_id}/rsvps` +- PUT `/api/admin/events/{event_id}/attendance` +- GET `/api/admin/events` +- DELETE `/api/admin/events/{event_id}` + +**Admin – Storage** +- GET `/api/admin/storage/usage` +- GET `/api/admin/storage/breakdown` + +**Admin – Users & Invitations** +- GET `/api/admin/users` +- GET `/api/admin/users/invitations` +- GET `/api/admin/users/export` +- GET `/api/admin/users/{user_id}` +- PUT `/api/admin/users/{user_id}` +- PUT `/api/admin/users/{user_id}/validate` +- PUT `/api/admin/users/{user_id}/status` +- POST `/api/admin/users/{user_id}/reject` +- POST `/api/admin/users/{user_id}/activate-payment` +- PUT `/api/admin/users/{user_id}/reset-password` +- PUT `/api/admin/users/{user_id}/role` +- POST `/api/admin/users/{user_id}/resend-verification` +- POST `/api/admin/users/{user_id}/upload-photo` +- DELETE `/api/admin/users/{user_id}/delete-photo` +- POST `/api/admin/users/create` +- POST `/api/admin/users/invite` +- POST `/api/admin/users/invitations/{invitation_id}/resend` +- DELETE `/api/admin/users/invitations/{invitation_id}` +- POST `/api/admin/users/import` +- GET `/api/admin/users/import-jobs` +- GET `/api/admin/users/import-jobs/{job_id}` + +**Admin – Imports** +- POST `/api/admin/import/upload-csv` +- GET `/api/admin/import/{job_id}/preview` +- POST `/api/admin/import/{job_id}/execute` +- POST `/api/admin/import/{job_id}/rollback` +- GET `/api/admin/import/{job_id}/status` +- GET `/api/admin/import/{job_id}/errors/download` + +**Admin – Subscriptions** +- GET `/api/admin/subscriptions/plans` +- GET `/api/admin/subscriptions/plans/{plan_id}` +- POST `/api/admin/subscriptions/plans` +- PUT `/api/admin/subscriptions/plans/{plan_id}` +- DELETE `/api/admin/subscriptions/plans/{plan_id}` +- GET `/api/admin/subscriptions` +- GET `/api/admin/subscriptions/stats` +- PUT `/api/admin/subscriptions/{subscription_id}` +- POST `/api/admin/subscriptions/{subscription_id}/cancel` +- GET `/api/admin/subscriptions/export` + +**Admin – Donations** +- GET `/api/admin/donations` +- GET `/api/admin/donations/stats` +- GET `/api/admin/donations/export` + +**Admin – Newsletters** +- POST `/api/admin/newsletters` +- PUT `/api/admin/newsletters/{newsletter_id}` +- DELETE `/api/admin/newsletters/{newsletter_id}` + +**Admin – Financials** +- POST `/api/admin/financials` +- PUT `/api/admin/financials/{report_id}` +- DELETE `/api/admin/financials/{report_id}` + +**Admin – Bylaws** +- POST `/api/admin/bylaws` +- PUT `/api/admin/bylaws/{bylaws_id}` +- DELETE `/api/admin/bylaws/{bylaws_id}` + +**Admin – Roles** +- GET `/api/admin/roles` +- GET `/api/admin/roles/assignable` +- POST `/api/admin/roles` +- GET `/api/admin/roles/{role_id}` +- PUT `/api/admin/roles/{role_id}` +- DELETE `/api/admin/roles/{role_id}` +- GET `/api/admin/roles/{role_id}/permissions` +- PUT `/api/admin/roles/{role_id}/permissions` + +**Admin – Permissions** +- GET `/api/admin/permissions` +- GET `/api/admin/permissions/modules` +- GET `/api/admin/permissions/roles/{role}` +- PUT `/api/admin/permissions/roles/{role}` +- POST `/api/admin/permissions/seed` + +**Admin – Stripe Settings** +- GET `/api/admin/settings/stripe/status` +- POST `/api/admin/settings/stripe/test-connection` +- PUT `/api/admin/settings/stripe` + +**Webhooks** +- POST `/api/webhooks/stripe` \ No newline at end of file diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..7321506 --- /dev/null +++ b/nginx.conf @@ -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; + } +} diff --git a/package.json b/package.json index 5663868..13ec4bf 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@radix-ui/react-tooltip": "^1.2.4", "@stripe/react-stripe-js": "^2.0.0", "@stripe/stripe-js": "^2.0.0", + "@tailwindcss/line-clamp": "^0.4.4", "axios": "^1.8.4", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/src/App.js b/src/App.js index 7023eb9..3b51ce0 100644 --- a/src/App.js +++ b/src/App.js @@ -23,7 +23,9 @@ import AdminStaff from './pages/admin/AdminStaff'; import AdminMembers from './pages/admin/AdminMembers'; import AdminPermissions from './pages/admin/AdminPermissions'; import AdminSettings from './pages/admin/AdminSettings'; +import AdminMemberTiers from './pages/admin/AdminMemberTiers'; import AdminRoles from './pages/admin/AdminRoles'; +import AdminTheme from './pages/admin/AdminTheme'; import AdminEvents from './pages/admin/AdminEvents'; import AdminEventAttendance from './pages/admin/AdminEventAttendance'; import AdminValidations from './pages/admin/AdminValidations'; @@ -31,6 +33,7 @@ import AdminPlans from './pages/admin/AdminPlans'; import AdminSubscriptions from './pages/admin/AdminSubscriptions'; import AdminDonations from './pages/admin/AdminDonations'; import AdminLayout from './layouts/AdminLayout'; +import SettingsLayout from './layouts/SettingsLayout'; import { AuthProvider, useAuth } from './context/AuthContext'; import MemberRoute from './components/MemberRoute'; import MemberCalendar from './pages/members/MemberCalendar'; @@ -44,6 +47,8 @@ import AdminGallery from './pages/admin/AdminGallery'; import AdminNewsletters from './pages/admin/AdminNewsletters'; import AdminFinancials from './pages/admin/AdminFinancials'; import AdminBylaws from './pages/admin/AdminBylaws'; +import AdminRegistrationBuilder from './pages/admin/AdminRegistrationBuilder'; +import AdminDirectorySettings from './pages/admin/AdminDirectorySettings'; import History from './pages/History'; import MissionValues from './pages/MissionValues'; import BoardOfDirectors from './pages/BoardOfDirectors'; @@ -58,19 +63,19 @@ import NotFound from './pages/NotFound'; const PrivateRoute = ({ children, adminOnly = false }) => { const { user, loading } = useAuth(); - + if (loading) { return
Loading...
; } - + if (!user) { return ; } - + if (adminOnly && !['admin', 'superadmin'].includes(user.role)) { return ; } - + return children; }; @@ -235,6 +240,20 @@ function App() { } /> + + + + + + } /> + + + + + + } /> @@ -286,18 +305,24 @@ function App() { } /> - - - + } /> + - + - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + {/* 404 - Catch all undefined routes */} } /> diff --git a/src/components/AddPaymentMethodDialog.js b/src/components/AddPaymentMethodDialog.js new file mode 100644 index 0000000..0784c8f --- /dev/null +++ b/src/components/AddPaymentMethodDialog.js @@ -0,0 +1,222 @@ +import React, { useState } from 'react'; +import { useStripe, useElements, CardElement } from '@stripe/react-stripe-js'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from './ui/dialog'; +import { Button } from './ui/button'; +import { Checkbox } from './ui/checkbox'; +import { Label } from './ui/label'; +import { CreditCard, AlertCircle, Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import api from '../utils/api'; + +/** + * AddPaymentMethodDialog - Dialog for adding a new payment method using Stripe Elements + * + * This dialog should be wrapped in an Elements provider with a clientSecret + * + * @param {string} saveEndpoint - Optional custom API endpoint for saving (default: '/payment-methods') + */ +const AddPaymentMethodDialog = ({ + open, + onOpenChange, + onSuccess, + clientSecret, + saveEndpoint = '/payment-methods', +}) => { + const stripe = useStripe(); + const elements = useElements(); + const [loading, setLoading] = useState(false); + const [setAsDefault, setSetAsDefault] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e) => { + e.preventDefault(); + + if (!stripe || !elements) { + return; + } + + setLoading(true); + setError(null); + + try { + // Get the CardElement + const cardElement = elements.getElement(CardElement); + + if (!cardElement) { + setError('Card element not found'); + toast.error('Card element not found'); + setLoading(false); + return; + } + + // Confirm the SetupIntent with the card element + const { error: stripeError, setupIntent } = await stripe.confirmCardSetup( + clientSecret, + { + payment_method: { + card: cardElement, + }, + } + ); + + if (stripeError) { + setError(stripeError.message); + toast.error(stripeError.message); + setLoading(false); + return; + } + + if (setupIntent.status === 'succeeded') { + // Save the payment method to our backend using the specified endpoint + await api.post(saveEndpoint, { + stripe_payment_method_id: setupIntent.payment_method, + set_as_default: setAsDefault, + }); + + toast.success('Payment method added successfully'); + onSuccess?.(); + onOpenChange(false); + } else { + setError(`Setup failed with status: ${setupIntent.status}`); + toast.error('Failed to set up payment method'); + } + } catch (err) { + const errorMessage = err.response?.data?.detail || err.message || 'Failed to save payment method'; + setError(errorMessage); + toast.error(errorMessage); + } finally { + setLoading(false); + } + }; + + return ( + + + +
+ +
+ + Add Payment Method + + + Enter your card details securely + +
+
+
+ +
+ {/* Stripe Card Element */} +
+ +
+ +
+
+ + {/* Set as Default Checkbox */} +
+ + +
+ + {/* Error Message */} + {error && ( +
+ +

+ {error} +

+
+ )} + + {/* Security Note */} +

+ Your card information is securely processed by Stripe. We never store your full card number. +

+ + + + + +
+
+
+ ); +}; + +export default AddPaymentMethodDialog; diff --git a/src/components/AdminSidebar.js b/src/components/AdminSidebar.js index 12401d5..a8c96f2 100644 --- a/src/components/AdminSidebar.js +++ b/src/components/AdminSidebar.js @@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; import { useTheme } from 'next-themes'; import { useAuth } from '../context/AuthContext'; +import { useThemeConfig } from '../context/ThemeConfigContext'; import api from '../utils/api'; import { Badge } from './ui/badge'; import { @@ -26,12 +27,15 @@ import { Heart, Sun, Moon, + Star, + FileEdit } from 'lucide-react'; const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { const location = useLocation(); const navigate = useNavigate(); const { user, logout } = useAuth(); + const { getLogoUrl } = useThemeConfig(); const { theme, setTheme } = useTheme(); const [pendingCount, setPendingCount] = useState(0); const [storageUsed, setStorageUsed] = useState(0); @@ -102,18 +106,31 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { path: '/admin', disabled: false }, + { - name: 'Staff', + name: 'Staff & Admins', icon: UserCog, path: '/admin/staff', disabled: false }, { - name: 'Members', + name: 'Member Roster', icon: Users, path: '/admin/members', disabled: false }, + { + name: 'Member Tiers', + icon: Star, + path: '/admin/member-tiers', + disabled: false + }, + { + name: 'Registration', + icon: FileEdit, + path: '/admin/registration', + disabled: false + }, { name: 'Validations', icon: CheckCircle, @@ -169,13 +186,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => { path: '/admin/bylaws', disabled: false }, - { - name: 'Permissions', - icon: Shield, - path: '/admin/permissions', - disabled: false, - superadminOnly: true - }, + { name: 'Settings', icon: Settings, @@ -287,7 +298,7 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
LOAF Logo { {/* Dashboard - Standalone */} {renderNavItem(filteredNavItems.find(item => item.name === 'Dashboard'))} + {/* Onboarding Section */} + {isOpen && ( +
+

+ Onboarding +

+
+ )} +
+ {renderNavItem(filteredNavItems.find(item => item.name === 'Registration'))} + {renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))} +
{/* MEMBERSHIP Section */} {isOpen && (
@@ -329,9 +352,9 @@ const AdminSidebar = ({ isOpen, onToggle, isMobile }) => {
)}
- {renderNavItem(filteredNavItems.find(item => item.name === 'Staff'))} - {renderNavItem(filteredNavItems.find(item => item.name === 'Members'))} - {renderNavItem(filteredNavItems.find(item => item.name === 'Validations'))} + {renderNavItem(filteredNavItems.find(item => item.name === 'Member Roster'))} + {renderNavItem(filteredNavItems.find(item => item.name === 'Member Tiers'))} + {renderNavItem(filteredNavItems.find(item => item.name === 'Staff & Admins'))}
{/* FINANCIALS Section */} diff --git a/src/components/ChangePasswordDialog.js b/src/components/ChangePasswordDialog.js index 916bdf1..ba641e3 100644 --- a/src/components/ChangePasswordDialog.js +++ b/src/components/ChangePasswordDialog.js @@ -66,7 +66,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => { return ( - +
@@ -128,7 +128,7 @@ const ChangePasswordDialog = ({ open, onOpenChange }) => { diff --git a/src/components/ChangeRoleDialog.js b/src/components/ChangeRoleDialog.js index dafa8d3..736a005 100644 --- a/src/components/ChangeRoleDialog.js +++ b/src/components/ChangeRoleDialog.js @@ -77,7 +77,7 @@ export default function ChangeRoleDialog({ open, onClose, user, onSuccess }) { return ( - + diff --git a/src/components/CreateMemberDialog.js b/src/components/CreateMemberDialog.js index 02b077e..4d3308b 100644 --- a/src/components/CreateMemberDialog.js +++ b/src/components/CreateMemberDialog.js @@ -181,7 +181,7 @@ const CreateMemberDialog = ({ open, onOpenChange, onSuccess }) => { 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="John" + placeholder="Jane" /> {errors.first_name && (

{errors.first_name}

diff --git a/src/components/CreateStaffDialog.js b/src/components/CreateStaffDialog.js index 166806f..01f36c4 100644 --- a/src/components/CreateStaffDialog.js +++ b/src/components/CreateStaffDialog.js @@ -106,7 +106,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => { return ( - + @@ -165,7 +165,7 @@ const CreateStaffDialog = ({ open, onOpenChange, onSuccess }) => { 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="John" + placeholder="Jane" /> {errors.first_name && (

{errors.first_name}

diff --git a/src/components/CreateSubscriptionDialog.js b/src/components/CreateSubscriptionDialog.js new file mode 100644 index 0000000..9c4a5d5 --- /dev/null +++ b/src/components/CreateSubscriptionDialog.js @@ -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 ( + + + + + + Create Subscription + + + Search for an existing member and create a subscription with manual payment processing. + + + +
+
+ {/* User Search Section */} + {!selectedUser ? ( +
+ +
+ + setSearchQuery(e.target.value)} + className="pl-10 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + /> + {searchLoading && ( + + )} +
+ + {/* Search Results */} + {searchResults.length > 0 && ( + +
+ {searchResults.map((user) => ( + + ))} +
+
+ )} + + {searchQuery && !searchLoading && searchResults.length === 0 && ( +

+ No members found matching "{searchQuery}" +

+ )} +
+ ) : ( + /* Selected User Card */ + +
+
+
+ +
+
+

+ {selectedUser.first_name} {selectedUser.last_name} +

+

+ {selectedUser.email} +

+
+
+ +
+
+ )} + + {/* Payment Form - Only show when user is selected */} + {selectedUser && ( + <> + {/* Plan Selection */} +
+ + + {selectedPlan && ( +

+ {selectedPlan.description || `${selectedPlan.billing_cycle} subscription`} +

+ )} +
+ + {/* Payment Amount */} +
+ + setFormData({ ...formData, amount: e.target.value })} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required + /> + {selectedPlan && ( +

+ Minimum: {formatPrice(selectedPlan.minimum_price_cents || selectedPlan.price_cents || 3000)} +

+ )} +
+ + {/* Amount Breakdown */} + {breakdown && breakdown.total >= breakdown.base && ( + +
+
+ Membership Fee: + {formatPrice(breakdown.base)} +
+ {breakdown.donation > 0 && ( +
+ + + Additional Donation: + + {formatPrice(breakdown.donation)} +
+ )} +
+ Total: + {formatPrice(breakdown.total)} +
+
+
+ )} + + {/* Payment Date */} +
+ +
+ + setFormData({ ...formData, payment_date: e.target.value })} + className="pl-12 rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required + /> +
+
+ + {/* Payment Method */} +
+ + +
+ + {/* Subscription Period */} +
+ + +
+ setUseCustomPeriod(e.target.checked)} + className="rounded border-[var(--neutral-800)]" + /> + +
+ + {useCustomPeriod ? ( +
+
+ + setFormData({ ...formData, custom_period_start: e.target.value })} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required={useCustomPeriod} + /> +
+
+ + setFormData({ ...formData, custom_period_end: e.target.value })} + className="rounded-xl border-2 border-[var(--neutral-800)] focus:border-brand-purple" + required={useCustomPeriod} + /> +
+
+ ) : ( + selectedPlan && ( +
+ {selectedPlan.custom_cycle_enabled ? ( + <> +

+ Plan uses custom billing cycle: +
+ {(() => { + 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)`; + })()} +

+

+ Subscription will end on the upcoming cycle end date based on today's date. +

+ + ) : ( +

+ Will use plan's billing cycle: {selectedPlan.billing_cycle} +
+ 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 +

+ )} +
+ ) + )} +
+ + {/* Notes */} +
+ +