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

  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

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

  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:

// 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>