diff --git a/src/components/DaySelect/time_select_component/time_select_component.tsx b/src/components/DaySelect/time_select_component/time_select_component.tsx index 4578b10b..ec11e67e 100644 --- a/src/components/DaySelect/time_select_component/time_select_component.tsx +++ b/src/components/DaySelect/time_select_component/time_select_component.tsx @@ -55,7 +55,7 @@ export const TimeSelectComponent = (props: any) => { return (

diff --git a/src/components/GroupView/GroupViewPage.tsx b/src/components/GroupView/GroupViewPage.tsx index 77edd6e5..bc0c4499 100644 --- a/src/components/GroupView/GroupViewPage.tsx +++ b/src/components/GroupView/GroupViewPage.tsx @@ -164,6 +164,20 @@ export default function GroupViewPage({ [createCalendarEventUrl] ); + const today = new Date(); + const getNextDayOccurrence = (targetDayNum: number): Date => { + const date = new Date(today); + const currentDayNum = today.getDay(); + + let daysToAdd = targetDayNum - currentDayNum; + if (daysToAdd <= 0) { + daysToAdd += 7; + } + + date.setDate(date.getDate() + daysToAdd); + return date; + }; + async function handleSelectionSubmission() { if (!dragState.endPoint || !dragState.startPoint) { setAlertMessage('No new time selection made!'); @@ -196,11 +210,17 @@ export default function GroupViewPage({ ? selectedEndTimeHHMM.split(':').map(Number) : [0, 0]; - const selectedStartTimeDateObject = new Date(calDate?.date!); + let selectedStartTimeDateObject = new Date(calDate?.date!); + if (selectedStartTimeDateObject.getFullYear() === 2000) { + selectedStartTimeDateObject = getNextDayOccurrence(selectedStartTimeDateObject.getDay()); + } selectedStartTimeDateObject.setHours(startHour); selectedStartTimeDateObject.setMinutes(startMinute); - const selectedEndTimeDateObject = new Date(calDate?.date!); + let selectedEndTimeDateObject = new Date(calDate?.date!); + if (selectedEndTimeDateObject.getFullYear() === 2000) { + selectedEndTimeDateObject = getNextDayOccurrence(selectedEndTimeDateObject.getDay()); + } endMinute += 15; if (endMinute == 60) { @@ -378,9 +398,7 @@ export default function GroupViewPage({ ? 'View Availabilities' : 'Edit Your Availability'} - {isAdmin && - calendarFramework?.dates?.[0][0].date instanceof Date && - (calendarFramework.dates[0][0].date as Date).getFullYear() !== 2000 && ( + {isAdmin && ( )}

@@ -494,10 +512,7 @@ export default function GroupViewPage({ theCalendarFramework={[calendarFramework, setCalendarFramework]} chartedUsersData={[filteredChartedUsers, setChartedUsers]} draggable={ - isAdmin && - calendarFramework?.dates?.[0][0].date instanceof Date && - (calendarFramework.dates[0][0].date as Date).getFullYear() !== - 2000 + isAdmin } user={getCurrentUserIndex()} isAdmin={isAdmin} diff --git a/src/components/Home/HomePage.tsx b/src/components/Home/HomePage.tsx index 5b5c8557..164dfd1e 100644 --- a/src/components/Home/HomePage.tsx +++ b/src/components/Home/HomePage.tsx @@ -7,6 +7,7 @@ import graphic from './calendargraphic.png'; import LoginPopup from '../utils/components/LoginPopup'; import Footer from '../utils/components/Footer'; import Button from '../utils/components/Button'; +import TutorialModal from '../utils/components/TutorialModal/TutorialModal'; // import { SiGooglecalendar } from 'react-icons/si'; // import { FaLock } from 'react-icons/fa'; @@ -72,10 +73,19 @@ export default function HomePage() { } }; + const [showTutorial, setTutorial] = React.useState(false); + return (
+ {showTutorial && ( + setTutorial(false)} + /> + )} +
@@ -127,6 +137,16 @@ export default function HomePage() { View My Events
+ +

+ New to ymeets? → + setTutorial(true)}> + Click here for a quick walkthrough! + +

+
diff --git a/src/components/TimeSelect/TimeSelectPage.tsx b/src/components/TimeSelect/TimeSelectPage.tsx index adb0e8ab..dfa4fec6 100644 --- a/src/components/TimeSelect/TimeSelectPage.tsx +++ b/src/components/TimeSelect/TimeSelectPage.tsx @@ -198,6 +198,10 @@ function TimeSelectPage({ useEffect(() => { if (!isGeneralDays) return; + if (calendarFramework.dates.length === 0) return; + + const startDate = calendarFramework.dates[0]?.[0]?.date; + if (!startDate || startDate.getFullYear() !== 2000) return; // you need to injet dates into each column so later on const today = new Date(); @@ -244,7 +248,7 @@ function TimeSelectPage({ ...prev, dates: updatedDates, })); - }, [isGeneralDays]); + }, [isGeneralDays, calendarFramework.dates]); const fetchGoogleCalEvents = async ( calIds: string[] @@ -252,13 +256,15 @@ function TimeSelectPage({ if (!hasAccess || calIds.length === 0) return []; const dates = calendarFramework.dates.flat(); - const timeMin = dates[0]?.date?.toISOString() ?? new Date().toISOString(); - const timeMax = new Date(dates[dates.length - 1].date as Date).setUTCHours( - 23, - 59, - 59, - 999 - ); + const dateTimestamps = dates.map((d) => (d.date as Date).getTime()); + + const startDate = new Date(Math.min(...dateTimestamps)); + startDate.setHours(0, 0, 0, 0); + const timeMin = startDate.toISOString(); + + const endDate = new Date(Math.max(...dateTimestamps)); + endDate.setHours(23, 59, 59, 999); + const timeMax = endDate.toISOString(); const allEvents: calendar_v3.Schema$Event[] = []; @@ -266,7 +272,7 @@ function TimeSelectPage({ const events = await getEvents( calId, timeMin, - new Date(timeMax).toISOString(), + timeMax, calendarFramework.timezone ); @@ -295,6 +301,7 @@ function TimeSelectPage({ hasAccess, shouldFillAvailability, calendarFramework.timezone, + calendarFramework.dates, ]); // Fetch the user's Google Calendars diff --git a/src/components/navbar/NavBar.tsx b/src/components/navbar/NavBar.tsx index b78509cc..d3d5c6e7 100644 --- a/src/components/navbar/NavBar.tsx +++ b/src/components/navbar/NavBar.tsx @@ -5,6 +5,7 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { onAuthStateChanged } from 'firebase/auth'; import { auth } from '../../backend/firebase'; +import TutorialModal from '../utils/components/TutorialModal/TutorialModal'; import { IconMenu2, @@ -19,6 +20,7 @@ import { IconMoonFilled, IconMoon, IconSun, + IconBook, } from '@tabler/icons-react'; import { useAuth } from '../../backend/authContext'; import { useTheme } from '../../contexts/ThemeContext'; @@ -34,6 +36,8 @@ export default function NavBar() { const { login, logout, currentUser } = useAuth(); + const [showTutorial, setTutorial] = useState(false); + const handleMouseLeave = () => { setIsOpen(false); }; @@ -82,6 +86,13 @@ export default function NavBar() { return ( <> + {showTutorial && ( + setTutorial(false)} + /> + )} +
@@ -148,6 +159,20 @@ export default function NavBar() { aria-orientation="vertical" aria-labelledby="options-menu" > + { + e.preventDefault(); + setTutorial(true); + setMenuState('closed'); + }} + > + Tutorial + + +
+ void; +} + +interface TutorialSlide { + id: string; + title: string; + description: string; + media: { type: 'video' | 'image'; src: string; alt: string }; +} + +const tutorialSlides: TutorialSlide[] = [ + { + id: 'welcome', + title: 'Welcome to ymeets', + description: + 'The easiest way to find a time that works for everyone. Let us show you how it works.', + media: { type: 'video', src: stock_meeting_gif, alt: 'ymeets overview' }, + }, + { + id: 'create', + title: 'Create Your Event', + description: + 'Name your event, set the timezone, and pick your dates. Choose specific calendar days or recurring days of the week.', + media: { type: 'image', src: day_select_pic, alt: 'Date selection' }, + }, + { + id: 'share', + title: 'Share and Invite', + description: + 'Copy your event link to share. Participants can add availability manually or sync directly from Google Calendar.', + media: { type: 'video', src: copy_link_gif, alt: 'Sharing event' }, + }, + { + id: 'schedule', + title: 'Find the Perfect Time', + description: + 'See when everyone is free at a glance. Hover over names for details, then export your meeting to Google Calendar.', + media: { type: 'video', src: group_view_vid, alt: 'Viewing availability' }, + }, + { + id: 'done', + title: "You're All Set!", + description: + 'Access all your events from "My Events" in the navigation. Happy scheduling!', + media: { type: 'video', src: delete_vid, alt: 'My Events' }, + }, +]; + +export default function TutorialModal({ isOpen, onClose }: TutorialProps) { + const [isVisible, setIsVisible] = useState(false); + const [isAnimating, setIsAnimating] = useState(false); + const [currentSlide, setCurrentSlide] = useState(0); + const [mediaLoaded, setMediaLoaded] = useState(false); + const [isTransitioning, setIsTransitioning] = useState(false); + + const totalSlides = tutorialSlides.length; + const isFirstSlide = currentSlide === 0; + const isLastSlide = currentSlide === totalSlides - 1; + const currentSlideData = tutorialSlides[currentSlide]; + + useEffect(() => { + if (isOpen) { + setIsVisible(true); + setCurrentSlide(0); + setMediaLoaded(false); + setIsTransitioning(false); + document.body.style.overflow = 'hidden'; + setTimeout(() => setIsAnimating(true), 10); + } else if (isVisible) { + setIsAnimating(false); + const timer = setTimeout(() => { + setIsVisible(false); + document.body.style.overflow = ''; + }, 300); + return () => clearTimeout(timer); + } + }, [isOpen, isVisible]); + + useEffect(() => { + setMediaLoaded(false); + }, [currentSlide]); + + const changeSlide = (newSlide: number) => { + if (isTransitioning) return; + setIsTransitioning(true); + + setTimeout(() => { + setCurrentSlide(newSlide); + setTimeout(() => { + setIsTransitioning(false); + }, 50); + }, 150); + }; + + const handleNext = () => { + if (!isLastSlide) { + changeSlide(currentSlide + 1); + } else { + localStorage.setItem('hasSeenCreatorTutorial', 'true'); + onClose(); + } + }; + + const handlePrevious = () => { + if (!isFirstSlide) { + changeSlide(currentSlide - 1); + } + }; + + const handleDotClick = (index: number) => { + if (index !== currentSlide) { + changeSlide(index); + } + }; + + const handleClose = () => { + localStorage.setItem('hasSeenCreatorTutorial', 'true'); + onClose(); + }; + + if (!isVisible) return null; + + return ( +
+ {/* Backdrop - click to dismiss */} +
+ + {/* Modal */} +
+ {/* Media container */} +
+ {/* Loading skeleton */} +
+ + {/* Media content with fade transition */} +
+ {currentSlideData.media.type === 'video' ? ( + + ) : ( + {currentSlideData.media.alt} setMediaLoaded(true)} + /> + )} +
+
+ + {/* Text content with fade transition */} +
+

+ {currentSlideData.title} +

+

+ {currentSlideData.description} +

+
+ + {/* Progress dots with glow */} +
+ {tutorialSlides.map((_, index) => { + const isCompleted = index < currentSlide; + const isCurrent = index === currentSlide; + + return ( + + ); + })} +
+ + {/* Navigation */} +
+ + + +
+
+
+ ); +} diff --git a/src/components/utils/components/TutorialModal/ViewAvailable.mp4 b/src/components/utils/components/TutorialModal/ViewAvailable.mp4 new file mode 100644 index 00000000..d0832e8f Binary files /dev/null and b/src/components/utils/components/TutorialModal/ViewAvailable.mp4 differ diff --git a/src/react-app-env.d.ts b/src/react-app-env.d.ts index 9bb86ecd..40e1f705 100644 --- a/src/react-app-env.d.ts +++ b/src/react-app-env.d.ts @@ -6,3 +6,8 @@ declare global { FB: typeof FB } } + +declare module '*.mp4' { + const src: string; + export default src; +} \ No newline at end of file diff --git a/tailwind.config.js b/tailwind.config.js index 7677bed8..f6936f90 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -14,11 +14,16 @@ module.exports = { 'scale-in': { '0%': { transform: 'scale(0.95)', opacity: '0' }, '100%': { transform: 'scale(1)', opacity: '1' } + }, + 'shimmer': { + '0%': { backgroundPosition: '-200% 0' }, + '100%': { backgroundPosition: '200% 0' } } }, animation: { 'fade-in': 'fade-in 0.2s ease-out', - 'scale-in': 'scale-in 0.2s ease-out' + 'scale-in': 'scale-in 0.2s ease-out', + 'shimmer': 'shimmer 1.5s infinite' }, fontFamily: { roboto: ["Roboto", "sans"],