Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 12 additions & 12 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20

Expand All @@ -38,10 +38,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20

Expand All @@ -56,10 +56,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: ⎔ Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20

Expand All @@ -74,17 +74,17 @@ jobs:

playwright:
name: 🎭 Playwright
runs-on: ubuntu-latest
runs-on: ubuntu-22.04
timeout-minutes: 60
steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: 🏄 Copy test env vars
run: cp .env.example .env

- name: ⎔ Setup node
uses: actions/setup-node@v3
uses: actions/setup-node@v4
with:
node-version: 20

Expand All @@ -99,7 +99,7 @@ jobs:

- name: 🏦 Cache Database
id: db-cache
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: prisma/data.db
key:
Expand All @@ -114,7 +114,7 @@ jobs:
run: npx playwright test

- name: 📊 Upload report
uses: actions/upload-artifact@v3
uses: actions/upload-artifact@v4
if: always()
with:
name: playwright-report
Expand All @@ -132,7 +132,7 @@ jobs:

steps:
- name: ⬇️ Checkout repo
uses: actions/checkout@v3
uses: actions/checkout@v4

- name: 👀 Read app name
uses: SebRollen/toml-action@v1.0.2
Expand Down
27 changes: 15 additions & 12 deletions app/components/EventAgenda.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,37 @@
import { useUser } from '~/utils/user.ts'
import { volunteerTypes, type EventWithVolunteers } from '~/data.ts'

type VolunteerTypes = typeof volunteerTypes
type VolunteerType = VolunteerTypes[number]
import {
volunteerTypes,
getVolunteers,
getVolunteerReq,
type EventWithVolunteers,
type VolunteerTypeEntry,
} from '~/data.ts'

interface PositionStatusProps {
event: EventWithVolunteers
volunteerType: VolunteerType
volunteerType: VolunteerTypeEntry
}

function PositionStatus({ volunteerType, event }: PositionStatusProps) {
const user = useUser()

const positionFilled =
event[volunteerType.field].length >= event[volunteerType.reqField]
const volunteers = getVolunteers(event, volunteerType.field)
const required = getVolunteerReq(event, volunteerType.reqField)
const positionFilled = volunteers.length >= required
const containerClass = `grid grid-cols-2 gap-4 ${
positionFilled ? 'text-muted-foreground' : ''
}`

const userIsRegistered = event[volunteerType.field]
.map(user => user.id)
const userIsRegistered = (volunteers as { id: string }[])
.map(u => u.id)
.includes(user.id)
const volunteerTypeClass = `capitalize ${
userIsRegistered ? 'before:content-["✅"] before:pr-1' : ''
}`

const spotsLeft =
event[volunteerType.reqField] - event[volunteerType.field].length
const spotsLeft = required - volunteers.length

if (event[volunteerType.reqField] > 0)
if (required > 0)
return (
<div className={containerClass}>
<div className={volunteerTypeClass}>{volunteerType.displayName}</div>
Expand Down
80 changes: 70 additions & 10 deletions app/components/sidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef } from 'react'
import { useRef, useState } from 'react'
import { Form, Link, NavLink } from '@remix-run/react'
import { Cat } from 'lucide-react'
import { Cat, Menu, X } from 'lucide-react'
import { Icon } from '~/components/ui/icon.tsx'
import { ThemeSwitch } from '~/routes/resources+/theme/index.tsx'
import { useUser } from '~/utils/user.ts'
Expand All @@ -14,6 +14,7 @@ export function Sidebar({
const user = useUser()
const formRef = useRef<HTMLFormElement>(null)
const userIsAdmin = user?.roles.find(r => r.name === 'admin')
const [mobileOpen, setMobileOpen] = useState(false)

const navLinkClass = ({ isActive }: { isActive: boolean }) =>
`flex items-center gap-3 rounded-md px-3 py-2 text-body-sm font-medium transition-colors ${
Expand All @@ -22,18 +23,30 @@ export function Sidebar({
: 'text-sidebar-foreground hover:bg-sidebar-border'
}`

return (
<aside className="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-sidebar-border bg-sidebar">
const sidebarContent = (
<>
{/* Logo */}
<div className="flex h-16 shrink-0 items-center border-b border-sidebar-border px-5">
<div className="flex h-16 shrink-0 items-center justify-between border-b border-sidebar-border px-5">
<Link to="/" className="flex items-center gap-2">
<span className="text-h6 font-bold text-sidebar-active">The Barn</span>
</Link>
{/* Close button on mobile */}
<button
className="rounded p-1 text-muted-foreground hover:text-sidebar-foreground lg:hidden"
onClick={() => setMobileOpen(false)}
aria-label="Close menu"
>
<X className="h-5 w-5" />
</button>
</div>

{/* Navigation */}
<nav className="flex flex-1 flex-col gap-1 overflow-y-auto px-3 py-4">
<NavLink to="/calendar" className={navLinkClass}>
<NavLink
to="/calendar"
className={navLinkClass}
onClick={() => setMobileOpen(false)}
>
<Icon name="calendar" className="h-4 w-4 shrink-0" />
Calendar
</NavLink>
Expand All @@ -43,15 +56,27 @@ export function Sidebar({
<p className="mt-5 px-3 pb-1 text-body-2xs font-semibold uppercase tracking-wider text-muted-foreground">
Admin
</p>
<NavLink to="/admin/users" className={navLinkClass}>
<NavLink
to="/admin/users"
className={navLinkClass}
onClick={() => setMobileOpen(false)}
>
<Icon name="person" className="h-4 w-4 shrink-0" />
Users
</NavLink>
<NavLink to="/admin/animals" className={navLinkClass}>
<NavLink
to="/admin/animals"
className={navLinkClass}
onClick={() => setMobileOpen(false)}
>
<Cat className="h-4 w-4 shrink-0" />
Animals
</NavLink>
<NavLink to="/admin/email" className={navLinkClass}>
<NavLink
to="/admin/email"
className={navLinkClass}
onClick={() => setMobileOpen(false)}
>
<Icon name="email" className="h-4 w-4 shrink-0" />
Email
</NavLink>
Expand Down Expand Up @@ -93,6 +118,41 @@ export function Sidebar({
</Form>
</div>
</div>
</aside>
</>
)

return (
<>
{/* Mobile hamburger button */}
<button
className="fixed left-4 top-4 z-50 rounded-md border border-border bg-background p-2 shadow-sm lg:hidden"
onClick={() => setMobileOpen(true)}
aria-label="Open menu"
>
<Menu className="h-5 w-5" />
</button>

{/* Mobile overlay */}
{mobileOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setMobileOpen(false)}
/>
)}

{/* Mobile sidebar drawer */}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-60 flex-col border-r border-sidebar-border bg-sidebar transition-transform duration-200 lg:hidden ${
mobileOpen ? 'translate-x-0' : '-translate-x-full'
}`}
>
{sidebarContent}
</aside>

{/* Desktop sidebar (always visible) */}
<aside className="fixed inset-y-0 left-0 z-40 hidden w-60 flex-col border-r border-sidebar-border bg-sidebar lg:flex">
{sidebarContent}
</aside>
</>
)
}
94 changes: 65 additions & 29 deletions app/data.ts
Original file line number Diff line number Diff line change
@@ -1,42 +1,64 @@
import { Prisma } from '@prisma/client'
import { getRoleLabels, getRoleDescriptions } from '~/utils/role-labels.ts'

export const siteName = 'The Barn Volunteer Portal'
export const siteEmailAddress = 'hello@thebarnaz.com'
export const siteEmailAddressWithName =
siteName + ' <hello@thebarnaz.com>'
export const siteBaseUrl = 'https://thebarnaz.com'

export const volunteerTypes = [
{
displayName: 'cleaning crew',
field: 'cleaningCrew',
reqField: 'cleaningCrewReq',
description:
'Cleaning crew volunteers help maintain the facility, check waterers, sweep common areas, and handle other miscellaneous cleaning tasks. No prior experience with animals is required.',
},
{
displayName: 'side walkers',
field: 'sideWalkers',
reqField: 'sideWalkersReq',
description:
'Side walkers walk alongside participants helping to support them during sessions. No prior experience with animals needed. Must be able to walk on uneven surfaces.',
},
{
displayName: 'lesson assistants',
field: 'lessonAssistants',
reqField: 'lessonAssistantsReq',
description:
'Lesson assistants should have 1+ years of experience with the animals. They assist instructors and communicate effectively with both participants and staff.',
},
{
displayName: 'animal handlers',
field: 'animalHandlers',
reqField: 'animalHandlersReq',
description:
'Animal handlers guide and manage animals during sessions. Should have 1+ years of experience with animals, and must be able to walk on uneven surfaces.',
},
/** Volunteer role field names — stable identifiers used in schema and forms */
export const volunteerFields = [
'cleaningCrew',
'sideWalkers',
'lessonAssistants',
'animalHandlers',
] as const

export type VolunteerField = (typeof volunteerFields)[number]

export const volunteerReqFields = {
cleaningCrew: 'cleaningCrewReq',
sideWalkers: 'sideWalkersReq',
lessonAssistants: 'lessonAssistantsReq',
animalHandlers: 'animalHandlersReq',
} as const

export type VolunteerReqField =
(typeof volunteerReqFields)[VolunteerField]

/**
* Returns volunteer type metadata with display names and descriptions
* adapted to the organization's animal type. Falls back to generic labels
* when no animalType is provided.
*/
export function getVolunteerTypes(animalType?: string | null) {
const labels = getRoleLabels(animalType)
const descriptions = getRoleDescriptions(animalType)

return volunteerFields.map(field => ({
displayName: labels[field],
field,
reqField: volunteerReqFields[field],
description: descriptions[field],
}))
}

/** Type-safe volunteer type entry for use in components that index into events */
export interface VolunteerTypeEntry {
displayName: string
field: VolunteerField
reqField: VolunteerReqField
description: string
}

/**
* Default volunteer types using horse-specific labels for backward
* compatibility with the existing equestrian customer. Use
* getVolunteerTypes(animalType) when org context is available.
*/
export const volunteerTypes: VolunteerTypeEntry[] = getVolunteerTypes()

export interface UserData {
id: string
name: string | null
Expand Down Expand Up @@ -85,6 +107,20 @@ export interface CalEvent {
sideWalkers: UserData[]
}

/** Safely index into an event by volunteer field name */
export function getVolunteers<
T extends Record<VolunteerField, unknown[]>,
>(event: T, field: VolunteerField): T[VolunteerField] {
return event[field]
}

/** Safely index into an event by volunteer req field name */
export function getVolunteerReq<
T extends Record<VolunteerReqField, number>,
>(event: T, reqField: VolunteerReqField): number {
return event[reqField]
}

const EventWithAllRelations = Prisma.validator<Prisma.EventArgs>()({
include: {
animals: true,
Expand Down
2 changes: 1 addition & 1 deletion app/root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ function App() {
// Authenticated: sidebar layout
<div className="flex h-screen overflow-hidden">
<Sidebar userPreference={data.requestInfo.session.theme} />
<main className="ml-60 flex-1 overflow-y-auto">
<main className="flex-1 overflow-y-auto pt-14 lg:ml-60 lg:pt-0">
<Outlet />
</main>
</div>
Expand Down
Loading
Loading