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
26 changes: 26 additions & 0 deletions src/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -263,6 +263,10 @@
@apply text-sm text-brand-l leading-relaxed;
}

.card-loading {
@apply opacity-60 pointer-events-none animate-pulse;
}

/* Card Grid System */
.card-grid {
@apply grid gap-6;
Expand Down Expand Up @@ -980,6 +984,28 @@ select:disabled {
}
}

/* Location buttons within service listings */
.location-btn {
background-color: var(--color-brand-q);
border-color: #e5e7eb;
color: var(--color-brand-l);
cursor: pointer;
}

.location-btn:hover {
background-color: #e9e9e9;
border-color: var(--color-brand-f);
}

.location-btn.selected {
border-color: var(--color-brand-a);
color: var(--color-brand-k);
}

.location-btn.selected:hover {
background-color: #e6f5f1;
}

/* Statistics section */
.statistics-value {
font-family: 'Museo Sans', Arial, sans-serif;
Expand Down
7 changes: 5 additions & 2 deletions src/components/FindHelp/GroupedServiceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React from 'react';
import React, { useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useLocation } from '@/contexts/LocationContext';
Expand Down Expand Up @@ -90,10 +90,13 @@ const GroupedServiceCard = React.memo(function GroupedServiceCard({
const shouldTruncate = decodedDescription.length > 100;


const [isLoading, setIsLoading] = useState(false);

return (
<Link
href={destination}
className="card card-compact"
onClick={() => setIsLoading(true)}
className={`card card-compact${isLoading ? ' card-loading' : ''}`}
aria-label={`View details for ${decodedOrgName}`}
>
<div className="flex justify-between items-start mb-2">
Expand Down
60 changes: 38 additions & 22 deletions src/components/FindHelp/ServiceCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useMemo } from 'react';
import React, { useMemo, useState } from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useLocation } from '@/contexts/LocationContext';
Expand Down Expand Up @@ -70,6 +70,12 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle

const distanceText = formatDistance(service.distance);

const is24Hour = service.isOpen247 || (service.openTimes && service.openTimes.some((slot) => {
const startTime = Number(slot.start);
const endTime = Number(slot.end);
return startTime === 0 && endTime === 2359;
}));

return {
destination,
decodedDescription,
Expand All @@ -81,7 +87,8 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle
categoryName,
subCategoryName,
openingStatus,
distanceText
distanceText,
is24Hour
};
}, [service, location, searchParams]);

Expand All @@ -93,21 +100,24 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle
categoryName,
subCategoryName,
openingStatus,
distanceText
distanceText,
is24Hour
} = memoizedData;

const [isLoading, setIsLoading] = useState(false);

return (
<Link
href={destination}
onClick={() => {
// Track service card click for analytics
setIsLoading(true);
trackServiceCardClick(
service.id?.toString() || 'unknown',
decodedOrgName,
categoryName
);
}}
className="card card-compact"
className={`card card-compact${isLoading ? ' card-loading' : ''}`}
aria-label={`View details for ${decodedName}`}
>
<div className="flex justify-between items-start mb-2">
Expand All @@ -132,17 +142,23 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle
</span>
)}

{openingStatus.isOpen && (
<span className="service-tag open">
Open Now
</span>
)}

{/* Appointment Only indicator */}
{openingStatus.isAppointmentOnly && (
<span className="service-tag limited">
Appointment Only
{is24Hour ? (
<span className="service-tag always-open">
Open 24/7
</span>
) : (
<>
{openingStatus.isOpen && (
<span className="service-tag open">
Open Now
</span>
)}
{openingStatus.isAppointmentOnly && (
<span className="service-tag limited">
Appointment Only
</span>
)}
</>
)}
</div>

Expand Down Expand Up @@ -202,7 +218,7 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle
</div>
)}

{service.openTimes && service.openTimes.length > 0 && !service.isOpen247 ? (
{is24Hour ? null : service.openTimes && service.openTimes.length > 0 ? (
<div className="mt-3">
<p className="text-small font-semibold mb-1 !text-black">Opening Times:</p>
<ul className="list-disc pl-5 text-sm !text-black">
Expand All @@ -211,19 +227,19 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle
// Database uses Monday-first indexing: 0=Monday, ..., 6=Sunday
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
const dayGroups = new Map();

const formatTime = (time: number) => {
if (isNaN(time)) return '00:00';
const str = time.toString().padStart(4, '0');
return `${str.slice(0, 2)}:${str.slice(2)}`;
};

// Group slots by day
service.openTimes.forEach((slot) => {
const dayIndex = Number(slot.day);
const startTime = Number(slot.start);
const endTime = Number(slot.end);

if (dayIndex >= 0 && dayIndex <= 6) {
const dayName = days[dayIndex];
if (!dayGroups.has(dayName)) {
Expand All @@ -235,14 +251,14 @@ const ServiceCard = React.memo(function ServiceCard({ service, isOpen, onToggle
});
}
});

// Sort days in proper order and format consolidated times
const orderedDays = days.filter(day => dayGroups.has(day));

return orderedDays.map((dayName) => {
const slots = dayGroups.get(dayName);
const timeRanges = slots.map((slot: { start: string; end: string }) => `${slot.start} – ${slot.end}`).join(', ');

return (
<li key={dayName}>
{dayName}: {timeRanges}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -790,11 +790,7 @@ export default function OrganisationServicesAccordion({
onLocationClick(coordinates[1], coordinates[0]);
}
}}
className={`w-full px-3 py-3 rounded-lg border text-sm transition-colors ${
isSelected
? 'bg-blue-50 border-blue-200 text-blue-800'
: 'bg-gray-50 border-gray-200 text-gray-700 hover:bg-gray-100'
}`}
className={`location-btn w-full px-3 py-3 rounded-lg border text-sm transition-colors${isSelected ? ' selected' : ''}`}
>
<div className="flex items-start gap-2">
<span className="text-xs mt-0.5 flex-shrink-0">📍</span>
Expand Down Expand Up @@ -849,7 +845,7 @@ export default function OrganisationServicesAccordion({
</button>

{isSelected && (
<div className="pl-4 border-l-2 border-blue-200 mt-1 mb-2">
<div className="pl-4 border-l-2 mt-1 mb-2" style={{ borderColor: 'var(--color-brand-a)' }}>
{renderLocationDetails(location, accordionKey)}
</div>
)}
Expand Down
50 changes: 32 additions & 18 deletions tests/__tests__/components/ServiceCard-24-7.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,53 +30,67 @@ describe('ServiceCard 24/7 functionality', () => {
mockUseSearchParams.mockReturnValue(new URLSearchParams() as any);
});

it('should hide opening times for services with isOpen247 flag', () => {
// Update mock service to set isOpen247 flag
it('should hide opening times and show 24/7 pill for services with isOpen247 flag', () => {
const service247 = {
...mock24_7Service,
isOpen247: true
};

render(
<LocationContext.Provider value={mockLocationContextValue}>
<ServiceCard
<ServiceCard
service={service247}
isOpen={false}
onToggle={jest.fn()}
/>
</LocationContext.Provider>
);

// The service name should be visible
expect(screen.getByText('24/7 Crisis Support')).toBeInTheDocument();

// Opening times should NOT be visible for 24/7 services
expect(screen.queryByText('Opening Times:')).not.toBeInTheDocument();

// Should show "No opening times available" instead
expect(screen.getByText('No opening times available')).toBeInTheDocument();
expect(screen.queryByText('No opening times available')).not.toBeInTheDocument();
expect(screen.getByText('Open 24/7')).toBeInTheDocument();
expect(screen.queryByText('Open Now')).not.toBeInTheDocument();
});

it('should detect 24-hour services from opening times with 00:00-23:59 slots', () => {
// mock24_7Service has openTimes with start: 0, end: 2359 but no isOpen247 flag
render(
<LocationContext.Provider value={mockLocationContextValue}>
<ServiceCard
service={mock24_7Service}
isOpen={false}
onToggle={jest.fn()}
/>
</LocationContext.Provider>
);

expect(screen.queryByText('Opening Times:')).not.toBeInTheDocument();
expect(screen.getByText('Open 24/7')).toBeInTheDocument();
expect(screen.queryByText('Open Now')).not.toBeInTheDocument();
});

it('should show opening times for services without 24/7 tags', () => {
it('should show opening times for services with regular hours', () => {
const regularService = {
...mock24_7Service,
organisation: {
...mock24_7Service.organisation,
tags: ['crisis', 'emergency'], // No 24/7 tag
}
isOpen247: false,
openTimes: [
{ day: 1, start: 900, end: 1700 },
{ day: 2, start: 900, end: 1700 },
],
};

render(
<LocationContext.Provider value={mockLocationContextValue}>
<ServiceCard
<ServiceCard
service={regularService}
isOpen={false}
onToggle={jest.fn()}
/>
</LocationContext.Provider>
);

// Opening times should be visible for regular services
expect(screen.getByText('Opening Times:')).toBeInTheDocument();
expect(screen.queryByText('Open 24/7')).not.toBeInTheDocument();
});
});
});
Loading