12 KiB
12 KiB
Public Shifts Page
Overview
File Path: admin/src/pages/public/ShiftsPage.tsx (344 lines)
Route: /shifts
Role Requirements: Public access (no authentication required)
Purpose: Public volunteer shift signup interface allowing community members to register for canvassing shifts, creating temporary user accounts automatically, and receiving email confirmations.
Key Features:
- Hero banner with gradient background
- Responsive shift cards grid (xs=1, sm=2, lg=3 columns)
- Real-time volunteer capacity progress bars
- Signup modal with name/email/phone fields
- Temporary user creation for non-authenticated signups
- Email confirmation after successful signup
- Success modal with shift details
- Visual opacity indication for full shifts
- Dark theme consistency with public pages
Layout: Uses PublicLayout with dark theme
Features
1. Hero Banner
Prominent call-to-action header:
- Gradient Background: Purple-to-blue gradient
- Title: "Volunteer Opportunities"
- Subtitle: "Join us in making a difference in your community"
- Icon: Calendar icon
- Padding: 80px vertical (desktop), 60px (mobile)
2. Shift Cards Grid
Responsive grid displaying available shifts:
Card Contents:
- Shift title (Typography.Title level 4)
- Date and time range (formatted with dayjs)
- Location address
- Cut/district name (if assigned)
- Description (truncated, 3-line ellipsis)
- Volunteer capacity progress bar
- "Sign Up" button (primary, full-width)
Styling:
- Dark card background (
colorBgContainer) - Hover elevation effect
- 24px gutter between cards
- Rounded corners (8px)
Capacity Indicators:
- Green progress bar (0-70% full)
- Yellow progress bar (71-90% full)
- Red progress bar (91-100% full)
- Text: "X of Y volunteers signed up"
Full Shifts:
- Card opacity reduced to 0.6
- Button disabled with "Full" text
- Badge showing "Full" in red
3. Signup Modal
User registration form:
Form Fields:
- Your Name (required, min 2 chars)
- Email (required, email validation)
- Phone (required, phone number format)
Shift Details Display:
- Shift title (read-only)
- Date/time (read-only)
- Location (read-only)
Submission:
- Creates temporary user if not logged in
- Creates shift signup record
- Sends confirmation email
- Opens success modal
Validation:
- Required field indicators
- Email format check
- Phone format (10 digits, (XXX) XXX-XXXX)
- Duplicate signup prevention
4. Success Modal
Post-signup confirmation:
Content:
- Green checkmark icon
- "Successfully Signed Up!" heading
- Shift details (title, date, time, location)
- Email confirmation message
- "OK" button to close
Behavior:
- Auto-opens after successful signup
- Reloads shift list on close (to show updated capacity)
User Workflow
Browsing Shifts
- User navigates to
/shifts - Hero banner loads with CTA
- API fetches active shifts
- Shift cards render in grid
- User sees capacity bars (green/yellow/red)
- User scrolls through available shifts
Signing Up for Shift
- User finds desired shift card
- User clicks "Sign Up" button
- Modal opens with signup form
- User enters name: "Jane Doe"
- User enters email: "jane@example.com"
- User enters phone: "(555) 123-4567"
- User clicks "Sign Up" submit button
- API creates temp user (role: TEMP)
- API creates shift signup
- Confirmation email sent
- Success modal displays
- User clicks "OK"
- Modal closes
- Shift list refreshes
- Signed-up shift shows updated capacity
Full Shift Handling
- User sees shift with red progress bar (full)
- Card has reduced opacity
- Button shows "Full" and is disabled
- User cannot click signup
- "Full" badge visible on card
Component Structure
import React, { useState, useEffect } from 'react';
import { Card, Row, Col, Typography, Button, Form, Input, Modal, Progress, Tag, Grid, message } from 'antd';
import { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, EnvironmentOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import PublicLayout from '../../components/PublicLayout';
import axios from 'axios';
const { Title, Paragraph, Text } = Typography;
const { useBreakpoint } = Grid;
interface Shift {
id: string;
title: string;
description: string | null;
date: string;
startTime: string;
endTime: string;
location: string;
maxVolunteers: number;
currentSignups: number;
cutName: string | null;
}
const ShiftsPage: React.FC = () => {
const [shifts, setShifts] = useState<Shift[]>([]);
const [loading, setLoading] = useState(true);
const [signupModalVisible, setSignupModalVisible] = useState(false);
const [selectedShift, setSelectedShift] = useState<Shift | null>(null);
const [form] = Form.useForm();
const screens = useBreakpoint();
const isMobile = !screens.md;
return (
<PublicLayout>
{/* Hero Banner */}
{/* Shift Cards Grid */}
{/* Signup Modal */}
{/* Success Modal */}
</PublicLayout>
);
};
State Management
const [shifts, setShifts] = useState<Shift[]>([]);
const [loading, setLoading] = useState(true);
const [signupModalVisible, setSignupModalVisible] = useState(false);
const [selectedShift, setSelectedShift] = useState<Shift | null>(null);
const [successModalVisible, setSuccessModalVisible] = useState(false);
const [form] = Form.useForm();
API Integration
Endpoints
1. List Public Shifts
GET /api/public/map/shifts
Response:
[
{
"id": "cm1abc123",
"title": "Weekend Canvass - Downtown",
"description": "Door-to-door canvassing in the downtown district",
"date": "2025-02-15",
"startTime": "10:00",
"endTime": "14:00",
"location": "123 Main St, Campaign Office",
"maxVolunteers": 10,
"currentSignups": 7,
"cutName": "Downtown District"
}
]
2. Sign Up for Shift
POST /api/public/map/shifts/:id/signup
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "(555) 123-4567"
}
Response:
{
"success": true,
"signupId": "cm2def456",
"message": "Successfully signed up! Confirmation email sent."
}
Code Examples
Shift Card with Capacity Bar
{shifts.map(shift => {
const isFull = shift.currentSignups >= shift.maxVolunteers;
const percentage = (shift.currentSignups / shift.maxVolunteers) * 100;
const progressColor =
percentage < 70 ? '#52c41a' :
percentage < 90 ? '#faad14' :
'#f5222d';
return (
<Col xs={24} sm={12} lg={8} key={shift.id}>
<Card
hoverable={!isFull}
style={{ opacity: isFull ? 0.6 : 1 }}
>
{isFull && (
<Tag color="red" style={{ position: 'absolute', top: 16, right: 16 }}>
Full
</Tag>
)}
<Title level={4} style={{ marginBottom: 12 }}>
{shift.title}
</Title>
<Space direction="vertical" size={8} style={{ width: '100%', marginBottom: 16 }}>
<Text>
<CalendarOutlined /> {dayjs(shift.date).format('MMMM D, YYYY')}
</Text>
<Text>
<ClockCircleOutlined /> {shift.startTime} - {shift.endTime}
</Text>
<Text>
<EnvironmentOutlined /> {shift.location}
</Text>
{shift.cutName && (
<Tag color="blue">{shift.cutName}</Tag>
)}
</Space>
{shift.description && (
<Paragraph ellipsis={{ rows: 3 }} type="secondary" style={{ marginBottom: 16, minHeight: 66 }}>
{shift.description}
</Paragraph>
)}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{shift.currentSignups} of {shift.maxVolunteers} volunteers
</Text>
<Text type="secondary" style={{ fontSize: 12 }}>
{Math.round(percentage)}%
</Text>
</div>
<Progress
percent={percentage}
strokeColor={progressColor}
showInfo={false}
/>
</div>
<Button
type="primary"
block
disabled={isFull}
onClick={() => {
setSelectedShift(shift);
setSignupModalVisible(true);
}}
>
{isFull ? 'Full' : 'Sign Up'}
</Button>
</Card>
</Col>
);
})}
Signup Modal
<Modal
title="Sign Up for Shift"
open={signupModalVisible}
onCancel={() => {
setSignupModalVisible(false);
form.resetFields();
}}
footer={null}
width={500}
>
{selectedShift && (
<>
<div style={{
background: '#e6f7ff',
padding: 16,
borderRadius: 8,
marginBottom: 24
}}>
<Title level={5} style={{ marginBottom: 8 }}>
{selectedShift.title}
</Title>
<Text>
<CalendarOutlined /> {dayjs(selectedShift.date).format('MMMM D, YYYY')}
</Text>
<br />
<Text>
<ClockCircleOutlined /> {selectedShift.startTime} - {selectedShift.endTime}
</Text>
<br />
<Text>
<EnvironmentOutlined /> {selectedShift.location}
</Text>
</div>
<Form form={form} layout="vertical" onFinish={handleSignup}>
<Form.Item
name="name"
label="Your Name"
rules={[
{ required: true, message: 'Please enter your name' },
{ min: 2, message: 'Name must be at least 2 characters' }
]}
>
<Input size="large" placeholder="Jane Doe" />
</Form.Item>
<Form.Item
name="email"
label="Email"
rules={[
{ required: true, message: 'Please enter your email' },
{ type: 'email', message: 'Please enter a valid email' }
]}
>
<Input size="large" type="email" placeholder="jane@example.com" />
</Form.Item>
<Form.Item
name="phone"
label="Phone Number"
rules={[
{ required: true, message: 'Please enter your phone number' },
{ pattern: /^\(?([0-9]{3})\)?[-. ]?([0-9]{3})[-. ]?([0-9]{4})$/, message: 'Invalid phone format' }
]}
>
<Input size="large" placeholder="(555) 123-4567" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" size="large" block loading={loading}>
Sign Up
</Button>
</Form.Item>
</Form>
</>
)}
</Modal>
Performance Considerations
- Single API Call: All shifts fetched once on mount
- Optimistic UI: Capacity updates immediately after signup
- Form Reset: Clears fields after successful submission
- Debounced Validation: Email/phone validation on blur, not keystroke
Accessibility
- Keyboard Navigation: All buttons focusable
- Form Labels: Associated with inputs via htmlFor
- Progress Bars: Include sr-only text for screen readers
- Color Contrast: All text meets WCAG AA standards
Troubleshooting
Issue: Phone Validation Failing
Solution:
// Normalize phone input
const normalizePhone = (value: string) => {
const cleaned = value.replace(/\D/g, '');
if (cleaned.length === 10) {
return `(${cleaned.slice(0,3)}) ${cleaned.slice(3,6)}-${cleaned.slice(6)}`;
}
return value;
};
<Form.Item normalize={normalizePhone}>
<Input />
</Form.Item>