477 lines
12 KiB
Markdown

# 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
1. User navigates to `/shifts`
2. Hero banner loads with CTA
3. API fetches active shifts
4. Shift cards render in grid
5. User sees capacity bars (green/yellow/red)
6. User scrolls through available shifts
### Signing Up for Shift
1. User finds desired shift card
2. User clicks "Sign Up" button
3. Modal opens with signup form
4. User enters name: "Jane Doe"
5. User enters email: "jane@example.com"
6. User enters phone: "(555) 123-4567"
7. User clicks "Sign Up" submit button
8. API creates temp user (role: TEMP)
9. API creates shift signup
10. Confirmation email sent
11. Success modal displays
12. User clicks "OK"
13. Modal closes
14. Shift list refreshes
15. Signed-up shift shows updated capacity
### Full Shift Handling
1. User sees shift with red progress bar (full)
2. Card has reduced opacity
3. Button shows "Full" and is disabled
4. User cannot click signup
5. "Full" badge visible on card
---
## Component Structure
```tsx
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
```tsx
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
```http
GET /api/public/map/shifts
```
Response:
```json
[
{
"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
```http
POST /api/public/map/shifts/:id/signup
Content-Type: application/json
{
"name": "Jane Doe",
"email": "jane@example.com",
"phone": "(555) 123-4567"
}
```
Response:
```json
{
"success": true,
"signupId": "cm2def456",
"message": "Successfully signed up! Confirmation email sent."
}
```
---
## Code Examples
### Shift Card with Capacity Bar
```tsx
{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
```tsx
<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
1. **Single API Call**: All shifts fetched once on mount
2. **Optimistic UI**: Capacity updates immediately after signup
3. **Form Reset**: Clears fields after successful submission
4. **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:**
```tsx
// 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>
```
---
## Related Documentation
- [Admin Shifts Page](../admin/shifts-page.md)
- [Volunteer Shifts Page](../volunteer/volunteer-shifts-page.md)
- [Shift Signups API](../../../api/modules/map/shifts-routes.md)