mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-22 23:16:46 +00:00
feat(planner): reach a hotel's booking from the plan, not only its place
A booked stay's pill in the day head gets a second target that opens the booking; the name still opens the place. The place panel of a hotel, on the desktop and on the phone, lists the stay booked for it next to the bookings pinned to the stop. (#2363)
This commit is contained in:
@@ -4338,6 +4338,44 @@ describe('DayPlanSidebar', () => {
|
||||
expect(onPlaceClick).toHaveBeenCalledWith(8)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-176b: a booked stay opens its booking from the pill, the name still opens the place (#2363)', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [
|
||||
buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }),
|
||||
buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' }),
|
||||
]
|
||||
const accommodations: Accommodation[] = [
|
||||
{ id: 3, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 9, place_lat: 51.5, place_lng: -0.12, place_name: 'Booked Hotel' },
|
||||
{ id: 4, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 8, place_lat: 51.5, place_lng: -0.12, place_name: 'Unbooked Hotel' },
|
||||
]
|
||||
const stay = buildReservation({ id: 610, type: 'hotel', title: 'Booked Hotel stay', accommodation_id: 3 } as any)
|
||||
const onPlaceClick = vi.fn()
|
||||
const onOpenBooking = vi.fn()
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, accommodations, reservations: [stay], onPlaceClick, onOpenBooking })} />)
|
||||
|
||||
const header = dayHeader('Day 1')
|
||||
const bookingButtons = header.querySelectorAll('[data-dp="day-pill-booking"]')
|
||||
// Only the stay that has a booking gets the second target.
|
||||
expect(bookingButtons).toHaveLength(1)
|
||||
expect(bookingButtons[0]).toHaveAccessibleName('Open booking')
|
||||
await user.click(bookingButtons[0] as HTMLElement)
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(stay)
|
||||
expect(onPlaceClick).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(within(header).getByText('Booked Hotel'))
|
||||
expect(onPlaceClick).toHaveBeenCalledWith(9)
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-176c: without a booking detail to open, the stay pill has no booking button', () => {
|
||||
const days = [buildDay({ id: 10, date: '2025-06-01', title: 'Day 1' }), buildDay({ id: 11, date: '2025-06-02', title: 'Day 2' })]
|
||||
const accommodations: Accommodation[] = [
|
||||
{ id: 3, trip_id: 1, start_day_id: 10, end_day_id: 11, place_id: 9, place_lat: 51.5, place_lng: -0.12, place_name: 'Booked Hotel' },
|
||||
]
|
||||
const stay = buildReservation({ id: 610, type: 'hotel', title: 'Booked Hotel stay', accommodation_id: 3 } as any)
|
||||
render(<DayPlanSidebar {...makeDefaultProps({ days, accommodations, reservations: [stay] })} />)
|
||||
expect(dayHeader('Day 1').querySelector('[data-dp="day-pill-booking"]')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-PLANNER-DAYPLAN-177: clicking an active rental badge opens its booking detail', async () => {
|
||||
const user = userEvent.setup()
|
||||
const days = [
|
||||
|
||||
@@ -1898,16 +1898,32 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar(props: DayPlanSidebarP
|
||||
const pending = pendingStays.has(acc.id)
|
||||
const named = state ? `${state}: ${accName}` : accName
|
||||
const tip = pending ? `${named} (${t('reservations.pending')})` : named
|
||||
// The name opens the place, as before; a stay with a booking gets a
|
||||
// second target in the same pill that opens the booking (#2363).
|
||||
const stayBooking = onOpenBooking
|
||||
? reservations.find(r => r.accommodation_id != null && Number(r.accommodation_id) === acc.id)
|
||||
: undefined
|
||||
return (
|
||||
<Tooltip key={acc.id} label={tip}>
|
||||
<button type="button" data-dp="day-pill" data-pending={pending || undefined}
|
||||
onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }}
|
||||
className={`inline-flex min-w-0 max-w-full items-center gap-1 rounded-full px-2 py-[2px] font-geist font-normal text-content-secondary hover:text-content ${pending ? 'border border-dashed border-warning bg-warning-soft' : 'bg-surface-card shadow-sm'}`}
|
||||
style={{ ...fs(10.5), cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
|
||||
<Hotel size={11} strokeWidth={2} className={`flex-none ${iconTone}`} />
|
||||
<span className="truncate">{accName}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
<span key={acc.id} className={`inline-flex min-w-0 max-w-full items-stretch overflow-hidden rounded-full ${pending ? 'border border-dashed border-warning bg-warning-soft' : 'bg-surface-card shadow-sm'}`}>
|
||||
<Tooltip label={tip}>
|
||||
<button type="button" data-dp="day-pill" data-pending={pending || undefined}
|
||||
onClick={e => { e.stopPropagation(); if ((acc as any).place_id) onPlaceClick((acc as any).place_id) }}
|
||||
className={`inline-flex min-w-0 items-center gap-1 py-[2px] font-geist font-normal text-content-secondary hover:text-content ${stayBooking ? 'pl-2 pr-1.5' : 'px-2'}`}
|
||||
style={{ ...fs(10.5), cursor: (acc as any).place_id ? 'pointer' : 'default' }}>
|
||||
<Hotel size={11} strokeWidth={2} className={`flex-none ${iconTone}`} />
|
||||
<span className="truncate">{accName}</span>
|
||||
</button>
|
||||
</Tooltip>
|
||||
{stayBooking && (
|
||||
<Tooltip label={t('day.openStayBooking')}>
|
||||
<button type="button" data-dp="day-pill-booking" aria-label={t('day.openStayBooking')}
|
||||
onClick={e => { e.stopPropagation(); onOpenBooking!(stayBooking) }}
|
||||
className="flex flex-none items-center border-l border-edge-faint pl-1.5 pr-2 text-content-faint hover:text-content">
|
||||
<Ticket size={11} strokeWidth={2} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
{/* Active rental car badges */}
|
||||
|
||||
@@ -430,6 +430,20 @@ describe('PlaceInspector', () => {
|
||||
expect(screen.getByText('Museum Ticket')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030h: a hotel shows the stay booked for it and opens that booking (#2363)', () => {
|
||||
const onOpenBooking = vi.fn();
|
||||
const stay = buildReservation({
|
||||
id: 540, title: 'Hotel Adlon, 3 nights', status: 'confirmed', type: 'hotel', assignment_id: null,
|
||||
accommodation_place_id: place.id,
|
||||
} as any);
|
||||
const elsewhere = buildReservation({ id: 541, title: 'Other hotel', type: 'hotel', accommodation_place_id: place.id + 1 } as any);
|
||||
render(<PlaceInspector {...defaultProps} reservations={[stay, elsewhere]} onOpenBooking={onOpenBooking} />);
|
||||
|
||||
expect(screen.queryByText('Other hotel')).toBeNull();
|
||||
fireEvent.click(screen.getByText('Hotel Adlon, 3 nights').closest('[role="button"]') as HTMLElement);
|
||||
expect(onOpenBooking).toHaveBeenCalledWith(stay);
|
||||
});
|
||||
|
||||
it('FE-PLANNER-INSPECTOR-030g: every booking on the stop gets its own strip (#2201)', () => {
|
||||
const onEditReservation = vi.fn();
|
||||
const parking = buildReservation({
|
||||
|
||||
@@ -34,7 +34,7 @@ import { useTripStore } from '../../store/tripStore'
|
||||
import { useCanDo } from '../../store/permissionsStore'
|
||||
import { formatDistance, formatElevation } from '../../utils/units'
|
||||
import { navigationTargetLabel, getNavigationTargets, openNavigationTarget } from './placeNavigation'
|
||||
import { TRANSPORT_TYPES, getAssignmentReservations } from '../../utils/dayMerge'
|
||||
import { TRANSPORT_TYPES, getPlaceBookings } from '../../utils/dayMerge'
|
||||
import { NavigationMenu } from '../shared/NavigationMenu'
|
||||
import { resolveOpenNow, resolvePlaceTimeZone, placeWeekdayIndex, type OpeningPeriod } from './placeOpenState'
|
||||
import { convertHoursLine } from './placeHoursFormat'
|
||||
@@ -479,7 +479,7 @@ export default function PlaceInspector({
|
||||
|
||||
{/* Bookings + participants — trip-only (collections have no days) */}
|
||||
{mode === 'trip' && (
|
||||
<PlaceBookingsAndPeople selectedAssignmentId={selectedAssignmentId} reservations={reservations}
|
||||
<PlaceBookingsAndPeople selectedAssignmentId={selectedAssignmentId} placeId={place.id} reservations={reservations}
|
||||
assignments={assignments} selectedDayId={selectedDayId} tripMembers={tripMembers}
|
||||
timeFormat={timeFormat} onSetParticipants={onSetParticipants}
|
||||
onEditTransport={onEditTransport} onEditReservation={onEditReservation} onOpenBooking={onOpenBooking} />
|
||||
@@ -758,6 +758,7 @@ function InspectorHead({ place, category, openNow, phone, rating, ratingCount, p
|
||||
|
||||
interface PlaceBookingsAndPeopleProps {
|
||||
selectedAssignmentId: number | null
|
||||
placeId: number
|
||||
reservations: Reservation[]
|
||||
assignments: AssignmentsMap
|
||||
selectedDayId: number | null
|
||||
@@ -770,10 +771,10 @@ interface PlaceBookingsAndPeopleProps {
|
||||
}
|
||||
|
||||
/** The bookings pinned to this stop and who joins it, side by side from sm up. */
|
||||
function PlaceBookingsAndPeople({ selectedAssignmentId, reservations, assignments, selectedDayId,
|
||||
function PlaceBookingsAndPeople({ selectedAssignmentId, placeId, reservations, assignments, selectedDayId,
|
||||
tripMembers, timeFormat, onSetParticipants, onEditTransport, onEditReservation, onOpenBooking }: PlaceBookingsAndPeopleProps) {
|
||||
const { t } = useTranslation()
|
||||
const linked = getAssignmentReservations<Reservation>(reservations, selectedAssignmentId)
|
||||
const linked = getPlaceBookings<Reservation>(reservations, selectedAssignmentId, placeId)
|
||||
const assignment = selectedAssignmentId ? (assignments[String(selectedDayId)] || []).find(a => a.id === selectedAssignmentId) : null
|
||||
const currentParticipants = assignment?.participants || []
|
||||
const participantIds = currentParticipants.map(p => p.user_id)
|
||||
|
||||
Reference in New Issue
Block a user