forked from jamesp/sasa-membership
Add UTC datetime helpers to attempt to fix running issue
This commit is contained in:
@@ -0,0 +1,81 @@
|
||||
import React from 'react';
|
||||
|
||||
type AdminAreaKey = 'operations' | 'rfid' | 'comms' | 'flags' | 'tiers';
|
||||
type AdminSectionKey =
|
||||
| 'overview'
|
||||
| 'users'
|
||||
| 'events'
|
||||
| 'profileQuestions'
|
||||
| 'espActions'
|
||||
| 'espReaders'
|
||||
| 'espCards'
|
||||
| 'espActivity'
|
||||
| 'featureFlags'
|
||||
| 'tiers'
|
||||
| 'email'
|
||||
| 'bounces';
|
||||
|
||||
interface AdminWorkspacePageProps {
|
||||
activeAdminArea: AdminAreaKey;
|
||||
activePageItems: Array<{ key: AdminSectionKey; label: string }>;
|
||||
adminPrimaryItems: Array<{ key: AdminAreaKey; label: string; defaultSection: AdminSectionKey }>;
|
||||
adminSection: AdminSectionKey;
|
||||
children: React.ReactNode;
|
||||
renderAdminRailTools: () => React.ReactNode;
|
||||
renderPrimaryIcon: (area: AdminAreaKey) => React.ReactNode;
|
||||
showAdminPageRail: boolean;
|
||||
navigateToAdminSection: (section: AdminSectionKey) => void;
|
||||
}
|
||||
|
||||
const AdminWorkspacePage: React.FC<AdminWorkspacePageProps> = ({
|
||||
activeAdminArea,
|
||||
activePageItems,
|
||||
adminPrimaryItems,
|
||||
adminSection,
|
||||
children,
|
||||
renderAdminRailTools,
|
||||
renderPrimaryIcon,
|
||||
showAdminPageRail,
|
||||
navigateToAdminSection
|
||||
}) => (
|
||||
<div className={`admin-workspace ${showAdminPageRail ? 'has-page-rail' : 'single-page-area'}`}>
|
||||
<aside className="admin-primary-rail">
|
||||
<nav className="admin-primary-nav" aria-label="Admin areas">
|
||||
{adminPrimaryItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={activeAdminArea === item.key ? 'admin-primary-link active' : 'admin-primary-link'}
|
||||
onClick={() => navigateToAdminSection(item.defaultSection)}
|
||||
title={item.label}
|
||||
>
|
||||
<span className="admin-primary-icon">{renderPrimaryIcon(item.key)}</span>
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{showAdminPageRail && (
|
||||
<aside className="admin-page-rail">
|
||||
<div className="admin-page-rail-title">
|
||||
{adminPrimaryItems.find((item) => item.key === activeAdminArea)?.label}
|
||||
</div>
|
||||
<nav className="admin-page-nav" aria-label="Admin pages">
|
||||
{activePageItems.map((item) => (
|
||||
<button
|
||||
key={item.key}
|
||||
className={adminSection === item.key ? 'admin-page-link active' : 'admin-page-link'}
|
||||
onClick={() => navigateToAdminSection(item.key)}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
{renderAdminRailTools()}
|
||||
</aside>
|
||||
)}
|
||||
|
||||
<section className="admin-content">{children}</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default AdminWorkspacePage;
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { Event, Membership, Payment } from '../../services/membershipService';
|
||||
import { utcToLondonTimeInput } from '../../utils/timezone';
|
||||
|
||||
interface MemberOverviewPageProps {
|
||||
activeMembership?: Membership;
|
||||
formatDate: (dateString: string) => string;
|
||||
getStatusClass: (status: string) => string;
|
||||
handleMembershipSetup: () => void;
|
||||
handleRSVP: (eventId: number, status: 'attending' | 'maybe' | 'not_attending') => void;
|
||||
payments: Payment[];
|
||||
rsvpLoading: { [eventId: number]: boolean };
|
||||
upcomingEvents: Event[];
|
||||
}
|
||||
|
||||
const MemberOverviewPage: React.FC<MemberOverviewPageProps> = ({
|
||||
activeMembership,
|
||||
formatDate,
|
||||
getStatusClass,
|
||||
handleMembershipSetup,
|
||||
handleRSVP,
|
||||
payments,
|
||||
rsvpLoading,
|
||||
upcomingEvents
|
||||
}) => (
|
||||
<>
|
||||
<section className="member-hero">
|
||||
<div>
|
||||
<p className="member-hero-kicker">Member Dashboard</p>
|
||||
<h2 className="member-hero-title">Everything you need for your SASA membership</h2>
|
||||
<p className="member-hero-copy">
|
||||
Track your status, respond to upcoming events, and keep your details current from one place.
|
||||
</p>
|
||||
</div>
|
||||
<div className="member-stat-strip">
|
||||
<div className="member-stat-chip">
|
||||
<span className="member-stat-label">Membership</span>
|
||||
<strong className="member-stat-value">{activeMembership ? activeMembership.status : 'Not set up'}</strong>
|
||||
</div>
|
||||
<div className="member-stat-chip">
|
||||
<span className="member-stat-label">Events</span>
|
||||
<strong className="member-stat-value">{upcomingEvents.length}</strong>
|
||||
</div>
|
||||
<div className="member-stat-chip">
|
||||
<span className="member-stat-label">Payments</span>
|
||||
<strong className="member-stat-value">{payments.length}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="dashboard-grid member-overview-grid">
|
||||
{activeMembership ? (
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership</p>
|
||||
<h3>Your Membership</h3>
|
||||
</div>
|
||||
<span className={`status-badge ${getStatusClass(activeMembership.status)}`}>{activeMembership.status.toUpperCase()}</span>
|
||||
</div>
|
||||
<h4 className="member-tier-title">{activeMembership.tier.name}</h4>
|
||||
<div className="member-data-list">
|
||||
<div className="member-data-row"><strong>Membership Number</strong><span>{activeMembership.id}</span></div>
|
||||
<div className="member-data-row"><strong>Annual Fee</strong><span>£{activeMembership.tier.annual_fee.toFixed(2)}</span></div>
|
||||
<div className="member-data-row"><strong>Valid From</strong><span>{formatDate(activeMembership.start_date)}</span></div>
|
||||
<div className="member-data-row"><strong>Valid Until</strong><span>{formatDate(activeMembership.end_date)}</span></div>
|
||||
<div className="member-data-row"><strong>Auto Renew</strong><span>{activeMembership.auto_renew ? 'Yes' : 'No'}</span></div>
|
||||
</div>
|
||||
<div className="member-info-panel">
|
||||
<strong>Benefits:</strong>
|
||||
<p>{activeMembership.tier.benefits}</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Membership</p>
|
||||
<h3>Set Up Your Membership</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p>Choose from our membership tiers to get started with SASA benefits.</p>
|
||||
<p className="member-muted-copy">Available tiers include Personal, Aircraft Owners, and Corporate memberships.</p>
|
||||
<button className="btn btn-primary member-inline-action" onClick={handleMembershipSetup}>
|
||||
Set Up Membership
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Calendar</p>
|
||||
<h3>Upcoming Events</h3>
|
||||
</div>
|
||||
</div>
|
||||
{upcomingEvents.length > 0 ? (
|
||||
<div className="events-container">
|
||||
{upcomingEvents.map((event) => (
|
||||
<div key={event.id} className="event-card">
|
||||
<div className="event-header">
|
||||
<div className="event-info">
|
||||
<h4 className="event-title">{event.title}</h4>
|
||||
<p className="event-datetime">
|
||||
{formatDate(event.event_date)} at {utcToLondonTimeInput(event.event_date)}
|
||||
</p>
|
||||
{event.location && <p className="event-location">{event.location}</p>}
|
||||
</div>
|
||||
<div className="event-rsvp-buttons">
|
||||
<button
|
||||
className={`rsvp-btn rsvp-btn-attending ${event.rsvp_status === 'attending' ? 'active' : ''}`}
|
||||
onClick={() => handleRSVP(event.id, 'attending')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Attending'}
|
||||
</button>
|
||||
<button
|
||||
className={`rsvp-btn rsvp-btn-maybe ${event.rsvp_status === 'maybe' ? 'active' : ''}`}
|
||||
onClick={() => handleRSVP(event.id, 'maybe')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Maybe'}
|
||||
</button>
|
||||
<button
|
||||
className={`rsvp-btn rsvp-btn-not-attending ${event.rsvp_status === 'not_attending' ? 'active' : ''}`}
|
||||
onClick={() => handleRSVP(event.id, 'not_attending')}
|
||||
disabled={rsvpLoading[event.id]}
|
||||
>
|
||||
{rsvpLoading[event.id] ? '...' : 'Not Attending'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{event.description && <p className="event-description">{event.description}</p>}
|
||||
{event.rsvp_status && (
|
||||
<div className={`event-rsvp-status ${event.rsvp_status}`}>
|
||||
<strong>Your RSVP:</strong> <span className="member-rsvp-state">{event.rsvp_status.replace('_', ' ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="member-muted-copy">No upcoming events at this time.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card member-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Billing</p>
|
||||
<h3>Payment History</h3>
|
||||
</div>
|
||||
</div>
|
||||
{payments.length > 0 ? (
|
||||
<div className="table-container">
|
||||
<table className="member-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Amount</th>
|
||||
<th>Method</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{payments.map((payment) => (
|
||||
<tr key={payment.id}>
|
||||
<td>{payment.payment_date ? formatDate(payment.payment_date) : 'Pending'}</td>
|
||||
<td>£{payment.amount.toFixed(2)}</td>
|
||||
<td className="member-table-caps">{payment.payment_method}</td>
|
||||
<td>
|
||||
<span className={`status-badge ${getStatusClass(payment.status)}`}>
|
||||
{payment.status.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="member-muted-copy">No payment history available.</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
export default MemberOverviewPage;
|
||||
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import ProfileQuestionsForm from '../../components/ProfileQuestionsForm';
|
||||
import { ProfileAnswerInput, ProfileQuestionForUser } from '../../services/membershipService';
|
||||
|
||||
interface MemberQuestionsPageProps {
|
||||
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
|
||||
questions: ProfileQuestionForUser[];
|
||||
}
|
||||
|
||||
const MemberQuestionsPage: React.FC<MemberQuestionsPageProps> = ({ onSave, questions }) => (
|
||||
<ProfileQuestionsForm
|
||||
title="Your Profile Questions"
|
||||
description="Optional details that help us support your membership and volunteering. Some fields are admin-managed."
|
||||
questions={questions}
|
||||
onSave={onSave}
|
||||
saveLabel="Save Profile Answers"
|
||||
surface="member"
|
||||
/>
|
||||
);
|
||||
|
||||
export default MemberQuestionsPage;
|
||||
@@ -0,0 +1,165 @@
|
||||
import React from 'react';
|
||||
|
||||
interface MemberSettingsPageProps {
|
||||
passwordError: string;
|
||||
passwordForm: {
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
};
|
||||
passwordSaving: boolean;
|
||||
passwordSuccess: string;
|
||||
profileError: string;
|
||||
profileFormData: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
};
|
||||
profileSaving: boolean;
|
||||
profileSuccess: string;
|
||||
setPasswordForm: React.Dispatch<React.SetStateAction<{
|
||||
current_password: string;
|
||||
new_password: string;
|
||||
confirm_password: string;
|
||||
}>>;
|
||||
setProfileFormData: React.Dispatch<React.SetStateAction<{
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
address: string;
|
||||
}>>;
|
||||
onChangePassword: () => void;
|
||||
onSaveProfile: () => void;
|
||||
}
|
||||
|
||||
const MemberSettingsPage: React.FC<MemberSettingsPageProps> = ({
|
||||
passwordError,
|
||||
passwordForm,
|
||||
passwordSaving,
|
||||
passwordSuccess,
|
||||
profileError,
|
||||
profileFormData,
|
||||
profileSaving,
|
||||
profileSuccess,
|
||||
setPasswordForm,
|
||||
setProfileFormData,
|
||||
onChangePassword,
|
||||
onSaveProfile
|
||||
}) => (
|
||||
<div className="card member-card member-settings-card">
|
||||
<div className="member-card-header">
|
||||
<div>
|
||||
<p className="member-card-kicker">Settings</p>
|
||||
<h3>Profile Settings</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profileError && <div className="alert alert-error">{profileError}</div>}
|
||||
{profileSuccess && <div className="alert alert-success">{profileSuccess}</div>}
|
||||
|
||||
<div className="member-settings-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-first-name">First Name</label>
|
||||
<input
|
||||
id="settings-first-name"
|
||||
type="text"
|
||||
placeholder="First Name"
|
||||
value={profileFormData.first_name}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, first_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-last-name">Last Name</label>
|
||||
<input
|
||||
id="settings-last-name"
|
||||
type="text"
|
||||
placeholder="Last Name"
|
||||
value={profileFormData.last_name}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, last_name: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-email">Email</label>
|
||||
<input
|
||||
id="settings-email"
|
||||
type="email"
|
||||
placeholder="Email"
|
||||
value={profileFormData.email}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, email: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-phone">Phone</label>
|
||||
<input
|
||||
id="settings-phone"
|
||||
type="text"
|
||||
placeholder="Phone"
|
||||
value={profileFormData.phone}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, phone: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-address">Address</label>
|
||||
<textarea
|
||||
id="settings-address"
|
||||
placeholder="Address"
|
||||
value={profileFormData.address}
|
||||
onChange={(e) => setProfileFormData((prev) => ({ ...prev, address: e.target.value }))}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="member-settings-actions">
|
||||
<button className="btn btn-primary" disabled={profileSaving} onClick={onSaveProfile}>
|
||||
{profileSaving ? 'Saving...' : 'Save Profile'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="member-settings-divider" />
|
||||
<h4 className="member-section-heading">Change Password</h4>
|
||||
{passwordError && <div className="alert alert-error">{passwordError}</div>}
|
||||
{passwordSuccess && <div className="alert alert-success">{passwordSuccess}</div>}
|
||||
<div className="member-settings-grid">
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-current-password">Current Password</label>
|
||||
<input
|
||||
id="settings-current-password"
|
||||
type="password"
|
||||
placeholder="Current Password"
|
||||
value={passwordForm.current_password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, current_password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-new-password">New Password</label>
|
||||
<input
|
||||
id="settings-new-password"
|
||||
type="password"
|
||||
placeholder="New Password"
|
||||
value={passwordForm.new_password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, new_password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="form-group">
|
||||
<label htmlFor="settings-confirm-password">Confirm New Password</label>
|
||||
<input
|
||||
id="settings-confirm-password"
|
||||
type="password"
|
||||
placeholder="Confirm New Password"
|
||||
value={passwordForm.confirm_password}
|
||||
onChange={(e) => setPasswordForm((prev) => ({ ...prev, confirm_password: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="member-settings-actions">
|
||||
<button className="btn btn-secondary" disabled={passwordSaving} onClick={onChangePassword}>
|
||||
{passwordSaving ? 'Updating...' : 'Update Password'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export default MemberSettingsPage;
|
||||
Reference in New Issue
Block a user