477 lines
12 KiB
Markdown
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)
|