diff --git a/src/components/availability/availability-actions.tsx b/src/components/availability/availability-actions.tsx index b00c0f94a..704a08dc3 100644 --- a/src/components/availability/availability-actions.tsx +++ b/src/components/availability/availability-actions.tsx @@ -202,7 +202,7 @@ export function AvailabilityActions({ : handleScheduleCancel } > - Cancel + Cancel ) : (
{isScheduled && ( - +
+ )} +
+ - )} - - +
{isOwner && ( - <> +
- +
)} )} diff --git a/src/components/availability/availability.tsx b/src/components/availability/availability.tsx index 5321b79ae..b3e3c3f5c 100644 --- a/src/components/availability/availability.tsx +++ b/src/components/availability/availability.tsx @@ -1,6 +1,7 @@ "use client"; import { Paper } from "@mui/material"; +import { useRouter } from "next/navigation"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useShallow } from "zustand/shallow"; import { AvailabilityActions } from "@/components/availability/availability-actions"; @@ -27,7 +28,9 @@ import { import type { MemberMeetingAvailability } from "@/lib/types/availability"; import type { HourMinuteString } from "@/lib/types/chrono"; import { useAvailabilityStore } from "@/store/useAvailabilityStore"; +import { MobilePersonalAvailabilitySidebar } from "../nav/mobile-personal-availability"; import { PersonalAvailabilitySidebar } from "../nav/personal-availability-sidebar"; +import { MobileGroupResponses } from "./mobile-group-responses"; export function Availability({ meetingData, @@ -48,10 +51,11 @@ export function Availability({ // View + paint mode live in the store (paint mode is reset atomically in // `setAvailabilityView`, so it cannot drift across view switches). - const { availabilityView, paintMode } = useAvailabilityStore( + const { availabilityView, paintMode, setPaintMode } = useAvailabilityStore( useShallow((state) => ({ availabilityView: state.availabilityView, paintMode: state.paintMode, + setPaintMode: state.setPaintMode, })), ); @@ -84,6 +88,15 @@ export function Availability({ })), ); + const { setAvailabilityView, setIsMobileDrawerOpen } = useAvailabilityStore( + useShallow((state) => ({ + setAvailabilityView: state.setAvailabilityView, + setIsMobileDrawerOpen: state.setIsMobileDrawerOpen, + })), + ); + + const router = useRouter(); + const isMobile = useIsMobile(); useEffect(() => { setItemsPerPage(isMobile ? 2 : 5); @@ -254,6 +267,51 @@ export function Availability({ onOpenInviteDialog: handleOpenInviteDialog, } as const; + const isMeetingOwner = Boolean(user && meetingData.hostId === user.memberId); + + const groupResponsesProps = useMemo( + () => ({ + availabilityDates, + fromTime: fromTimeMinutes, + members, + pendingMembers, + timezone: userTimezone, + anchorNormalizedDate, + currentPageAvailability, + availabilityTimeBlocks, + doesntNeedDay, + }), + [ + availabilityDates, + fromTimeMinutes, + members, + pendingMembers, + userTimezone, + anchorNormalizedDate, + currentPageAvailability, + availabilityTimeBlocks, + doesntNeedDay, + ], + ); + + const handleMobileAddAvailability = useCallback(() => { + if (!user) { + router.push("/auth/login/google"); + return; + } + setChangeableTimezone(false); + setUserTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); + setAvailabilityView("personal"); + }, [router, setAvailabilityView, user]); + + const handleMobileOpenAttendees = useCallback(() => { + setIsMobileDrawerOpen(true); + }, [setIsMobileDrawerOpen]); + + const handleMobileSchedule = useCallback(() => { + setAvailabilityView("schedule"); + }, [setAvailabilityView]); + return (
{(availabilityView === "group" || availabilityView === "schedule") && ( -
- - - +
+ + + + +
+ +
+ +
+ +
+ - +
)} {availabilityView === "personal" && ( -
- - - +
+ + + + +
+ +
+ + setPaintMode( + typeof next === "function" ? next(paintMode) : next, + ) + } importGridIsoSet={importGridIsoSet} canImport={Boolean(user?.memberId)} onImportSlots={handleImportSlotsFromMeeting} - onClearAvailability={handleClearAvailability} /> - +
)}
diff --git a/src/components/availability/mobile-group-responses.tsx b/src/components/availability/mobile-group-responses.tsx new file mode 100644 index 000000000..b12fd5aa0 --- /dev/null +++ b/src/components/availability/mobile-group-responses.tsx @@ -0,0 +1,81 @@ +"use client"; + +import { + CalendarMonthOutlined, + EditCalendarOutlined, + PeopleAltOutlined, +} from "@mui/icons-material"; +import { Button, Divider, Paper, Stack, Typography } from "@mui/material"; + +const actionButtonSx = { + flex: 1, + minWidth: 0, + px: 2, + py: 1.25, + borderRadius: 2, +}; + +export interface MobileGroupResponsesProps { + isOwner: boolean; + respondedMembersCount: number; + pendingMembersCount: number; + onAddAvailability: () => void; + onOpenAttendees: () => void; + onSchedule: () => void; +} + +export function MobileGroupResponses({ + isOwner, + respondedMembersCount, + pendingMembersCount, + onAddAvailability, + onOpenAttendees, + onSchedule, +}: MobileGroupResponsesProps) { + return ( +
+ + + + + + + + {isOwner && ( + <> + + + + )} + +
+ ); +} diff --git a/src/components/nav/mobile-personal-availability.tsx b/src/components/nav/mobile-personal-availability.tsx new file mode 100644 index 000000000..c6a24e5c4 --- /dev/null +++ b/src/components/nav/mobile-personal-availability.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + Paper, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import type { Dispatch, SetStateAction } from "react"; +import type { PaintMode } from "@/lib/availability/paint-selection"; +import { PERSONAL_AVAILABILITY_OPTIONS } from "./personal-availability-options"; + +export interface MobilePersonalAvailabilitySidebarProps { + meetingId: string; + userTimezone: string; + availability: PaintMode; + setAvailability: Dispatch>; + importGridIsoSet: ReadonlySet; + canImport: boolean; + onImportSlots: (slots: { + meetingAvailabilities: string[]; + ifNeededAvailabilities: string[]; + }) => void; +} + +export function MobilePersonalAvailabilitySidebar({ + availability, + setAvailability, +}: MobilePersonalAvailabilitySidebarProps) { + return ( +
+ + val && setAvailability(val)} + aria-label="availability" + > + {PERSONAL_AVAILABILITY_OPTIONS.map(({ value, label, icon }) => ( + ({ + display: "flex", + flexDirection: "column", + gap: 1, + px: 1.5, + py: 1.25, + "&.Mui-selected": { + backgroundColor: alpha( + theme.palette.primary.main, + theme.palette.mode === "dark" ? 0.2 : 0.12, + ), + borderColor: theme.palette.primary.main, + }, + })} + > + {icon} + {label} + + ))} + + +
+ ); +} diff --git a/src/components/nav/mui-app-shell.tsx b/src/components/nav/mui-app-shell.tsx index 7912c59c6..dc9a0b7b6 100644 --- a/src/components/nav/mui-app-shell.tsx +++ b/src/components/nav/mui-app-shell.tsx @@ -1,6 +1,7 @@ "use client"; import { Box, useMediaQuery, useTheme } from "@mui/material"; +import { usePathname } from "next/navigation"; import type { NotificationItem, UserProfile } from "@/lib/auth/user"; import { MuiBottomNav } from "./mui-bottom-nav"; import { MuiTopNav } from "./mui-top-nav"; @@ -18,6 +19,7 @@ export function MuiAppShell({ }: MuiAppShellProps) { const theme = useTheme(); const isMobile = useMediaQuery(theme.breakpoints.down("md")); + const availPath = usePathname().startsWith("/availability"); return ( {children} - {isMobile && } + {isMobile && !availPath && } ); } diff --git a/src/components/nav/personal-availability-options.tsx b/src/components/nav/personal-availability-options.tsx new file mode 100644 index 000000000..1e3e729eb --- /dev/null +++ b/src/components/nav/personal-availability-options.tsx @@ -0,0 +1,54 @@ +import type React from "react"; +import type { PaintMode } from "@/lib/availability/paint-selection"; + +const SWATCH_DIMENSION_STYLE = { + width: 20, + height: 20, + borderRadius: "50%", + boxSizing: "border-box" as const, +}; + +export const PERSONAL_AVAILABILITY_OPTIONS: { + value: PaintMode; + label: string; + icon: React.ReactNode; +}[] = [ + { + value: "available", + label: "Available", + icon: ( +
+ ), + }, + { + value: "if-needed", + label: "If Needed", + icon: ( +
+ ), + }, + { + value: "unavailable", + label: "Unavailable", + icon: ( +
+ ), + }, +];