diff --git a/.github/workflows/formatting.yml b/.github/workflows/formatting.yml index f53f78c9..82466215 100644 --- a/.github/workflows/formatting.yml +++ b/.github/workflows/formatting.yml @@ -8,19 +8,19 @@ jobs: runs-on: ubuntu-latest permissions: - contents: write # Required to push to the repo + contents: write # Required to push to the repo steps: - name: Checkout code uses: actions/checkout@v4 with: - ref: ${{ github.head_ref }} # Checkout PR branch + ref: ${{ github.head_ref }} # Checkout PR branch - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: '20' - cache: 'npm' + node-version: "20" + cache: "npm" - name: Install dependencies run: npm ci diff --git a/.gitignore b/.gitignore index 5ef6a520..3eb32276 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +certificates diff --git a/README.md b/README.md index 2b001613..f416f97b 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,137 @@ -# plancake Frontend +# Plancake Frontend -This repository contains the frontend code for plancake. +A modern, responsive web application for scheduling and meeting coordination built with Next.js 15, React 19, and Tailwind CSS. -## Getting Started +## Overview -To clone and run this application, [Node.js](https://nodejs.org/en/download/) (which comes with [npm](http://npmjs.com)) installed on your computer: +Plancake is a meeting scheduling tool that helps users coordinate availability across time zones. The application allows users to create events, share availability grids, and find optimal meeting times. + +--- + +## Architecture + +### Tech Stack + +**Core Framework:** + +- [Next.js 15.3.1](https://nextjs.org/) - React framework with App Router +- [React 19](https://react.dev/) - UI library +- [TypeScript 5](https://www.typescriptlang.org/) - Type safety + +**Styling:** + +- [Tailwind CSS 4.1.4](https://tailwindcss.com/) - Utility-first CSS framework +- [Tailwind Merge](https://github.com/dcastil/tailwind-merge) - Conditional class merging +- [clsx](https://github.com/lukeed/clsx) - Utility for constructing className strings + +**UI Components:** + +- [Radix UI](https://www.radix-ui.com/) - Accessible component primitives + - Dialog + - Popover + - Select + - Icons +- [Framer Motion](https://www.framer.com/motion/) - Animation library +- [Vaul](https://vaul.emilkowal.ski/) - Drawer component +- [next-themes](https://github.com/pacocoursey/next-themes) - Dark mode support + +**Date/Time Handling:** + +- [date-fns-tz](https://date-fns.org/) - Timezone utilities +- [react-day-picker](https://react-day-picker.js.org/) - Date picker component + +**Development Tools:** + +- [ESLint 9](https://eslint.org/) - Code linting +- [Prettier 3.5.3](https://prettier.io/) - Code formatting +- [Turbopack](https://turbo.build/pack) - Fast bundler (dev mode) + +### `.env` Setup + +Create a file called `.env` in the root directory, copying the contents of `example.env`. + +Replace all values in the file with the relevant information. + +## Local Development Setup + +### Prerequisites + +- **Node.js** 20+ (LTS recommended) +- **npm** 10+ or compatible package manager +- **Git** for version control + +### Installation Steps + +1. **Clone the repository** + + ```bash + https://github.com/plan-cake/frontend.git + cd frontend + ``` + +2. **Install dependencies** + + ```bash + npm install + ``` + + This will install all dependencies listed in [package.json](package.json), including: + + - Next.js 15.3.1 with React 19 + - Tailwind CSS 4.1.4 + - Radix UI components + - TypeScript and type definitions + +3. **Run the development server** + + ```bash + npm run dev + ``` + + The application will start on `http://localhost:3000` using Turbopack for fast refresh. + +4. **Open your browser** + Navigate to [http://localhost:3000](http://localhost:3000) to see the application. + +### Available Scripts ```bash -npm install npm@latest -g +# Development server with Turbopack (fast refresh) +npm run dev + +# Production build +npm run build + +# Start production server (requires build first) +npm run start + +# Lint code with ESLint +npm run lint + +# Format code with Prettier +npm run format + +# Check formatting without modifying files +npm run check-format ``` -### Installation +### Building for Production ```bash -# Clone this repository -$ git clone https://github.com/tomeeto/frontend.git +# Create optimized production build +npm run build -# Install dependencies -$ npm install - -# Run the app -$ npm run dev +# Test production build locally +npm run start ``` -## Credits +The production build: + +- Optimizes and minifies JavaScript/CSS +- Generates static pages where possible +- Creates optimized images +- Outputs to `.next/` directory -This project is built with the following: +--- -- [Next.js](https://nextjs.org/) -- [Tailwind.css](https://tailwindcss.com/) -- [Radix UI Elements](https://www.radix-ui.com/) +**Made with** [Next.js](https://nextjs.org/) **•** [Tailwind CSS](https://tailwindcss.com/) **•** [Radix UI](https://www.radix-ui.com/) diff --git a/app/[event-code]/edit/loading.tsx b/app/[event-code]/edit/loading.tsx new file mode 100644 index 00000000..a356b043 --- /dev/null +++ b/app/[event-code]/edit/loading.tsx @@ -0,0 +1,26 @@ +import HeaderSpacer from "@/app/ui/components/header/header-spacer"; + +export default function Loading() { + return ( +
+ + +
+
+
+
+ +
+
+
+
+
+
+ +
+
+
+
+
+ ); +} diff --git a/app/[event-code]/edit/page.tsx b/app/[event-code]/edit/page.tsx new file mode 100644 index 00000000..42b96cee --- /dev/null +++ b/app/[event-code]/edit/page.tsx @@ -0,0 +1,23 @@ +import { EventCodePageProps } from "@/app/_lib/types/event-code-page-props"; +import { fetchEventDetails } from "@/app/_utils/fetch-data"; +import { processEventData } from "@/app/_utils/process-event-data"; +import EventEditor from "../../ui/layout/event-editor"; +import notFound from "../not-found"; + +export default async function Page({ params }: EventCodePageProps) { + const { "event-code": eventCode } = await params; + + if (!eventCode) { + notFound(); + } + + const eventData = await fetchEventDetails(eventCode); + const { eventName, eventRange } = processEventData(eventData); + + return ( + + ); +} diff --git a/app/[event-code]/loading.tsx b/app/[event-code]/loading.tsx new file mode 100644 index 00000000..df4d086d --- /dev/null +++ b/app/[event-code]/loading.tsx @@ -0,0 +1,28 @@ +import HeaderSpacer from "@/app/ui/components/header/header-spacer"; + +export default function Loading() { + return ( +
+ + +
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ +
+
+
+ ); +} diff --git a/app/[event-code]/not-found.tsx b/app/[event-code]/not-found.tsx new file mode 100644 index 00000000..b6f444d6 --- /dev/null +++ b/app/[event-code]/not-found.tsx @@ -0,0 +1,3 @@ +export default function NotFound() { + return
404 - Page Not Found :p
; +} diff --git a/app/[event-code]/page.tsx b/app/[event-code]/page.tsx new file mode 100644 index 00000000..8ab1599b --- /dev/null +++ b/app/[event-code]/page.tsx @@ -0,0 +1,31 @@ +import { notFound } from "next/navigation"; +import { fetchEventDetails, fetchSelfAvailability } from "../_utils/fetch-data"; +import { processEventData } from "../_utils/process-event-data"; + +import AvailabilityClientPage from "@/app/ui/layout/availability-page"; +import { EventCodePageProps } from "../_lib/types/event-code-page-props"; +import { getAuthCookieString } from "../_utils/cookie-utils"; + +export default async function Page({ params }: EventCodePageProps) { + const { "event-code": eventCode } = await params; + const authCookies = await getAuthCookieString(); + + if (!eventCode) { + notFound(); + } + + const [eventData, initialAvailabilityData] = await Promise.all([ + fetchEventDetails(eventCode), + fetchSelfAvailability(eventCode, authCookies), + ]); + const { eventName, eventRange } = processEventData(eventData); + + return ( + + ); +} diff --git a/app/[event-code]/results/loading.tsx b/app/[event-code]/results/loading.tsx new file mode 100644 index 00000000..786da26d --- /dev/null +++ b/app/[event-code]/results/loading.tsx @@ -0,0 +1,26 @@ +import HeaderSpacer from "@/app/ui/components/header/header-spacer"; + +export default function Loading() { + return ( +
+ + +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+ ); +} diff --git a/app/[event-code]/results/page.tsx b/app/[event-code]/results/page.tsx new file mode 100644 index 00000000..c8e56fbf --- /dev/null +++ b/app/[event-code]/results/page.tsx @@ -0,0 +1,36 @@ +import { notFound } from "next/navigation"; +import { + fetchEventDetails, + fetchAvailabilityData, +} from "@/app/_utils/fetch-data"; +import { processEventData } from "@/app/_utils/process-event-data"; + +import ResultsPage from "@/app/ui/layout/results-page"; +import { getAuthCookieString } from "@/app/_utils/cookie-utils"; +import { EventCodePageProps } from "@/app/_lib/types/event-code-page-props"; + +export default async function Page({ params }: EventCodePageProps) { + const { "event-code": eventCode } = await params; + const authCookies = await getAuthCookieString(); + + if (!eventCode) { + notFound(); + } + + const [initialEventData, availabilityData] = await Promise.all([ + fetchEventDetails(eventCode, authCookies), + fetchAvailabilityData(eventCode, authCookies), + ]); + + // Process the data here, on the server! + const { eventName, eventRange } = processEventData(initialEventData); + + return ( + + ); +} diff --git a/app/_lib/availability/availability-reducer.tsx b/app/_lib/availability/availability-reducer.tsx new file mode 100644 index 00000000..7b2e1904 --- /dev/null +++ b/app/_lib/availability/availability-reducer.tsx @@ -0,0 +1,62 @@ +import { AvailabilitySet } from "./types"; +import { createEmptyUserAvailability, toggleUtcSlot } from "./utils"; + +export interface AvailabilityState { + displayName: string; + timeZone: string; + userAvailability: AvailabilitySet; +} + +export type AvailabilityAction = + | { type: "RESET_AVAILABILITY" } + | { type: "SET_DISPLAY_NAME"; payload: string } + | { type: "SET_TIME_ZONE"; payload: string } + | { + type: "TOGGLE_SLOT"; + payload: { slot: string; togglingOn: boolean }; + }; + +// 3. Create the reducer function +export function availabilityReducer( + state: AvailabilityState, + action: AvailabilityAction, +): AvailabilityState { + switch (action.type) { + case "RESET_AVAILABILITY": { + return { + ...state, + userAvailability: createEmptyUserAvailability(), + }; + } + + case "SET_DISPLAY_NAME": { + return { + ...state, + displayName: action.payload, + }; + } + + case "SET_TIME_ZONE": { + return { + ...state, + timeZone: action.payload, + }; + } + + case "TOGGLE_SLOT": { + const { slot, togglingOn } = action.payload; + return { + ...state, + userAvailability: toggleUtcSlot( + state.userAvailability, + slot, + togglingOn, + ), + }; + } + + default: { + return state; + } + } +} diff --git a/app/_lib/availability/types.tsx b/app/_lib/availability/types.tsx new file mode 100644 index 00000000..d56f0fa4 --- /dev/null +++ b/app/_lib/availability/types.tsx @@ -0,0 +1,7 @@ +// a set containing ISO strings of selected UTC date-time slots +export type AvailabilitySet = Set; + +// ISO date string to array of participant names who are available at that time +export type ResultsAvailabilityMap = { + [key: string]: string[]; +}; diff --git a/app/_lib/availability/use-availability.tsx b/app/_lib/availability/use-availability.tsx new file mode 100644 index 00000000..f0a4ed83 --- /dev/null +++ b/app/_lib/availability/use-availability.tsx @@ -0,0 +1,47 @@ +import { SelfAvailabilityResponse } from "@/app/_utils/fetch-data"; +import { useCallback, useReducer } from "react"; +import { availabilityReducer, AvailabilityState } from "./availability-reducer"; +import { createUserAvailability } from "./utils"; + +export function useAvailability(initialData: SelfAvailabilityResponse | null) { + const initialTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone; + const isoStrings = []; + if (initialData && initialData.available_dates) { + for (const dateStr of initialData.available_dates) { + isoStrings.push(new Date(dateStr).toISOString()); + } + } + + const initialState: AvailabilityState = { + displayName: initialData?.display_name || "", + timeZone: initialData?.time_zone || initialTimeZone, + userAvailability: createUserAvailability(isoStrings), + }; + + const [state, dispatch] = useReducer(availabilityReducer, initialState); + + // DISPATCHERS + const setDisplayName = useCallback((name: string) => { + dispatch({ type: "SET_DISPLAY_NAME", payload: name }); + }, []); + + const setTimeZone = useCallback((tz: string) => { + dispatch({ type: "SET_TIME_ZONE", payload: tz }); + }, []); + + const toggleSlot = useCallback((slot: string, togglingOn: boolean) => { + dispatch({ type: "TOGGLE_SLOT", payload: { slot, togglingOn } }); + }, []); + + const resetAvailability = useCallback(() => { + dispatch({ type: "RESET_AVAILABILITY" }); + }, []); + + return { + state, + setDisplayName, + setTimeZone, + toggleSlot, + resetAvailability, + }; +} diff --git a/app/_lib/availability/utils.tsx b/app/_lib/availability/utils.tsx new file mode 100644 index 00000000..7b670443 --- /dev/null +++ b/app/_lib/availability/utils.tsx @@ -0,0 +1,145 @@ +import { eachDayOfInterval, parseISO } from "date-fns"; +import { AvailabilitySet } from "@/app/_lib/availability/types"; +import { EventRange, SpecificDateRange, WeekdayRange } from "../schedule/types"; +import { + getAbsoluteDateRangeInUTC, + getSelectedWeekdaysInTimezone, +} from "@/app/_lib/schedule/utils"; + +// Creates an empty UserAvailability object +export const createEmptyUserAvailability = (): AvailabilitySet => { + return new Set(); +}; + +export const createUserAvailability = ( + data: Array, +): AvailabilitySet => { + return new Set(data); +}; + +// Toggles a single time slot in the user's availability +export function toggleUtcSlot( + prev: AvailabilitySet, + timeSlot: string, // ISO string + togglingOn: boolean, +): AvailabilitySet { + const updated = new Set(prev); + if (togglingOn) { + updated.add(timeSlot); + } else { + updated.delete(timeSlot); + } + return updated; +} + +// Checks if a specific time slot is in the user's availability +export function isSlotSelected( + availability: AvailabilitySet, + timeSlot: Date, +): boolean { + return availability.has(timeSlot.toISOString()); +} + +// converts set to grid for api +export function convertAvailabilityToGrid( + availability: AvailabilitySet, + eventRange: EventRange, +): boolean[][] { + if (eventRange.type === "specific") { + return convertAvailabilityToGridForSpecificRange(availability, eventRange); + } else { + return convertAvailabilityToGridForWeekdayRange(availability, eventRange); + } +} + +function convertAvailabilityToGridForSpecificRange( + availability: AvailabilitySet, + eventRange: SpecificDateRange, +): boolean[][] { + const { eventStartUTC, eventEndUTC } = getAbsoluteDateRangeInUTC(eventRange); + const startTime = eventStartUTC.getHours(); + const endTime = + eventEndUTC.getMinutes() === 59 + ? eventEndUTC.getHours() + 1 + : eventEndUTC.getHours(); + + const days = eachDayOfInterval({ + start: parseISO(eventStartUTC.toISOString()), + end: parseISO(eventEndUTC.toISOString()), + }); + + const grid: boolean[][] = days.map((day) => { + const daySlots: boolean[] = []; + + for (let hour = startTime; hour < endTime; hour++) { + for (let minute = 0; minute < 60; minute += 15) { + const slot = new Date(day); + slot.setHours(hour, minute); + daySlots.push(isSlotSelected(availability, slot)); + } + } + + return daySlots; + }); + return grid; +} + +function convertAvailabilityToGridForWeekdayRange( + availability: AvailabilitySet, + eventRange: WeekdayRange, +): boolean[][] { + const selectedDays = getSelectedWeekdaysInTimezone(eventRange); + + const grid: boolean[][] = selectedDays.map((day) => { + const daySlots: boolean[] = []; + const { slotTimeUTC, dayEndUTC } = day; + + while (slotTimeUTC < dayEndUTC) { + daySlots.push(isSlotSelected(availability, slotTimeUTC)); + slotTimeUTC.setUTCMinutes(slotTimeUTC.getUTCMinutes() + 15); + } + + return daySlots; + }); + + return grid; +} + +function sortDateRange(start: Date, end: Date): [Date, Date] { + // given a start date and end date, it separately sorts the time and date components + // and returns two new dates, such that the first has both the earlier date and time + const startTime = new Date(start); + const endTime = new Date(end); + startTime.setFullYear(1970, 0, 1); + endTime.setFullYear(1970, 0, 1); + if (startTime > endTime) { + const temp = new Date(startTime); + startTime.setTime(endTime.getTime()); + endTime.setTime(temp.getTime()); + } + const startDate = start < end ? start : end; + const endDate = start < end ? end : start; + startDate.setHours(startTime.getHours(), startTime.getMinutes()); + endDate.setHours(endTime.getHours(), endTime.getMinutes()); + return [startDate, endDate]; +} + +export function generateDragSlots( + dragStart: string, + dragEnd: string, +): Set { + const slots = new Set(); + const [start, end] = sortDateRange(new Date(dragStart), new Date(dragEnd)); + const current = new Date(start); + const endMinutes = end.getHours() * 60 + end.getMinutes(); + while (current <= end) { + slots.add(current.toISOString()); + current.setMinutes(current.getMinutes() + 15); + const currentMinutes = current.getHours() * 60 + current.getMinutes(); + if (currentMinutes > endMinutes) { + current.setHours(start.getHours(), start.getMinutes()); + current.setDate(current.getDate() + 1); + } + } + return slots; +} diff --git a/app/_lib/classname.ts b/app/_lib/classname.ts index d1ff4c93..2bc6740c 100644 --- a/app/_lib/classname.ts +++ b/app/_lib/classname.ts @@ -1,7 +1,7 @@ // utils.ts -import { clsx } from "clsx"; +import { ClassValue, clsx } from "clsx"; import { twMerge } from "tailwind-merge"; -export function cn(...inputs: any[]) { +export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); } diff --git a/app/_lib/providers.tsx b/app/_lib/providers.tsx new file mode 100644 index 00000000..c489c21a --- /dev/null +++ b/app/_lib/providers.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { ThemeProvider } from "next-themes"; +import { createContext, useState } from "react"; +import ToastProvider from "./toast-provider"; + +export const LoginContext = createContext<{ + loggedIn: boolean | null; + setLoggedIn: (loggedIn: boolean) => void; +}>({ + loggedIn: null, + setLoggedIn: () => {}, +}); + +export function Providers({ children }: { children: React.ReactNode }) { + const [loggedIn, setLoggedIn] = useState(null); + + return ( + + + {children} + + + ); +} diff --git a/app/_lib/schedule/event-info-reducer.tsx b/app/_lib/schedule/event-info-reducer.tsx new file mode 100644 index 00000000..4b35b2a6 --- /dev/null +++ b/app/_lib/schedule/event-info-reducer.tsx @@ -0,0 +1,52 @@ +import { EventInformation } from "./types"; +import { EventRangeReducer, EventRangeAction } from "./event-range-reducer"; + +export type EventInfoAction = + | { type: "SET_TITLE"; payload: string } + | { type: "SET_CUSTOM_CODE"; payload: string } + | EventRangeAction + | { type: "RESET" }; + +export function EventInfoReducer( + state: EventInformation, + action: EventInfoAction, +): EventInformation { + switch (action.type) { + case "SET_TITLE": + return { + ...state, + title: action.payload, + }; + case "SET_CUSTOM_CODE": + return { + ...state, + customCode: action.payload, + }; + case "RESET": + return { + title: "", + customCode: "", + eventRange: { + type: "specific", + duration: 60, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + dateRange: { + from: new Date().toISOString(), + to: new Date().toISOString(), + }, + timeRange: { + from: 9, + to: 17, + }, + }, + }; + default: + return { + ...state, + eventRange: EventRangeReducer( + state.eventRange, + action as EventRangeAction, + ), + }; + } +} diff --git a/app/_lib/schedule/event-range-reducer.tsx b/app/_lib/schedule/event-range-reducer.tsx new file mode 100644 index 00000000..3714178e --- /dev/null +++ b/app/_lib/schedule/event-range-reducer.tsx @@ -0,0 +1,134 @@ +import { EventRange, WeekdayMap } from "@/app/_lib/schedule/types"; + +export type EventRangeAction = + | { type: "SET_RANGE_INFO"; payload: EventRange } + | { type: "SET_RANGE_TYPE"; payload: "specific" | "weekday" } + | { type: "SET_DATE_RANGE"; payload: { from: string; to: string } } + | { type: "SET_TIME_RANGE"; payload: { from: number; to: number } } + | { + type: "SET_WEEKDAYS"; + payload: { weekdays: Partial> }; + } + | { type: "SET_DURATION"; payload: number } + | { type: "SET_TIMEZONE"; payload: string } + | { type: "RESET" }; + +export function EventRangeReducer( + state: EventRange, + action: EventRangeAction, +): EventRange { + switch (action.type) { + case "SET_RANGE_INFO": { + return { + ...action.payload, + }; + } + + case "SET_RANGE_TYPE": { + if (action.payload === state.type) { + return state; + } + + const baseEvent = { + duration: state.duration, + timezone: state.timezone, + timeRange: state.timeRange, + }; + + if (action.payload === "specific") { + return { + ...baseEvent, + type: "specific", + dateRange: { + from: new Date().toISOString(), + to: new Date().toISOString(), + }, + }; + } else { + return { + ...baseEvent, + type: "weekday", + weekdays: { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 }, + }; + } + } + + case "SET_DATE_RANGE": { + if (state.type !== "specific") { + return state; + } + + return { + ...state, + dateRange: { + from: action.payload.from, + to: action.payload.to, + }, + }; + } + + case "SET_TIME_RANGE": { + return { + ...state, + timeRange: { + from: action.payload.from, + to: action.payload.to, + }, + }; + } + + case "SET_WEEKDAYS": { + if (state.type !== "weekday") { + return state; + } + + return { + ...state, + weekdays: { + ...state.weekdays, + ...action.payload.weekdays, + }, + }; + } + + case "SET_DURATION": { + return { + ...state, + duration: action.payload, + }; + } + + case "SET_TIMEZONE": { + return { + ...state, + timezone: action.payload, + }; + } + + case "RESET": { + if (state.type === "specific") { + return { + type: "specific", + duration: 30, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + dateRange: { + from: new Date().toISOString(), + to: new Date().toISOString(), + }, + timeRange: { from: 9, to: 17 }, + }; + } else { + return { + type: "weekday", + duration: 30, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + weekdays: { Sun: 0, Mon: 0, Tue: 0, Wed: 0, Thu: 0, Fri: 0, Sat: 0 }, + timeRange: { from: 9, to: 17 }, + }; + } + } + + default: + return state; + } +} diff --git a/app/_lib/schedule/types.tsx b/app/_lib/schedule/types.tsx new file mode 100644 index 00000000..43d45ac1 --- /dev/null +++ b/app/_lib/schedule/types.tsx @@ -0,0 +1,68 @@ +// app/_types/schedule.ts + +export type EventInformation = { + title: string; + customCode: string; + eventRange: EventRange; +}; + +// discriminated union for event ranges - this is your single source of truth +export type EventRange = SpecificDateRange | WeekdayRange; + +// represents selected weekdays +export type Weekday = "Sun" | "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat"; +export type WeekdayMap = { + [day in Weekday]: 0 | 1; +}; + +export const days: Array = [ + "Sun", + "Mon", + "Tue", + "Wed", + "Thu", + "Fri", + "Sat", +]; + +export type WeekdayTimeRange = { + slotTimeUTC: Date; + dayEndUTC: Date; +}; + +/* EVENT RANGE MODELS */ + +export type SpecificDateRange = { + type: "specific"; + duration: number; + timezone: string; + dateRange: { + from: string; + to: string; + }; + timeRange: { + from: number; // hour in 24h format, e.g., 9 for 9:00 AM + to: number; // hour in 24h format, e.g., 17 for 5:00 PM + }; +}; + +export type WeekdayRange = { + type: "weekday"; + duration: number; + timezone: string; + weekdays: WeekdayMap; + timeRange: { + from: number; // hour in 24h format, e.g., 9 for 9:00 AM + to: number; // hour in 24h format, e.g., 17 for 5:00 PM + }; +}; + +/* SLOTS TYPES FOR UI */ +// these types are generated from the core models above for rendering + +export type DaySlot = { + date: Date; // date for the entire day + dayLabel: string; // e.g., "MON MAY 10" + dayKey: string; // e.g., "2025-05-10" + timeslots: Date[]; +}; diff --git a/app/_lib/schedule/use-event-info.tsx b/app/_lib/schedule/use-event-info.tsx new file mode 100644 index 00000000..c99e089b --- /dev/null +++ b/app/_lib/schedule/use-event-info.tsx @@ -0,0 +1,99 @@ +import { useReducer, useCallback } from "react"; +import { EventInfoReducer } from "./event-info-reducer"; +import { EventInformation, EventRange, WeekdayMap } from "./types"; +import { DateRange } from "react-day-picker"; + +export function useEventInfo(initialData?: { + title: string; + code: string; + eventRange: EventRange; +}) { + const initialState: EventInformation = { + title: initialData?.title || "", + customCode: initialData?.code || "", + eventRange: initialData?.eventRange || { + type: "specific", + duration: 0, + timezone: Intl.DateTimeFormat().resolvedOptions().timeZone, + dateRange: { + from: new Date().toISOString(), + to: new Date().toISOString(), + }, + timeRange: { + from: 9, + to: 17, + }, + }, + }; + + if (!initialData?.eventRange?.duration) { + initialState.eventRange.duration = 0; + } + + const [state, dispatch] = useReducer(EventInfoReducer, initialState); + + // DISPATCHERS + const setTitle = useCallback((title: string) => { + dispatch({ type: "SET_TITLE", payload: title }); + }, []); + + const setCustomCode = useCallback((code: string) => { + dispatch({ type: "SET_CUSTOM_CODE", payload: code }); + }, []); + + const setEventRangeInfo = useCallback((info: EventRange) => { + dispatch({ type: "SET_RANGE_INFO", payload: info }); + }, []); + + const setEventType = useCallback((type: "specific" | "weekday") => { + dispatch({ type: "SET_RANGE_TYPE", payload: type }); + }, []); + + const setTimezone = useCallback((tz: string) => { + dispatch({ type: "SET_TIMEZONE", payload: tz }); + }, []); + + const setDuration = useCallback((duration: number) => { + dispatch({ type: "SET_DURATION", payload: duration }); + }, []); + + const setTimeRange = useCallback( + (timeRange: { from: number; to: number }) => { + dispatch({ type: "SET_TIME_RANGE", payload: timeRange }); + }, + [], + ); + + const setDateRange = useCallback((dateRange: DateRange | undefined) => { + if (dateRange?.from && dateRange?.to) { + const from = dateRange.from.toISOString(); + const to = dateRange.to.toISOString(); + dispatch({ + type: "SET_DATE_RANGE", + payload: { from, to }, + }); + } + }, []); + + const setWeekdayRange = useCallback((weekdays: WeekdayMap) => { + dispatch({ type: "SET_WEEKDAYS", payload: { weekdays } }); + }, []); + + const resetEventInfo = useCallback(() => { + dispatch({ type: "RESET" }); + }, []); + + return { + state, + setTitle, + setEventType, + setCustomCode, + setEventRangeInfo, + setTimezone, + setDuration, + setTimeRange, + setDateRange, + setWeekdayRange, + resetEventInfo, + }; +} diff --git a/app/_lib/schedule/utils.tsx b/app/_lib/schedule/utils.tsx new file mode 100644 index 00000000..efeb8036 --- /dev/null +++ b/app/_lib/schedule/utils.tsx @@ -0,0 +1,227 @@ +import { + EventRange, + SpecificDateRange, + WeekdayRange, + WeekdayTimeRange, + WeekdayMap, + Weekday, + days, +} from "@/app/_lib/schedule/types"; +import { formatInTimeZone, fromZonedTime, toZonedTime } from "date-fns-tz"; +import { getHours, getMinutes } from "date-fns"; +import { DateRange } from "react-day-picker"; + +/* WEEKDAY SPECIFIC UTILITIES */ + +export function generateWeekdayMap( + startDay: number, + endDay: number, +): WeekdayMap { + const weekdays: WeekdayMap = { + Sun: 0, + Mon: 0, + Tue: 0, + Wed: 0, + Thu: 0, + Fri: 0, + Sat: 0, + }; + + for (let i = startDay; i <= endDay; i++) { + const dayKey: Weekday = days[i]; + weekdays[dayKey] = 1; + } + return weekdays; +} + +export function findRangeFromWeekdayMap(selectedDays: WeekdayMap): { + startDay: Weekday | null; + endDay: Weekday | null; +} { + const selected = days.filter((day) => selectedDays[day] === 1); + + if (selected.length === 0) { + return { startDay: null, endDay: null }; + } + + return { + startDay: selected[0], + endDay: selected[selected.length - 1], + }; +} + +/* EXPAND EVENT RANGE UTILITIES */ + +function getTimeStrings(timeRange: { from: number; to: number }) { + const fromHour = String(timeRange.from).padStart(2, "0"); + const toHour = + timeRange.to === 24 ? "23:59" : String(timeRange.to).padStart(2, "0"); + return { fromHour, toHour }; +} + +export function getAbsoluteDateRangeInUTC(eventRange: SpecificDateRange): { + eventStartUTC: Date; + eventEndUTC: Date; +} { + const startDateString = eventRange.dateRange.from.split("T")[0]; + const endDateString = eventRange.dateRange.to.split("T")[0]; + const { fromHour: startTimeString, toHour: endTimeString } = getTimeStrings( + eventRange.timeRange, + ); + const eventStartUTC = fromZonedTime( + `${startDateString}T${startTimeString}`, + eventRange.timezone, + ); + const eventEndUTC = fromZonedTime( + `${endDateString}T${endTimeString}`, + eventRange.timezone, + ); + + return { eventStartUTC, eventEndUTC }; +} + +export function getSelectedWeekdaysInTimezone( + range: WeekdayRange, +): WeekdayTimeRange[] { + const dayNameToIndex: { [key: string]: number } = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + + const selectedDayIndexes = new Set(); + for (const dayName in range.weekdays) { + if (range.weekdays[dayName as keyof WeekdayMap] === 1) { + selectedDayIndexes.add(dayNameToIndex[dayName]); + } + } + + if (selectedDayIndexes.size === 0) { + return []; + } + + // 01/01/2012 is a sunday + const startOfWeekInViewerTz = fromZonedTime( + "2012-01-01T00:00:00", + range.timezone, + ); + + const { fromHour: startTimeString, toHour: endTimeString } = getTimeStrings( + range.timeRange, + ); + + const selectedDatesUTC: WeekdayTimeRange[] = []; + for (let i = 0; i < 7; i++) { + const currentDay = new Date(startOfWeekInViewerTz); + currentDay.setDate(startOfWeekInViewerTz.getDate() + i); + if (selectedDayIndexes.has(currentDay.getDay())) { + const dateString = formatInTimeZone( + currentDay, + range.timezone, + "yyyy-MM-dd", + ); + + const slotTimeUTC = fromZonedTime( + `${dateString}T${startTimeString}`, + range.timezone, + ); + const dayEndUTC = fromZonedTime( + `${dateString}T${endTimeString}`, + range.timezone, + ); + + selectedDatesUTC.push({ slotTimeUTC, dayEndUTC }); + } + } + + return selectedDatesUTC; +} + +/** + * expands a high-level EventRange into a concrete list of days and time slots + * for the user's local timezone + */ +export function expandEventRange(range: EventRange): Date[] { + if (range.type === "specific") { + return generateSlotsForSpecificRange(range); + } else { + return generateSlotsForWeekdayRange(range); + } +} + +function generateSlotsForSpecificRange(range: SpecificDateRange): Date[] { + const slots: Date[] = []; + if (!range.dateRange.from || !range.dateRange.to) { + return []; + } + + // Get the absolute start and end times in UTC + const { eventStartUTC, eventEndUTC } = getAbsoluteDateRangeInUTC(range); + + // Get the valid time range for any given day in UTC + const validStartHour = range.timeRange.from; + const validEndHour = range.timeRange.to; + + const currentUTC = new Date(eventStartUTC); + + while (currentUTC <= eventEndUTC) { + // Get the time-of-day part of the current date + const zonedCurrent = toZonedTime(currentUTC, range.timezone); + + const currentHour = getHours(zonedCurrent); + const currentMinute = getMinutes(zonedCurrent); + + const isAfterStartTime = + currentHour > validStartHour || + (currentHour === validStartHour && currentMinute >= 0); + + const isBeforeEndTime = + currentHour < validEndHour || + (currentHour === validEndHour && currentMinute < 0); + + if (isAfterStartTime && isBeforeEndTime) { + slots.push(new Date(currentUTC)); + } + + currentUTC.setUTCMinutes(currentUTC.getUTCMinutes() + 15); + } + + return slots; +} + +function generateSlotsForWeekdayRange(range: WeekdayRange): Date[] { + const slots: Date[] = []; + if (range.type !== "weekday") { + return []; + } + + const selectedDays = getSelectedWeekdaysInTimezone(range); + if (selectedDays.length === 0) { + return []; + } + + for (const day of selectedDays) { + const { slotTimeUTC, dayEndUTC } = day; + + while (slotTimeUTC < dayEndUTC) { + slots.push(new Date(slotTimeUTC)); + slotTimeUTC.setUTCMinutes(slotTimeUTC.getUTCMinutes() + 15); + } + } + + return slots; +} + +export function checkInvalidDateRangeLength( + range: DateRange | undefined, +): boolean { + if (range?.from && range?.to) { + const diffTime = range.to.getTime() - range.from.getTime(); + return diffTime > 30 * 24 * 60 * 60 * 1000; // more than 30 days + } + return false; +} diff --git a/app/_utils/timezone-file-generator.tsx b/app/_lib/timezone-file-generator.tsx similarity index 96% rename from app/_utils/timezone-file-generator.tsx rename to app/_lib/timezone-file-generator.tsx index 95398cb9..c85f2b51 100644 --- a/app/_utils/timezone-file-generator.tsx +++ b/app/_lib/timezone-file-generator.tsx @@ -1,9 +1,8 @@ import { useEffect, useState } from "react"; -function formatLabel(tz: string): string { +export function formatLabel(tz: string): string { try { const now = new Date(); - const offsetMinutes = -now.getTimezoneOffset(); const offset = new Intl.DateTimeFormat("en-US", { timeZone: tz, diff --git a/app/_lib/toast-context.tsx b/app/_lib/toast-context.tsx new file mode 100644 index 00000000..e78af0e3 --- /dev/null +++ b/app/_lib/toast-context.tsx @@ -0,0 +1,27 @@ +import { createContext, useContext } from "react"; + +export type ToastData = { + id: number; + type: "error" | "success"; + title: string; + message: string; + icon?: React.ReactNode; +}; + +export const ToastContext = createContext<{ + addToast: (data: ToastData) => void; + removeToast: (id: number) => void; +}>({ + addToast: () => {}, + removeToast: () => {}, +}); + +export function useToast() { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} + +export default ToastContext; diff --git a/app/_lib/toast-provider.tsx b/app/_lib/toast-provider.tsx new file mode 100644 index 00000000..b221a05a --- /dev/null +++ b/app/_lib/toast-provider.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useCallback, useState } from "react"; +import * as Toast from "@radix-ui/react-toast"; +import ToastContext, { ToastData } from "@/app/_lib/toast-context"; +import ErrorToast from "../ui/components/toasts/error-toast"; +import SuccessToast from "../ui/components/toasts/success-toast"; +import { CheckIcon } from "@radix-ui/react-icons"; + +export default function ToastProvider({ + children, +}: { + children: React.ReactNode; +}) { + const [toasts, setToasts] = useState([]); + + const addToast = useCallback((data: ToastData) => { + setToasts((prevToasts) => [...prevToasts, data]); + }, []); + + const removeToast = useCallback((id: number) => { + setToasts((prevToasts) => prevToasts.filter((toast) => toast.id !== id)); + }, []); + + return ( + + + {children} + + {toasts.map((toast) => { + if (toast.type === "error") { + return ( + !isOpen && removeToast(toast.id)} + /> + ); + } else if (toast.type === "success") { + return ( + !isOpen && removeToast(toast.id)} + icon={ + toast.icon ? ( + toast.icon + ) : ( + + ) + } + /> + ); + } + + return null; + })} + + + + + ); +} diff --git a/app/_lib/types/date-range-props.tsx b/app/_lib/types/date-range-props.tsx new file mode 100644 index 00000000..d79fd5ba --- /dev/null +++ b/app/_lib/types/date-range-props.tsx @@ -0,0 +1,21 @@ +import { EventRange, WeekdayMap } from "@/app/_lib/schedule/types"; +import { DateRange } from "react-day-picker"; + +export type DateRangeProps = { + earliestDate?: Date; + eventRange: EventRange; + tooManyDays?: boolean; + editing?: boolean; + + // update functions + setTitle?: (title: string) => void; + setCustomCode?: (code: string) => void; + setEventType?: (type: "specific" | "weekday") => void; + setTimezone?: (tz: string) => void; + setDuration?: (duration: number) => void; + setTimeRange?: (timeRange: { from: number; to: number }) => void; + setDateRange?: (dateRange: DateRange | undefined) => void; + setWeekdayRange?: (weekdays: WeekdayMap) => void; + + displayCalendar?: boolean; +}; diff --git a/app/_lib/types/event-code-page-props.tsx b/app/_lib/types/event-code-page-props.tsx new file mode 100644 index 00000000..545cdcb4 --- /dev/null +++ b/app/_lib/types/event-code-page-props.tsx @@ -0,0 +1,3 @@ +export type EventCodePageProps = { + params: Promise<{ "event-code": string }>; +}; diff --git a/app/_lib/types/toast.tsx b/app/_lib/types/toast.tsx new file mode 100644 index 00000000..994cd4d2 --- /dev/null +++ b/app/_lib/types/toast.tsx @@ -0,0 +1,4 @@ +export interface ToastErrorMessage { + id: number; + message: string; +} diff --git a/app/_utils/use-check-mobile.tsx b/app/_lib/use-check-mobile.tsx similarity index 100% rename from app/_utils/use-check-mobile.tsx rename to app/_lib/use-check-mobile.tsx diff --git a/app/_lib/use-debounce.tsx b/app/_lib/use-debounce.tsx new file mode 100644 index 00000000..d986e68c --- /dev/null +++ b/app/_lib/use-debounce.tsx @@ -0,0 +1,21 @@ +import { useEffect, useRef, DependencyList } from "react"; + +export function useDebounce( + effect: () => void, + deps: DependencyList, + delay: number = 1000, +) { + const timeoutRef = useRef(null); + + useEffect(() => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + + timeoutRef.current = setTimeout(() => { + effect(); + }, delay); + + return () => { + if (timeoutRef.current) clearTimeout(timeoutRef.current); + }; + }, [deps, delay, effect]); +} diff --git a/app/_lib/use-generate-timeslots.tsx b/app/_lib/use-generate-timeslots.tsx new file mode 100644 index 00000000..52b36bf9 --- /dev/null +++ b/app/_lib/use-generate-timeslots.tsx @@ -0,0 +1,80 @@ +import { useMemo } from "react"; +import { toZonedTime } from "date-fns-tz"; +import { differenceInCalendarDays } from "date-fns"; +import { EventRange } from "@/app/_lib/schedule/types"; +import { expandEventRange } from "@/app/_lib/schedule/utils"; + +export default function useGenerateTimeSlots( + eventRange: EventRange, + timezone: string, +) { + return useMemo(() => { + const daySlots = expandEventRange(eventRange); + if (daySlots.length === 0) { + return { + timeBlocks: [], + dayGroupedSlots: [], + numDays: 0, + numHours: 0, + error: "Invalid or missing date range", + }; + } + + const localStartTime = toZonedTime(daySlots[0], timezone); + const localEndTime = toZonedTime(daySlots[daySlots.length - 1], timezone); + + const localStartHour = localStartTime.getHours(); + const localEndHour = localEndTime.getHours(); + + const timeBlocks = []; + let numHours = 0; + // Handle overnight ranges + if (localEndHour < localStartHour) { + timeBlocks.push({ startHour: 0, endHour: localEndHour }); + timeBlocks.push({ startHour: localStartHour, endHour: 23 }); + numHours += localEndHour; + numHours += 24 - localStartHour; + } else { + timeBlocks.push({ startHour: localStartHour, endHour: localEndHour }); + numHours += localEndHour - localStartHour; + } + + const dayGroupedSlots = Array.from( + daySlots + .reduce((daysMap, slot) => { + const zonedDate = toZonedTime(slot, timezone); + const dayKey = zonedDate.toLocaleDateString("en-CA"); + + if (!daysMap.has(dayKey)) { + let dayLabel = ""; + if (eventRange.type === "weekday") { + dayLabel = zonedDate + .toLocaleDateString("en-US", { + weekday: "short", + }) + .toUpperCase() as keyof typeof eventRange.weekdays; + } else { + dayLabel = zonedDate.toLocaleDateString("en-US", { + weekday: "short", + month: "short", + day: "numeric", + }); + } + daysMap.set(dayKey, { + dayKey, + dayLabel, + date: zonedDate, + timeslots: [], + }); + } + daysMap.get(dayKey)!.timeslots.push(slot); + return daysMap; + }, new Map()) + .values(), + ); + + const numDays = differenceInCalendarDays(localEndTime, localStartTime) + 1; + + return { timeBlocks, dayGroupedSlots, numDays, numHours, error: null }; + }, [eventRange, timezone]); +} diff --git a/app/_lib/use-schedule-drag.tsx b/app/_lib/use-schedule-drag.tsx new file mode 100644 index 00000000..9feeb1f4 --- /dev/null +++ b/app/_lib/use-schedule-drag.tsx @@ -0,0 +1,183 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { generateDragSlots } from "./availability/utils"; + +type DragState = { + startSlot: string | null; + endSlot: string | null; + togglingOn: boolean | null; + lastToggledSlot: string | null; + lastTogglingState: boolean | null; +}; + +/** + * hook to manage the drag-to-select logic for the schedule grid. + * it includes state, event handlers, and global listeners for both mouse and touch events. + */ +export default function useScheduleDrag( + onToggle: (slotIso: string, togglingOn: boolean) => void, + mode: "paint" | "view" | "preview", +) { + const [draggedSlots, setDraggedSlots] = useState>(new Set()); + const [hoveredSlot, setHoveredSlot] = useState(null); + const [isShifting, setIsShifting] = useState(false); + const dragState = useRef({ + startSlot: null, + endSlot: null, + togglingOn: null, + lastTogglingState: null, + lastToggledSlot: null, + }); + const isDragging = useRef(false); + + useEffect(() => { + isDragging.current = + dragState.current.startSlot !== null || + dragState.current.endSlot !== null; + }, [dragState.current.startSlot, dragState.current.endSlot]); + + // track shift key state + useEffect(() => { + if (mode !== "paint") return; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Shift" && !isDragging.current) { + setIsShifting(true); + } + }; + const handleKeyUp = (e: KeyboardEvent) => { + if (e.key === "Shift" && !isDragging.current) { + setIsShifting(false); + } + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("keyup", handleKeyUp); + }; + }, [mode]); + + useEffect(() => { + if (dragState.current.lastToggledSlot === null) { + setIsShifting(false); + return; + } + if (isShifting && hoveredSlot) { + setDraggedSlots( + generateDragSlots(dragState.current.lastToggledSlot!, hoveredSlot), + ); + } + }, [hoveredSlot, isShifting]); + + function setDragSlot(slotIso: string) { + if (!dragState.current.startSlot) { + dragState.current.startSlot = slotIso; + } + dragState.current.endSlot = slotIso; + // update draggedSlots + setDraggedSlots( + generateDragSlots(dragState.current.startSlot, dragState.current.endSlot), + ); + } + + function resetDragSlots() { + dragState.current = { + startSlot: null, + endSlot: null, + togglingOn: null, + lastTogglingState: dragState.current.lastTogglingState, + lastToggledSlot: dragState.current.lastToggledSlot, + }; + setDraggedSlots(new Set()); + setHoveredSlot(null); + } + + // keeps onToggle ref up to date + const onToggleRef = useRef(onToggle); + useEffect(() => { + onToggleRef.current = onToggle; + }, [onToggle]); + + // handle stopping drag on mouseup/touchend anywhere + useEffect(() => { + const stopDragging = () => { + for (const slotIso of draggedSlots) { + onToggleRef.current(slotIso, dragState.current.togglingOn!); + } + // save last toggled slot for shift-dragging + if (dragState.current.endSlot) { + dragState.current.lastTogglingState = dragState.current.togglingOn; + dragState.current.lastToggledSlot = dragState.current.endSlot; + } + resetDragSlots(); + }; + + window.addEventListener("mouseup", stopDragging); + window.addEventListener("touchend", stopDragging); + + return () => { + window.removeEventListener("mouseup", stopDragging); + window.removeEventListener("touchend", stopDragging); + }; + }, [draggedSlots]); + + /* EVENT HANDLERS */ + + const handlePointerDown = useCallback( + (slotIso: string, isDisabled: boolean, toggleState: boolean) => { + if (mode !== "paint" || isDisabled) return; + if (isShifting) { + // take over the shift drag + dragState.current.startSlot = dragState.current.lastToggledSlot; + dragState.current.endSlot = slotIso; + dragState.current.togglingOn = dragState.current.lastTogglingState; + setDragSlot(slotIso); + } else { + setDragSlot(slotIso); + dragState.current.togglingOn = !toggleState; + } + }, + [mode, isShifting], + ); + + const handlePointerEnter = useCallback( + (slotIso: string, isDisabled: boolean) => { + if (mode !== "paint" || isDisabled) return; + setHoveredSlot(slotIso); + if (!isDragging.current) return; + setDragSlot(slotIso); + }, + [mode], + ); + + const handlePointerLeave = useCallback(() => { + setHoveredSlot(null); + }, []); + + const handleTouchMove = useCallback( + (event: React.TouchEvent) => { + if (mode !== "paint" || !isDragging.current) return; + + // get touchpoint + const touch = event.touches[0]; + const target = document.elementFromPoint(touch.clientX, touch.clientY); + + if (target instanceof HTMLElement && target.dataset.slotIso) { + const currentSlotIso = target.dataset.slotIso; + setDragSlot(currentSlotIso); + } + }, + [mode], + ); + + return { + onPointerDown: handlePointerDown, + onPointerEnter: handlePointerEnter, + onPointerLeave: handlePointerLeave, + onTouchMove: handleTouchMove, + draggedSlots: draggedSlots, + togglingOn: isShifting + ? dragState.current.lastTogglingState + : dragState.current.togglingOn, + hoveredSlot: hoveredSlot, + }; +} diff --git a/app/_types/date-range-types.tsx b/app/_types/date-range-types.tsx deleted file mode 100644 index 5b0ebabd..00000000 --- a/app/_types/date-range-types.tsx +++ /dev/null @@ -1,17 +0,0 @@ -// _types/date-range-types.ts - -import { EventRange, WeekdayMap } from "@/app/_types/schedule-types"; - -export type DateRangeProps = { - eventRange?: EventRange; - onChangeEventRange?: (range: EventRange) => void; - displayCalendar?: boolean; - - // optional legacy support props - rangeType?: "specific" | "weekday"; - onChangeRangeType?: (type: "specific" | "weekday") => void; - specificRange?: { from: Date | null; to: Date | null }; - onChangeSpecific?: (key: "from" | "to", value: Date) => void; - weekdayRange?: WeekdayMap; - onChangeWeekday?: (map: WeekdayMap) => void; -}; diff --git a/app/_types/schedule-types.tsx b/app/_types/schedule-types.tsx deleted file mode 100644 index 24f73644..00000000 --- a/app/_types/schedule-types.tsx +++ /dev/null @@ -1,115 +0,0 @@ -export type TimeDateRange = { - from: Date | null; - to: Date | null; -}; - -// generic weekday mode map -export type WeekdayMap = { - [day in "Sun" | "Mon" | "Tue" | "Wed" | "Thu" | "Fri" | "Sat"]: 0 | 1; -}; - -// specific date mode -export type SpecificDateRange = { - type: "specific"; - duration: number; - timezone: string; - dateRange: TimeDateRange; - timeRange: TimeDateRange; -}; - -// generic weekday mode -export type WeekdayRange = { - type: "weekday"; - duration: number; - timezone: string; - weekdays: WeekdayMap; - timeRange: TimeDateRange; -}; - -// unified type -export type EventRange = SpecificDateRange | WeekdayRange; - -export type DateTimeSlot = { - day: string; - from: Date; - to: Date; -}; - -// Combine a date and a time-only object into a full Date object -export function combineDateAndTime(date: Date, time: Date): Date { - const result = new Date(date); - result.setHours(time.getHours(), time.getMinutes(), 0, 0); - return result; -} - -// Generate all concrete weekday instances for a given reference week -export function generateConcreteInstancesForWeek( - range: WeekdayRange, - weekStart: Date, -): DateTimeSlot[] { - const slots: DateTimeSlot[] = []; - - const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; - const baseFrom = range.timeRange.from; - const baseTo = range.timeRange.to; - - if (!baseFrom || !baseTo) return []; - - for (let i = 0; i < 7; i++) { - const day = days[i] as keyof WeekdayMap; - if (range.weekdays[day]) { - const date = new Date(weekStart); - date.setDate(weekStart.getDate() + i); - - const from = combineDateAndTime(date, baseFrom); - const to = combineDateAndTime(date, baseTo); - - slots.push({ day, from, to }); - } - } - - return slots; -} - -// Expand a specific date range into a list of dates (e.g., for previewing day-by-day) -export function expandDateRange(range: TimeDateRange): Date[] { - const { from, to } = range; - if (!from || !to) return []; - const days: Date[] = []; - - let current = new Date(from); - current.setHours(0, 0, 0, 0); - - const end = new Date(to); - end.setHours(0, 0, 0, 0); - - while (current <= end) { - days.push(new Date(current)); - current.setDate(current.getDate() + 1); - } - - return days; -} - -// Format a weekday map into a list of active days (e.g., ["Mon", "Wed"]) -export function getEnabledWeekdays(weekdays: WeekdayMap): string[] { - return Object.entries(weekdays) - .filter(([, v]) => v === 1) - .map(([k]) => k); -} - -// Create a list of visible day labels from a specific date range -export function getDateLabels({ from, to }: TimeDateRange): string[] { - const labels: string[] = []; - const range = expandDateRange({ from, to }); - for (const date of range) { - const weekday = date - .toLocaleDateString("en-US", { weekday: "short" }) - .toUpperCase(); - const monthDay = date - .toLocaleDateString("en-US", { month: "short", day: "numeric" }) - .toUpperCase(); - labels.push(`${weekday} ${monthDay}`); - } - return labels; -} diff --git a/app/_types/user-availability.tsx b/app/_types/user-availability.tsx deleted file mode 100644 index 7be7009c..00000000 --- a/app/_types/user-availability.tsx +++ /dev/null @@ -1,72 +0,0 @@ -// keys are either weekdays or specific date strings: -// for specific days: "2025-05-10" -// for weekdays: "Mon", "Tue", etc. -// values are sets timeslots where the user is available -export type AvailabilityMap = Record>; - -export type UserAvailability = { - type: "specific" | "weekday"; - selections: AvailabilityMap; -}; - -// Initialize empty availability -export const createEmptyUserAvailability = ( - type: "specific" | "weekday" = "specific", -): UserAvailability => ({ - type, - selections: {}, -}); - -// Toggle a cell's availability (add/remove hour from Set) -export function toggleAvailability( - prev: UserAvailability, - key: string, - hour: number, -): UserAvailability { - const updated = { ...prev, selections: { ...prev.selections } }; - const hours = new Set(updated.selections[key] || []); - - if (hours.has(hour)) { - hours.delete(hour); - } else { - hours.add(hour); - } - - updated.selections[key] = hours; - return updated; -} - -// Check if a user is available at a specific key + hour -export function isAvailable( - user: UserAvailability, - key: string, - hour: number, -): boolean { - return user.selections[key]?.has(hour) ?? false; -} - -// Add multiple hours for drag selection -export function addAvailability( - prev: UserAvailability, - key: string, - hour: number, -): UserAvailability { - const updated = { ...prev, selections: { ...prev.selections } }; - const hours = new Set(updated.selections[key] || []); - hours.add(hour); - updated.selections[key] = hours; - return updated; -} - -// Remove hours for drag deselection (optional if you want toggling behavior) -export function removeAvailability( - prev: UserAvailability, - key: string, - hour: number, -): UserAvailability { - const updated = { ...prev, selections: { ...prev.selections } }; - const hours = new Set(updated.selections[key] || []); - hours.delete(hour); - updated.selections[key] = hours; - return updated; -} diff --git a/app/_utils/cookie-utils.tsx b/app/_utils/cookie-utils.tsx new file mode 100644 index 00000000..9e2c2cee --- /dev/null +++ b/app/_utils/cookie-utils.tsx @@ -0,0 +1,14 @@ +import { cookies } from "next/headers"; + +export async function getAuthCookieString(): Promise { + const cookieStore = await cookies(); + let cookieHeader = ""; + const authCookieNames = ["account_sess_token", "guest_sess_token"]; + authCookieNames.forEach((name) => { + const cookie = cookieStore.get(name); + if (cookie) { + cookieHeader += `${name}=${cookie.value}; `; + } + }); + return cookieHeader.trim(); +} diff --git a/app/_utils/fetch-data.tsx b/app/_utils/fetch-data.tsx new file mode 100644 index 00000000..3f51a602 --- /dev/null +++ b/app/_utils/fetch-data.tsx @@ -0,0 +1,141 @@ +import formatApiError from "@/app/_utils/format-api-error"; + +export type EventDetailsResponse = { + title: string; + duration?: number; + start_hour: number; + end_hour: number; + time_zone: string; + event_type: "Date" | "Week"; + start_date?: string; + end_date?: string; + start_weekday?: number; + end_weekday?: number; +}; + +export async function fetchEventDetails( + eventCode: string, + cookieHeader?: string, +): Promise { + const baseUrl = process.env.API_URL; + const res = await fetch( + `${baseUrl}/event/get-details/?event_code=${eventCode}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader ? cookieHeader : "", + }, + cache: "no-store", + }, + ); + + if (!res.ok) { + const errorMessage = formatApiError(await res.json()); + throw new Error("Failed to fetch event details: " + errorMessage); + } + + return res.json(); +} + +export type AvailabilityDataResponse = { + is_creator: boolean; + user_display_name: string | null; + participants: string[]; + availability: Record; +}; + +export async function fetchAvailabilityData( + eventCode: string, + cookieHeader: string, +): Promise { + const baseUrl = process.env.API_URL; + const res = await fetch( + `${baseUrl}/availability/get-all/?event_code=${eventCode}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, + cache: "no-store", + }, + ); + + if (!res.ok) { + const errorMessage = formatApiError(await res.json()); + throw new Error("Failed to fetch availability data: " + errorMessage); + } + + return res.json(); +} + +export type SelfAvailabilityResponse = { + display_name: string | null; + time_zone: string; + available_dates: string[]; +}; + +export async function fetchSelfAvailability( + eventCode: string, + cookieHeader: string, +): Promise { + const baseUrl = process.env.API_URL; + const res = await fetch( + `${baseUrl}/availability/get-self/?event_code=${eventCode}`, + { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, + cache: "no-store", + }, + ); + + if (!res.ok) { + return null; + } + + return res.json(); +} + +export type DashboardEventResponse = { + title: string; + duration?: number; + start_hour: number; + end_hour: number; + time_zone: string; + event_type: "Date" | "Week"; + start_date?: string; + end_date?: string; + start_weekday?: number; + end_weekday?: number; + event_code: string; +}; + +export type DashboardResponse = { + created_events: DashboardEventResponse[]; + participated_events: DashboardEventResponse[]; +}; + +export async function fetchDashboard( + cookieHeader: string, +): Promise { + const baseUrl = process.env.API_URL; + const res = await fetch(`${baseUrl}/dashboard/get`, { + method: "GET", + headers: { + "Content-Type": "application/json", + Cookie: cookieHeader, + }, + cache: "no-store", + }); + + if (!res.ok) { + const errorMessage = formatApiError(await res.json()); + throw new Error("Failed to fetch dashboard events: " + errorMessage); + } + + return res.json(); +} diff --git a/app/_utils/format-api-error.tsx b/app/_utils/format-api-error.tsx new file mode 100644 index 00000000..17305daa --- /dev/null +++ b/app/_utils/format-api-error.tsx @@ -0,0 +1,42 @@ +function snakeToTitleCase(str: string): string { + return str + .split("_") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +type ApiErrorResponse = { + error: { + [key: string]: string[]; + }; +}; + +export default function formatApiError(errors: ApiErrorResponse): string { + let errorMessage = ""; + let generalMessage = ""; + const errorFields = errors.error; + + if (errorFields.general) { + generalMessage = errorFields.general[0]; + } + + for (const field in errorFields) { + if (field !== "general" && Array.isArray(errorFields[field])) { + for (const msg of errorFields[field]) { + const fieldTitle = snakeToTitleCase(field); + errorMessage += `${fieldTitle}: ${msg}\n`; + } + } + } + + if (errorMessage) { + if (generalMessage) { + return generalMessage + "\n" + errorMessage.trim(); + } + return errorMessage.trim(); + } else if (generalMessage) { + return generalMessage; + } + + return "An unknown error has occurred."; +} diff --git a/app/_utils/process-dashboard-data.tsx b/app/_utils/process-dashboard-data.tsx new file mode 100644 index 00000000..3422744d --- /dev/null +++ b/app/_utils/process-dashboard-data.tsx @@ -0,0 +1,53 @@ +import { DashboardEventProps } from "../ui/components/dashboard/dashboard-event"; +import { DashboardPageProps } from "../ui/layout/dashboard-page"; +import { DashboardEventResponse, DashboardResponse } from "./fetch-data"; + +function processSingleEvent( + myEvent: boolean, + eventData: DashboardEventResponse, +): DashboardEventProps { + if (eventData.event_type === "Date") { + const data: DashboardEventProps = { + myEvent: myEvent, + code: eventData.event_code, + title: eventData.title, + type: "specific", + startHour: eventData.start_hour, + endHour: eventData.end_hour, + startDate: eventData.start_date, + endDate: eventData.end_date, + }; + return data; + } else { + const data: DashboardEventProps = { + myEvent: myEvent, + code: eventData.event_code, + title: eventData.title, + type: "weekday", + startHour: eventData.start_hour, + endHour: eventData.end_hour, + startWeekday: eventData.start_weekday, + endWeekday: eventData.end_weekday, + }; + return data; + } +} + +export function processDashboardData( + eventData: DashboardResponse, +): DashboardPageProps { + const processedEvents = { + created_events: [] as DashboardEventProps[], + participated_events: [] as DashboardEventProps[], + }; + + for (const event of eventData.created_events) { + processedEvents.created_events.push(processSingleEvent(true, event)); + } + + for (const event of eventData.participated_events) { + processedEvents.participated_events.push(processSingleEvent(false, event)); + } + + return processedEvents; +} diff --git a/app/_utils/process-event-data.tsx b/app/_utils/process-event-data.tsx new file mode 100644 index 00000000..439efe4e --- /dev/null +++ b/app/_utils/process-event-data.tsx @@ -0,0 +1,44 @@ +import { generateWeekdayMap } from "@/app/_lib/schedule/utils"; +import { EventRange } from "../_lib/schedule/types"; +import { EventDetailsResponse } from "./fetch-data"; + +export function processEventData(eventData: EventDetailsResponse): { + eventName: string; + eventRange: EventRange; +} { + const eventName: string = eventData.title; + let eventRange: EventRange; + + if (eventData.event_type === "Date") { + eventRange = { + type: "specific", + duration: eventData.duration || 0, + timezone: eventData.time_zone, + dateRange: { + from: eventData.start_date!, + to: eventData.end_date!, + }, + timeRange: { + from: eventData.start_hour, + to: eventData.end_hour, + }, + }; + } else { + const weekdays = generateWeekdayMap( + eventData.start_weekday!, + eventData.end_weekday!, + ); + eventRange = { + type: "weekday", + duration: eventData.duration || 0, + timezone: eventData.time_zone, + weekdays: weekdays, + timeRange: { + from: eventData.start_hour, + to: eventData.end_hour, + }, + }; + } + + return { eventName, eventRange }; +} diff --git a/app/_utils/providers.tsx b/app/_utils/providers.tsx deleted file mode 100644 index d0da32bd..00000000 --- a/app/_utils/providers.tsx +++ /dev/null @@ -1,11 +0,0 @@ -"use client"; - -import { ThemeProvider } from "next-themes"; - -export function Providers({ children }: { children: React.ReactNode }) { - return ( - - {children} - - ); -} diff --git a/app/_utils/submit-event.tsx b/app/_utils/submit-event.tsx new file mode 100644 index 00000000..3f98a964 --- /dev/null +++ b/app/_utils/submit-event.tsx @@ -0,0 +1,138 @@ +import { + EventRange, + SpecificDateRange, + WeekdayRange, +} from "../_lib/schedule/types"; +import { findRangeFromWeekdayMap } from "../_lib/schedule/utils"; +import { EventEditorType } from "../ui/layout/event-editor"; +import formatApiError from "./format-api-error"; + +export type EventSubmitData = { + title: string; + code: string; + eventRange: EventRange; +}; + +type EventSubmitJsonBody = { + title: string; + duration?: number; + time_zone: string; + start_hour: number; + end_hour: number; + start_date?: string; + end_date?: string; + start_weekday?: number; + end_weekday?: number; + custom_code?: string; + event_code?: string; +}; + +const formatDate = (date: Date): string => { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, "0"); // Months are 0-indexed + const day = String(date.getUTCDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +}; + +export default async function submitEvent( + data: EventSubmitData, + type: EventEditorType, + eventType: "specific" | "weekday", + onSuccess: (code: string) => void, +): Promise { + let apiRoute = ""; + let jsonBody: EventSubmitJsonBody; + + if (eventType === "specific") { + apiRoute = + type === "new" ? "/api/event/date-create/" : "/api/event/date-edit/"; + + // check if the date range is more than 30 days + const fromDate = new Date( + (data.eventRange as SpecificDateRange).dateRange.from, + ); + const toDate = new Date( + (data.eventRange as SpecificDateRange).dateRange.to, + ); + if (toDate.getTime() - fromDate.getTime() > 30 * 24 * 60 * 60 * 1000) { + alert("Too many days selected. Max is 30 days."); + return; + } + + jsonBody = { + title: data.title, + time_zone: data.eventRange.timezone, + start_date: formatDate( + new Date((data.eventRange as SpecificDateRange).dateRange.from), + ), + end_date: formatDate( + new Date((data.eventRange as SpecificDateRange).dateRange.to), + ), + start_hour: data.eventRange.timeRange.from, + end_hour: data.eventRange.timeRange.to, + }; + } else { + apiRoute = + type === "new" ? "/api/event/week-create/" : "/api/event/week-edit/"; + + const weekdayRange = findRangeFromWeekdayMap( + (data.eventRange as WeekdayRange).weekdays, + ); + if (weekdayRange.startDay === null || weekdayRange.endDay === null) { + alert("Please select at least one weekday."); + return; + } + + const dayNameToIndex: { [key: string]: number } = { + Sun: 0, + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + }; + jsonBody = { + title: data.title, + time_zone: data.eventRange.timezone, + start_weekday: dayNameToIndex[weekdayRange.startDay!], + end_weekday: dayNameToIndex[weekdayRange.endDay!], + start_hour: data.eventRange.timeRange.from, + end_hour: data.eventRange.timeRange.to, + }; + } + + // only include duration if set + if (data.eventRange.duration && data.eventRange.duration > 0) { + jsonBody.duration = data.eventRange.duration; + } + + if (type === "new" && data.code) { + jsonBody.custom_code = data.code; + } else if (type === "edit") { + jsonBody.event_code = data.code; + } + + await fetch(apiRoute, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(jsonBody), + }) + .then(async (res) => { + if (res.ok) { + const code = (await res.json()).event_code; + if (type === "new") { + onSuccess(code); + } else { + // endpoint does not return code on edit + onSuccess(data.code); + } + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); +} diff --git a/app/_utils/use-theme.tsx b/app/_utils/use-theme.tsx deleted file mode 100644 index d6d191c2..00000000 --- a/app/_utils/use-theme.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { useState, useEffect, useCallback } from "react"; - -export default function useDarkMode() { - const [isDarkMode, setIsDarkMode] = useState(null); - const [isClientReady, setIsClientReady] = useState(false); - - useEffect(() => { - const savedTheme = localStorage.getItem("theme"); - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - - const dark = savedTheme ? savedTheme === "dark" : prefersDark; - - setIsDarkMode(dark); - setIsClientReady(true); - - const html = document.documentElement; - html.classList.toggle("dark", dark); - }, []); - - const toggleDarkMode = useCallback(() => { - if (isDarkMode === null) return; - - const newMode = !isDarkMode; - setIsDarkMode(newMode); - const html = document.documentElement; - - if (newMode) { - html.classList.add("dark"); - localStorage.setItem("theme", "dark"); - } else { - html.classList.remove("dark"); - localStorage.setItem("theme", "light"); - } - }, [isDarkMode]); - - return { - isDarkMode: isDarkMode ?? false, - isClientReady, - toggleDarkMode, - }; -} diff --git a/app/_utils/validate-data.tsx b/app/_utils/validate-data.tsx new file mode 100644 index 00000000..925197b3 --- /dev/null +++ b/app/_utils/validate-data.tsx @@ -0,0 +1,78 @@ +import { EventInformation } from "@/app/_lib/schedule/types"; +import { AvailabilityState } from "../_lib/availability/availability-reducer"; + +export async function validateEventData( + data: EventInformation, +): Promise> { + const errors: Record = {}; + const { title, customCode, eventRange } = data; + + if (!title?.trim()) { + errors.title = "Please enter an event name."; + } else if (title.length > 50) { + errors.title = "Event name must be under 50 characters."; + } + + if (customCode) { + try { + const response = await fetch("/api/event/check-code/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ custom_code: customCode }), + }); + if (!response.ok) { + errors.customCode = "This code is unavailable. Please choose another."; + } + } catch { + errors.api = "Could not verify the custom code. Please try again."; + } + } + + if ( + eventRange.type === "specific" && + (!eventRange.dateRange?.from || !eventRange.dateRange?.to) + ) { + errors.dateRange = "Please select a valid date range."; + } + + if (eventRange.timeRange.from >= eventRange.timeRange.to) { + errors.timeRange = "Please select a valid time range."; + } + + return errors; +} + +export async function validateAvailabilityData( + data: AvailabilityState, + eventCode: string, +): Promise> { + const errors: Record = {}; + const { displayName, userAvailability } = data; + + if (!displayName?.trim()) { + errors.displayName = "Please enter your name."; + } else { + try { + const response = await fetch("/api/availability/check-display-name/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + event_code: eventCode, + display_name: displayName, + }), + }); + if (!response.ok) { + errors.displayName = + "This name is already taken. Please choose another."; + } + } catch { + errors.api = "Could not verify name availability. Please try again."; + } + } + + if (!userAvailability || userAvailability.size === 0) { + errors.availability = "Please select your availability on the grid."; + } + + return errors; +} diff --git a/app/dashboard/loading.tsx b/app/dashboard/loading.tsx index 3c7a2b8b..497d8837 100644 --- a/app/dashboard/loading.tsx +++ b/app/dashboard/loading.tsx @@ -1,7 +1,20 @@ +import HeaderSpacer from "@/app/ui/components/header/header-spacer"; + export default function Loading() { return ( -
-
+
+ + +

Dashboard

+ +
+
+
+
+
+ +
+
); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index 46e7ba5a..7399ae7e 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -1,5 +1,13 @@ -import React from "react"; +import { getAuthCookieString } from "../_utils/cookie-utils"; +import { fetchDashboard } from "../_utils/fetch-data"; +import { processDashboardData } from "../_utils/process-dashboard-data"; +import DashboardPage from "../ui/layout/dashboard-page"; -export default function page() { - return
dashboard page
; +export default async function Page() { + const authCookies = await getAuthCookieString(); + + const eventData = await fetchDashboard(authCookies); + const processedData = processDashboardData(eventData); + + return ; } diff --git a/app/error.tsx b/app/error.tsx new file mode 100644 index 00000000..358d3469 --- /dev/null +++ b/app/error.tsx @@ -0,0 +1,33 @@ +"use client"; + +import { useEffect } from "react"; + +export default function EventErrorPage({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + useEffect(() => { + // You can log the error to an error reporting service like Sentry + console.error(error); + }, [error]); + + return ( +
+

+ Oops! Something went wrong. +

+ +

{error.message}

+ + +
+ ); +} diff --git a/app/forgot-password/page.tsx b/app/forgot-password/page.tsx new file mode 100644 index 00000000..af1b4b87 --- /dev/null +++ b/app/forgot-password/page.tsx @@ -0,0 +1,99 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import React, { useRef, useState } from "react"; +import formatApiError from "../_utils/format-api-error"; +import MessagePage from "../ui/layout/message-page"; +import LinkText from "../ui/components/link-text"; +import TextInputField from "../ui/components/auth/text-input-field"; + +export default function Page() { + const [email, setEmail] = useState(""); + const [emailSent, setEmailSent] = useState(false); + const isSubmitting = useRef(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (isSubmitting.current) return; + isSubmitting.current = true; + + if (!email) { + alert("Missing email"); + isSubmitting.current = false; + return; + } + + await fetch("/api/auth/start-password-reset/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }) + .then(async (res) => { + if (res.ok) { + setEmailSent(true); + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); + + isSubmitting.current = false; + }; + + return ( +
+ {emailSent ? ( + router.push("/login"), + }, + ]} + /> + ) : ( +
+ {/* Title */} +

+ forgot password +

+ + {/* Email */} + + +
+ {/* Forgot Password */} + + Remembered password? + + + {/* Email Button */} + +
+ + )} +
+ ); +} diff --git a/app/globals.css b/app/globals.css index 159f72d2..388d12a0 100644 --- a/app/globals.css +++ b/app/globals.css @@ -4,58 +4,139 @@ :root { --background: #f5f5f5; --foreground: #3e3c53; + --bone-base: #e9deca; + --red-base: #ff5c5c; + --white-base: #ffffff; + --bone: #e9deca; + --lion: #deb887; + --violet: #3e3c53; + --stone: #9ca3af; + --red: #ff6b6b; --calendar-accent: var(--color-blue); --calendar-accent-background: var(--color-blue-100); } -@media (prefers-color-scheme: dark) { - :root { - --background: #3e3c53; - --foreground: #e9deca; - --calendar-accent: var(--color-red); - --calendar-accent-background: var(--color-red-200); - } -} - .dark { - --background: #3e3c53; - --foreground: #e9deca; + --background: var(--violet); + --foreground: var(--bone); --calendar-accent: var(--color-red); - --calendar-accent-background: var(--color-red-200); + --calendar-accent-background: color-mix( + in srgb, + var(--color-red-200) 25%, + transparent + ); } body { background: var(--background); color: var(--foreground); + font-family: var(--font-nunito); } -.rdp-root { - --rdp-accent-color: var(--calendar-accent); - --rdp-accent-background-color: var(--calendar-accent-background); +.font-display { + font-family: var(--font-modak); + letter-spacing: -0.02em; + line-height: 0.9; +} + +.text-bone { + color: var(--bone); +} + +.text-lion { + color: var(--lion); +} + +.text-violet { + color: var(--violet); +} + +.text-outline { + -webkit-text-stroke: 2px currentColor; + color: transparent; +} + +.text-outline-dark { + -webkit-text-stroke: 2px var(--violet); + color: transparent; + text-shadow: 2px 2px 0px var(--violet); } -.text-stroke-white { - -webkit-text-stroke: 2px var(--color-white); +.text-outline-light { + -webkit-text-stroke: 2px var(--bone); + color: transparent; + text-shadow: 2px 2px 0px var(--bone); } -.text-stroke-violet { - -webkit-text-stroke: 2px var(--color-violet); +.text-outline-golden { + -webkit-text-stroke: 2px var(--lion); + color: transparent; } -.text-stroke-bone { - -webkit-text-stroke: 2px var(--color-bone); +.bubble-text { + font-family: var(--font-modak); + letter-spacing: 0.05em; + line-height: 1.1; } -.text-stroke-blue { - -webkit-text-stroke: 2px var(--color-blue); +.rdp-root { + --rdp-accent-color: var(--calendar-accent); + --rdp-accent-background-color: var(--calendar-accent-background); + --rdp-day_button-border: 3px solid transparent; } -.text-stroke-red { - -webkit-text-stroke: 2px var(--color-red); +.rdp-months { + display: flex; + justify-content: center; } -.text-stroke-lion { - -webkit-text-stroke: 2px var(--color-lion); +.frosted-glass { + backdrop-filter: blur(var(--blur-sm)); + /* copied from the generated tailwind css */ + border-style: var(--tw-border-style); + border-width: 1px; + /* dark background */ + background-color: color-mix(in srgb, var(--color-violet) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-violet) 20%, transparent); + } + /* dark border */ + border-color: color-mix(in srgb, var(--color-violet) 40%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-violet) 40%, transparent); + } + /* dark mode stuff */ + &:where(.dark, .dark *) { + /* light background */ + background-color: color-mix(in srgb, oklch(0.9702 0 0) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix( + in oklab, + var(--color-white) 10%, + transparent + ); + } + /* light border */ + border-color: color-mix(in srgb, oklch(0.9702 0 0) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + border-color: color-mix(in oklab, var(--color-white) 20%, transparent); + } + } + + /* shadow */ + box-shadow: var(--shadow-md); +} + +.frosted-glass .frosted-glass { + /* copied from the generated tailwind css */ + /* ALWAYS light background so it's not too dark against the parent glass */ + background-color: color-mix(in srgb, oklch(0.9702 0 0) 10%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 10%, transparent); + } + + /* no shadow on nested glass */ + box-shadow: none; } @custom-variant dark (&:where(.dark, .dark *)); @@ -80,12 +161,46 @@ body { cubic-bezier(0.16, 1, 0.3, 1); --animate-slideUp: slideUp 400ms cubic-bezier(0.16, 1, 0.3, 1); --animate-slideDown: slideDown 400ms cubic-bezier(0.16, 1, 0.3, 1); + --animate-hide: hide 100ms ease-in; + --animate-slideIn: slideIn 150ms cubic-bezier(0.16, 1, 0.3, 1); + --animate-swipeOut: swipeOut 100ms ease-out; + + @keyframes hide { + 0% { + opacity: 1; + } + + 100% { + opacity: 0; + } + } + + @keyframes slideIn { + 0% { + transform: translateX(calc(100% + var(--viewport-padding))); + } + + 100% { + transform: translateX(0); + } + } + + @keyframes swipeOut { + 0% { + transform: translateX(var(--radix-toast-swipe-end-x)); + } + + 100% { + transform: translateX(calc(100% + var(--viewport-padding))); + } + } @keyframes slideUpAndFade { 0% { opacity: 0; transform: translateY(2px); } + 100% { opacity: 1; transform: translateY(0); @@ -97,16 +212,19 @@ body { opacity: 0; transform: translateX(-2px); } + 100% { opacity: 1; transform: translateX(0); } } + @keyframes slideDownAndFade: { 0% { opacity: 0; transform: translateY(-2px); } + 100% { opacity: 1; transform: translateY(0); @@ -118,6 +236,7 @@ body { opacity: 0; transform: translateX(2px); } + 100% { opacity: 1; transform: translateX(0); @@ -128,6 +247,7 @@ body { 0% { transform: translateY(100%); } + 100% { transform: translateY(0); } @@ -137,6 +257,7 @@ body { 0% { transform: translateY(0); } + 100% { transform: translateY(100%); } diff --git a/app/layout.tsx b/app/layout.tsx index f64983ca..1636f0a3 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { Modak, Nunito } from "next/font/google"; -import Header from "./ui/layout/header"; -import { Providers } from "./_utils/providers"; +import Header from "./ui/components/header/header"; +import { Providers } from "@/app/_lib/providers"; import "./globals.css"; const modak = Modak({ @@ -19,15 +19,15 @@ const nunito = Nunito({ }); export const metadata: Metadata = { - title: "tomeeto", - description: "to meet or not to meet", + title: "Plancake", + description: "Stacking up perfect plans, one pancake at a time", }; export default function RootLayout({ children, -}: Readonly<{ +}: { children: React.ReactNode; -}>) { +}) { return ( -
+
{children} diff --git a/app/schedule/results/loading.tsx b/app/loading.tsx similarity index 51% rename from app/schedule/results/loading.tsx rename to app/loading.tsx index 3c7a2b8b..44ebee43 100644 --- a/app/schedule/results/loading.tsx +++ b/app/loading.tsx @@ -1,7 +1,9 @@ export default function Loading() { return (
-
+

+ loading... +

); } diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 00000000..1114b36d --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,116 @@ +"use client"; + +import React, { useRef, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import formatApiError from "../_utils/format-api-error"; +import { LoginContext } from "@/app/_lib/providers"; +import { useContext } from "react"; +import Checkbox from "../ui/components/checkbox"; +import TextInputField from "../ui/components/auth/text-input-field"; +import LinkText from "../ui/components/link-text"; + +export default function Page() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [rememberMe, setRememberMe] = useState(false); + const { setLoggedIn } = useContext(LoginContext); + const isSubmitting = useRef(false); + const router = useRouter(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (isSubmitting.current) return; + isSubmitting.current = true; + + if (!email) { + alert("Missing email"); + isSubmitting.current = false; + return; + } + if (!password) { + alert("Missing password"); + isSubmitting.current = false; + return; + } + + await fetch("/api/auth/login/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password, remember_me: rememberMe }), + }) + .then(async (res) => { + if (res.ok) { + setLoggedIn(true); + router.push("/dashboard"); + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); + + isSubmitting.current = false; + }; + + return ( +
+
+ {/* Title */} +

+ login +

+ + {/* Email */} + + + {/* Password */} + + +
+
+ {/* Remember Me Checkbox */} + setRememberMe(!rememberMe)} + /> + {/* Forgot Password */} + + Forgot password? + +
+ + {/* Login Button */} + +
+ + {/* Register Link */} +
+ No account?{" "} + + Register! + +
+ +
+ ); +} diff --git a/app/new-event/page.tsx b/app/new-event/page.tsx index c8cfe65c..db953b5e 100644 --- a/app/new-event/page.tsx +++ b/app/new-event/page.tsx @@ -1,140 +1,5 @@ -"use client"; - -import { useState } from "react"; - -import TimeDropdown from "../ui/components/time-dropdown"; -import DateRangeSelector from "../ui/components/date-range/date-range-selector"; -import TimezoneSelect from "../ui/components/timezone-select"; -import CustomSelect from "../ui/components/custom-select"; -import GridPreviewDialog from "../ui/components/schedule/grid-preview-dialog"; - -import { EventRange } from "../_types/schedule-types"; +import EventEditor from "../ui/layout/event-editor"; export default function Page() { - const defaultTZ = Intl.DateTimeFormat().resolvedOptions().timeZone; - - const [eventRange, setEventRange] = useState({ - type: "specific", - duration: 60, - timezone: defaultTZ, - dateRange: { from: new Date(), to: new Date() }, - timeRange: { from: new Date(), to: new Date() }, - }); - - const handleTZChange = (newTZ: string | number) => { - setEventRange((prev) => ({ - ...prev, - timezone: newTZ.toString(), - })); - }; - - const duationOptions = [ - { label: "30 minutes", value: 30 }, - { label: "45 minutes", value: 45 }, - { label: "1 hour", value: 60 }, - ]; - - const handleDurationChange = (newDuration: string | number) => { - const duration = Number(newDuration); - setEventRange((prev) => ({ - ...prev, - duration, - })); - }; - - const handleTimeChange = (key: "from" | "to", value: Date) => { - setEventRange((prev) => ({ - ...prev, - timeRange: { - ...prev.timeRange, - [key]: value, - }, - })); - }; - - const handleEventRangeChange = (range: EventRange) => { - setEventRange(range); - }; - - return ( -
- -
- {/* Prompt */} -
- What times and dates is this event? -
- - {/* Date range picker */} -
- -
- - {/* From/To */} -
- - handleTimeChange("from", from)} - /> -
-
- - handleTimeChange("to", to)} - /> -
- - {/* Timezone */} -
-
- - -
- -
- -
- - - {/*
- - - -
- */} -
-
- -
- ); + return ; } diff --git a/app/page.tsx b/app/page.tsx index fcd0ebc6..d0eb8e28 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,29 +1,172 @@ -"use client"; - -import { useRouter } from "next/navigation"; +import Link from "next/link"; +import Logo from "./ui/components/logo"; export default function Home() { - const router = useRouter().push; - return ( -
- - { - if (e.key === "Enter") { - router("/schedule/availability/" + e.currentTarget.value); - } - }} - placeholder="or enter code here" - className="w-3/4 border-b-2 text-center focus:outline-none" - /> -
+
+ {/* Hero Section */} +
+
+

+ + planning made + + + stack +
+ simple +
+

+

+ The fluffiest way to coordinate schedules and plan group events. + Stack up availability and serve the perfect meeting time. +

+
+ + Mix your first plan + + + View Dashboard + +
+
+
+ + {/* Why Plancake Section */} +
+
+
+
+ {/* Pancake emoji - centered on mobile */} +
+
🥞
+
+ + {/* Content - centered on mobile, left-aligned on desktop */} +
+

+ why +
+ plancake? +

+
+
+

+ Smart Planning +

+

+ Intelligently suggest optimal meeting times based on + everyone's availability. +

+
+
+

+ Easy Coordination +

+

+ Share a simple link and watch as responses stack up + without the back-and-forth. +

+
+
+

+ Perfect Results +

+

+ Get the ideal meeting time that works for everyone with an + intuitive graph view. +

+
+
+
+
+
+
+
+ + {/* Golden Stack Recipe */} +
+
+
+

+ golden +
+ stack recipe +

+

+ Follow these simple steps to cook up the perfect schedule every + time. +

+
+ +
+
+
+
+ 🍳 +
+

Mix your event

+

+ Set up your meeting details, add time options, and customize + your preferences +

+
+
+
+ 📤 +
+

Share & Stack

+

+ Send the link to participants and watch responses stack up in + a flash +

+
+
+
+ 🥞 +
+

Flip & Serve

+

+ Review the results and serve up the ideal meeting time for + everyone +

+
+
+
+
+
+ + {/* Plan Today Section */} +
+
+

+ PLAN TODAY +

+
+ + Start Planning + +
+
+
+ +
+

+ © 2025 Plancake. Stacking up perfect plans, one pancake at a + time. +

+
+
+
+
); } diff --git a/app/register/email-sent/page.tsx b/app/register/email-sent/page.tsx new file mode 100644 index 00000000..b8c70f5d --- /dev/null +++ b/app/register/email-sent/page.tsx @@ -0,0 +1,80 @@ +"use client"; + +import formatApiError from "@/app/_utils/format-api-error"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import MessagePage from "../../ui/layout/message-page"; + +export default function Page() { + const router = useRouter(); + const lastEmailResend = useRef(Date.now()); + const [email, setEmail] = useState(""); + + useEffect(() => { + const storedEmail = sessionStorage.getItem("register_email"); + if (!storedEmail) { + // the user shouldn't be here + router.push("/login"); + } else { + setEmail(storedEmail); + // don't clear the email from storage, it creates problems when testing + // it should be deleted after the session ends anyway + } + }, [router]); // empty dependency array to run once on initial mount + + if (!email) { + // don't render until there is an email + return null; + } + + const handleResendEmail = async () => { + const emailResendCooldown = 30000; // 30 seconds + let timeLeft = + (emailResendCooldown - (Date.now() - lastEmailResend.current)) / 1000; + timeLeft = Math.ceil(timeLeft); + if (timeLeft > 0) { + alert(`Slow down! ${timeLeft} seconds until you can send again.`); + return; + } + + await fetch("/api/auth/resend-register-email/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }), + }) + .then(async (res) => { + if (res.ok) { + alert("Email resent. Please check your inbox."); + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); + + lastEmailResend.current = Date.now(); + }; + + return ( +
+ router.push("/login"), + }, + ]} + /> +
+ ); +} diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 00000000..b2a4b436 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,169 @@ +"use client"; + +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import React, { useEffect, useState } from "react"; +import formatApiError from "../_utils/format-api-error"; +import TextInputField from "../ui/components/auth/text-input-field"; +import { useDebounce } from "../_lib/use-debounce"; +import PasswordCriteria from "../ui/components/auth/password-criteria"; +import LinkText from "../ui/components/link-text"; + +export default function Page() { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordCriteria, setPasswordCriteria] = useState({}); + const isSubmitting = React.useRef(false); + const router = useRouter(); + + function passwordIsStrong() { + return Object.keys(passwordCriteria).length === 0; + } + + useDebounce(() => { + if (password.length === 0) { + setPasswordCriteria({}); + return; + } + + // Check that the password is strong enough with the API + fetch("/api/auth/check-password/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + }) + .then((res) => { + if (res.ok) { + res.json().then((data) => { + if (data.is_strong) { + setPasswordCriteria({}); + return; + } else { + setPasswordCriteria(data.criteria || {}); + } + }); + } else { + console.error("Fetch error:", res.status); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + }); + }, [password]); + + useEffect(() => { + if (password.length === 0) { + setPasswordCriteria({}); + return; + } + }, [password]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (isSubmitting.current) return; + isSubmitting.current = true; + + if (!email) { + alert("Missing email"); + isSubmitting.current = false; + return; + } + if (!password) { + alert("Missing password"); + isSubmitting.current = false; + return; + } + if (!passwordIsStrong()) { + alert("Password is not strong enough"); + isSubmitting.current = false; + return; + } + if (confirmPassword !== password) { + alert("Passwords do not match"); + isSubmitting.current = false; + return; + } + + await fetch("/api/auth/register/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }) + .then(async (res) => { + if (res.ok) { + sessionStorage.setItem("register_email", email); + router.push("/register/email-sent"); + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); + + isSubmitting.current = false; + }; + + return ( +
+
+ {/* Title */} +

+ register +

+ + {/* Email */} + + + {/* Password */} + + + {/* Password Errors */} + {!passwordIsStrong() && ( +
+ +
+ )} + + {/* Retype Password */} + + + {/* Register Button */} +
+ +
+ + {/* Login Link */} +
+ Already have an account?{" "} + + Login! + +
+ +
+ ); +} diff --git a/app/reset-password/page.tsx b/app/reset-password/page.tsx new file mode 100644 index 00000000..1fab2e87 --- /dev/null +++ b/app/reset-password/page.tsx @@ -0,0 +1,154 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import React, { useEffect, useRef, useState } from "react"; +import formatApiError from "../_utils/format-api-error"; +import { useDebounce } from "../_lib/use-debounce"; +import PasswordCriteria from "../ui/components/auth/password-criteria"; +import TextInputField from "../ui/components/auth/text-input-field"; + +export default function Page() { + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [passwordCriteria, setPasswordCriteria] = useState({}); + const isSubmitting = useRef(false); + const router = useRouter(); + + const searchParams = useSearchParams(); + const pwdResetToken = searchParams.get("token"); + + function passwordIsStrong() { + return Object.keys(passwordCriteria).length === 0; + } + + useDebounce(() => { + if (newPassword.length === 0) { + setPasswordCriteria({}); + return; + } + + // Check that the password is strong enough with the API + fetch("/api/auth/check-password/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password: newPassword }), + }) + .then((res) => { + if (res.ok) { + res.json().then((data) => { + if (data.is_strong) { + setPasswordCriteria({}); + return; + } else { + setPasswordCriteria(data.criteria || {}); + } + }); + } else { + console.error("Fetch error:", res.status); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + }); + }, [newPassword]); + + useEffect(() => { + if (newPassword.length === 0) { + setPasswordCriteria({}); + return; + } + }, [newPassword]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (isSubmitting.current) return; + isSubmitting.current = true; + + if (!pwdResetToken) { + alert("This link is expired or invalid."); + isSubmitting.current = false; + return; + } + + if (!newPassword) { + alert("Missing new password."); + isSubmitting.current = false; + return; + } + if (!passwordIsStrong()) { + alert("Password is not strong enough"); + isSubmitting.current = false; + return; + } + if (newPassword !== confirmPassword) { + alert("Passwords do not match."); + isSubmitting.current = false; + return; + } + await fetch("/api/auth/reset-password/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + reset_token: pwdResetToken, + new_password: newPassword, + }), + }) + .then(async (res) => { + if (res.ok) { + router.push("/reset-password/success"); + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); + + isSubmitting.current = false; + }; + + return ( +
+
+ {/* Title */} +

+ reset password +

+ + {/* New Password */} + + + {!passwordIsStrong() && ( +
+ +
+ )} + + {/* Confirm Password */} + + + {/* Change Password Button */} +
+ +
+ +
+ ); +} diff --git a/app/reset-password/success/page.tsx b/app/reset-password/success/page.tsx new file mode 100644 index 00000000..ef81fc1c --- /dev/null +++ b/app/reset-password/success/page.tsx @@ -0,0 +1,23 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import MessagePage from "../../ui/layout/message-page"; + +export default function Page() { + const router = useRouter(); + + return ( +
+ router.push("/login"), + }, + ]} + /> +
+ ); +} diff --git a/app/schedule/availability/not-found.tsx b/app/schedule/availability/not-found.tsx deleted file mode 100644 index abaee81f..00000000 --- a/app/schedule/availability/not-found.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function NotFound() { - return
404 - Page Not Found
; -} diff --git a/app/schedule/availability/page.tsx b/app/schedule/availability/page.tsx deleted file mode 100644 index c5a8f3c6..00000000 --- a/app/schedule/availability/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from "react"; - -export default function page() { - return
availability page
; -} diff --git a/app/schedule/layout.tsx b/app/schedule/layout.tsx deleted file mode 100644 index e353ee11..00000000 --- a/app/schedule/layout.tsx +++ /dev/null @@ -1,11 +0,0 @@ -export default function ScheduleLayout({ - children, -}: { - children: React.ReactNode; -}) { - return ( - <> -
{children}
- - ); -} diff --git a/app/schedule/results/not-found.tsx b/app/schedule/results/not-found.tsx deleted file mode 100644 index abaee81f..00000000 --- a/app/schedule/results/not-found.tsx +++ /dev/null @@ -1,3 +0,0 @@ -export default function NotFound() { - return
404 - Page Not Found
; -} diff --git a/app/schedule/results/page.tsx b/app/schedule/results/page.tsx deleted file mode 100644 index 5e395bb3..00000000 --- a/app/schedule/results/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import React from "react"; - -export default function page() { - return
results page
; -} diff --git a/app/ui/components/archive/date-range-drawer copy.tsx b/app/ui/components/archive/date-range-drawer copy.tsx deleted file mode 100644 index 48d271e9..00000000 --- a/app/ui/components/archive/date-range-drawer copy.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { Drawer } from "vaul"; -import { Calendar } from "./month-calendar"; -import { format } from "date-fns"; - -type Props = { - specificRange: { from: Date | null; to: Date | null }; - onChangeSpecific: (range: { from: Date | null; to: Date | null }) => void; -}; - -export default function DateRangeDrawer({ - specificRange, - onChangeSpecific, -}: Props) { - const displayFrom = specificRange.from - ? format(specificRange.from, "EEE, MMM d") - : ""; - const displayTo = specificRange.to - ? format(specificRange.to, "EEE, MMM d") - : ""; - - return ( - - -
- - onChangeSpecific({ - ...specificRange, - from: new Date(e.target.value), - }) - } - className="rounded-l-full border-1 border-violet-500 px-4 py-1 text-center hover:border-red focus:outline-none dark:border-gray-400" - aria-label="Start date" - /> - - onChangeSpecific({ - ...specificRange, - to: new Date(e.target.value), - }) - } - className="rounded-r-full border-1 border-violet-500 px-4 py-1 text-center hover:border-red focus:outline-none dark:border-gray-400" - aria-label="End date" - /> -
-
- - - - -
-
- - Select a Date Range - - onChangeSpecific({ from, to })} - /> -
- - - - ); -} - -const DateRange = ({ specificRange, onChangeSpecific }: Props) => { - const displayFrom = specificRange.from - ? format(specificRange.from, "EEE, MMM d") - : ""; - const displayTo = specificRange.to - ? format(specificRange.to, "EEE, MMM d") - : ""; - return ( -
- - onChangeSpecific({ - ...specificRange, - from: new Date(e.target.value), - }) - } - className="rounded-l-full border-1 border-violet-500 px-4 py-1 text-center hover:border-red focus:outline-none dark:border-gray-400" - aria-label="Start date" - /> - - onChangeSpecific({ - ...specificRange, - to: new Date(e.target.value), - }) - } - className="rounded-r-full border-1 border-violet-500 px-4 py-1 text-center hover:border-red focus:outline-none dark:border-gray-400" - aria-label="End date" - /> -
- ); -}; diff --git a/app/ui/components/archive/month-calendar.tsx b/app/ui/components/archive/month-calendar.tsx deleted file mode 100644 index 75ad862b..00000000 --- a/app/ui/components/archive/month-calendar.tsx +++ /dev/null @@ -1,116 +0,0 @@ -"use client"; - -import { ChangeEventHandler, useEffect, useState } from "react"; -import useCheckMobile from "@/app/_utils/use-check-mobile"; - -import { format, isAfter, isBefore, isValid, parse } from "date-fns"; -import { - DateRange, - DayPicker, - SelectRangeEventHandler, - getDefaultClassNames, -} from "react-day-picker"; -import "react-day-picker/dist/style.css"; - -type CalendarProps = { - className?: string; - onRangeSelect?: (from: string | null, to: string | null) => void; -}; - -export function Calendar({ className, onRangeSelect }: CalendarProps) { - const defaultClassNames = getDefaultClassNames(); - - const isMobile = useCheckMobile(); - const numberOfMonths = isMobile ? 12 : 2; - const hideNavigation = isMobile ? true : false; - - const today = new Date(); - - const [month, setMonth] = useState(today); - const [selectedRange, setSelectedRange] = useState(); - - // const [fromValue, setFromValue] = useState(""); - // const [toValue, setToValue] = useState(""); - - // const handleFromChange: ChangeEventHandler = (e) => { - // setFromValue(e.target.value); - // const date = parse(e.target.value, "EEE, MMM d", new Date()); - // if (!isValid(date)) { - // return setSelectedRange({ from: undefined, to: undefined }); - // } - // if (selectedRange?.to && isAfter(date, selectedRange.to)) { - // setSelectedRange({ from: selectedRange.to, to: date }); - // } else { - // setSelectedRange({ from: date, to: selectedRange?.to }); - // } - // }; - - // const handleToChange: ChangeEventHandler = (e) => { - // setToValue(e.target.value); - // const date = parse(e.target.value, "EEE, MMM d", new Date()); - - // if (!isValid(date)) { - // return setSelectedRange({ from: selectedRange?.from, to: undefined }); - // } - // if (selectedRange?.from && isBefore(date, selectedRange.from)) { - // setSelectedRange({ from: date, to: selectedRange.from }); - // } else { - // setSelectedRange({ from: selectedRange?.from, to: date }); - // } - // }; - - const handleRangeSelect: SelectRangeEventHandler = ( - range: DateRange | undefined, - ) => { - setSelectedRange(range); - const from = range?.from ? format(range.from, "EEE, MMM d") : ""; - const to = range?.to ? format(range.to, "EEE, MMM d") : ""; - // setFromValue(from); - // setToValue(to); - onRangeSelect?.(from, to); - }; - - return ( -
- {/*
- -
- - -
-
*/} - - -
- ); -} diff --git a/app/ui/components/auth/password-criteria.tsx b/app/ui/components/auth/password-criteria.tsx new file mode 100644 index 00000000..724b9a42 --- /dev/null +++ b/app/ui/components/auth/password-criteria.tsx @@ -0,0 +1,26 @@ +import { CheckIcon, Cross2Icon } from "@radix-ui/react-icons"; +import { cn } from "../../../_lib/classname"; + +type PasswordCriteriaProps = { + criteria: { [key: string]: boolean }; +}; + +export default function PasswordCriteria(props: PasswordCriteriaProps) { + return ( +
+ Your password must: + {Object.entries(props.criteria).map(([key, value], index) => ( +
+ {value ? : } + {key} +
+ ))} +
+ ); +} diff --git a/app/ui/components/auth/text-input-field.tsx b/app/ui/components/auth/text-input-field.tsx new file mode 100644 index 00000000..7370a7dd --- /dev/null +++ b/app/ui/components/auth/text-input-field.tsx @@ -0,0 +1,44 @@ +import { EyeNoneIcon, EyeOpenIcon } from "@radix-ui/react-icons"; +import { useState } from "react"; + +type FieldType = "text" | "email" | "password"; + +type TextInputFieldProps = { + type: FieldType; + placeholder: string; + value: string; + onChange: (value: string) => void; +}; + +export default function TextInputField(props: TextInputFieldProps) { + const { type, placeholder, value, onChange } = props; + const [showPassword, setShowPassword] = useState(false); + + return ( +
+ onChange(e.target.value)} + className={ + "w-full rounded-full border px-4 py-2 focus:ring-2 focus:outline-none" + + (type === "password" ? " pr-10" : "") + } + /> + {type === "password" && ( + + )} +
+ ); +} diff --git a/app/ui/components/checkbox.tsx b/app/ui/components/checkbox.tsx index f9371175..c3d20fcb 100644 --- a/app/ui/components/checkbox.tsx +++ b/app/ui/components/checkbox.tsx @@ -11,11 +11,14 @@ export default function Checkbox(props: CheckboxProps) { onChange(e.target.checked)} /> -
diff --git a/app/ui/components/custom-group-select.tsx b/app/ui/components/custom-group-select.tsx deleted file mode 100644 index 9083d553..00000000 --- a/app/ui/components/custom-group-select.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import * as Select from "@radix-ui/react-select"; -import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons"; -import { cn } from "@/app/_lib/classname"; -import { forwardRef } from "react"; - -type Option = { label: string; value: string }; -type GroupedOption = { label: string; options: Option[] }; - -type CustomSelectProps = { - value: string; - onValueChange: (value: string) => void; - groupedOptions?: GroupedOption[]; - className?: string; -}; - -export default function CustomGroupSelect({ - groupedOptions, - value, - onValueChange, - className, -}: CustomSelectProps) { - const allOptions = groupedOptions?.flatMap((g) => g.options) ?? []; - const current = allOptions.find((o) => o.value === value); - - return ( - - - {current?.label || "Select"} - - - - - - - - {groupedOptions?.map((group) => ( - - {group.options.map((option) => ( - - {option.label} - - ))} - - ))} - - - - - ); -} - -type SelectItemProps = { - value: string; - children: React.ReactNode; -}; - -const SelectItem = forwardRef( - ({ children, value }, ref) => { - return ( - - {children} - - - - - ); - }, -); - -SelectItem.displayName = "SelectItem"; - -const SelectGroup = forwardRef( - ({ children, value }, ref) => { - return ( - - - {value} - - {children} - - - ); - }, -); -SelectGroup.displayName = "SelectGroup"; diff --git a/app/ui/components/custom-select.tsx b/app/ui/components/custom-select.tsx deleted file mode 100644 index 1ddf5a6f..00000000 --- a/app/ui/components/custom-select.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import * as Select from "@radix-ui/react-select"; -import { CheckIcon, ChevronDownIcon } from "@radix-ui/react-icons"; -import { forwardRef } from "react"; -import { cn } from "@/app/_lib/classname"; - -type Option = { label: string; value: string | number }; -type GroupedOption = { label: string; options: Option[] }; - -type CustomSelectOptions = Option | GroupedOption; - -type CustomSelectProps = { - value: string | number; - options: CustomSelectOptions[]; - isGrouped?: boolean; - onValueChange: (value: string | number) => void; - className?: string; -}; - -export default function CustomSelect({ - value, - options, - isGrouped = false, - onValueChange, - className, -}: CustomSelectProps) { - // flatten options if they are grouped and fine the current selected option - const allOptions = isGrouped - ? (options as GroupedOption[]).flatMap((g) => g.options) - : (options as Option[]); - const current = allOptions.find((o) => o.value === value); - - return ( - onValueChange(isNaN(Number(v)) ? v : Number(v))} - > - - - - - - - - - - - {isGrouped - ? (options as GroupedOption[]).map((group) => ( - - {group.options.map((option) => ( - - {option.label} - - ))} - - )) - : (options as Option[]).map((option) => ( - - {option.label} - - ))} - - - - - ); -} - -type SelectProps = { - value: string | number; - children: React.ReactNode; -}; - -const SelectItem = forwardRef( - ({ children, value }, ref) => { - return ( - - {children} - - - - - ); - }, -); -SelectItem.displayName = "SelectItem"; - -const SelectGroup = forwardRef( - ({ children, value }, ref) => { - return ( - - - {value} - - {children} - - - ); - }, -); -SelectGroup.displayName = "SelectGroup"; diff --git a/app/ui/components/dashboard/dashboard-copy-button.tsx b/app/ui/components/dashboard/dashboard-copy-button.tsx new file mode 100644 index 00000000..7bd48dc3 --- /dev/null +++ b/app/ui/components/dashboard/dashboard-copy-button.tsx @@ -0,0 +1,51 @@ +import { cn } from "@/app/_lib/classname"; +import { useToast } from "@/app/_lib/toast-context"; +import { CopyIcon } from "@radix-ui/react-icons"; +import { MouseEvent } from "react"; + +export type DashboardCopyButtonProps = { + code: string; +}; + +export default function DashboardCopyButton({ + code, +}: DashboardCopyButtonProps) { + const { addToast } = useToast(); + const eventUrl = + typeof window !== "undefined" ? `${window.location.origin}/${code}` : ""; + + const copyToClipboard = async (e: MouseEvent) => { + e.preventDefault(); // avoid triggering the parent link + try { + await navigator.clipboard.writeText(eventUrl); + addToast({ + type: "success", + id: Date.now() + Math.random(), + title: "COPIED EVENT LINK!", + message: eventUrl, + icon: , + }); + } catch (err) { + console.error("Failed to copy: ", err); + addToast({ + type: "error", + id: Date.now() + Math.random(), + title: "COPY FAILED", + message: "Could not copy link to clipboard.", + }); + } + }; + + return ( + + ); +} diff --git a/app/ui/components/dashboard/dashboard-event.tsx b/app/ui/components/dashboard/dashboard-event.tsx new file mode 100644 index 00000000..2c76f582 --- /dev/null +++ b/app/ui/components/dashboard/dashboard-event.tsx @@ -0,0 +1,95 @@ +import { cn } from "@/app/_lib/classname"; +import { ClockIcon, Pencil1Icon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import DashboardCopyButton from "./dashboard-copy-button"; +import DateRangeRow from "./date-range-row"; +import WeekdayRow from "./weekday-row"; +import { MouseEvent } from "react"; + +export type DashboardEventProps = { + myEvent: boolean; + code: string; + title: string; + type: "specific" | "weekday"; + startHour: number; + endHour: number; + startDate?: string; + endDate?: string; + startWeekday?: number; + endWeekday?: number; +}; + +export default function DashboardEvent({ + myEvent = false, + code, + title, + type, + startHour, + endHour, + startDate, + endDate, + startWeekday, + endWeekday, +}: DashboardEventProps) { + const router = useRouter(); + + function navigateToEdit(e: MouseEvent) { + e.preventDefault(); // prevent the link behind it triggering + router.push(`/${code}/edit`); + } + + return ( + +
+
+ {title} +
+
{code}
+
+ {type === "specific" && ( + + )} + {type === "weekday" && ( + + )} +
+
+ + {formatTimeRange(startHour, endHour)} +
+
+ + {myEvent && ( + + )} +
+
+ + ); +} + +function formatHour(hour: number): string { + if (hour === 0 || hour === 24) { + return "12am"; + } + const period = hour >= 12 ? "pm" : "am"; + const adjustedHour = hour % 12 === 0 ? 12 : hour % 12; + return `${adjustedHour}${period}`; +} + +function formatTimeRange(startHour: number, endHour: number): string { + if (startHour === 0 && endHour === 24) { + return "All day"; + } + return `${formatHour(startHour)} - ${formatHour(endHour)}`; +} diff --git a/app/ui/components/dashboard/date-range-row.tsx b/app/ui/components/dashboard/date-range-row.tsx new file mode 100644 index 00000000..e5650465 --- /dev/null +++ b/app/ui/components/dashboard/date-range-row.tsx @@ -0,0 +1,31 @@ +type DateRangeRowProps = { + startDate: string; + endDate: string; +}; + +export default function DateRangeRow({ + startDate, + endDate, +}: DateRangeRowProps) { + return ( +
+ {formatDates(startDate, endDate)} +
+ ); +} + +function formatDates(startDate: string, endDate: string): string { + const start = new Date(startDate); + const end = new Date(endDate); + if (start.getUTCMonth() === end.getUTCMonth()) { + if (start.getUTCDate() === end.getUTCDate()) { + return `${start.toLocaleString("en-US", { month: "long" })} ${start.getUTCDate()}`; + } else { + return `${start.toLocaleString("en-US", { month: "long" })} ${start.getUTCDate()} - ${end.getUTCDate()}`; + } + } else { + return `${start.toLocaleString("en-US", { month: "long" })} ${start.getUTCDate()} - ${end.toLocaleString("en-US", { month: "long" })} ${end.getUTCDate()}`; + } +} diff --git a/app/ui/components/dashboard/event-grid.tsx b/app/ui/components/dashboard/event-grid.tsx new file mode 100644 index 00000000..ad6cb4b1 --- /dev/null +++ b/app/ui/components/dashboard/event-grid.tsx @@ -0,0 +1,13 @@ +import DashboardEvent, { DashboardEventProps } from "./dashboard-event"; + +export type EventGridProps = DashboardEventProps[]; + +export default function EventGrid({ events }: { events: EventGridProps }) { + return ( +
+ {events.map((data: DashboardEventProps) => ( + + ))} +
+ ); +} diff --git a/app/ui/components/dashboard/weekday-row.tsx b/app/ui/components/dashboard/weekday-row.tsx new file mode 100644 index 00000000..878fc7cd --- /dev/null +++ b/app/ui/components/dashboard/weekday-row.tsx @@ -0,0 +1,53 @@ +import { cn } from "@/app/_lib/classname"; + +type WeekdayRowProps = { + startWeekday: number; + endWeekday: number; +}; + +export default function WeekdayRow({ + startWeekday, + endWeekday, +}: WeekdayRowProps) { + return ( +
+ {["S", "M", "T", "W", "T", "F", "S"].map((initial, index) => ( + + ))} +
+ ); +} + +function WeekdayRowIcon({ + label, + index, + start, + end, +}: { + label: string; + index: number; + start: number; + end: number; +}) { + const isActive = index >= start && index <= end; + const isStart = index === start; + const isEnd = index === end; + return ( +
+ {label} +
+ ); +} diff --git a/app/ui/components/date-range/date-range-drawer.tsx b/app/ui/components/date-range/date-range-drawer.tsx index 23a5b82b..70ead59a 100644 --- a/app/ui/components/date-range/date-range-drawer.tsx +++ b/app/ui/components/date-range/date-range-drawer.tsx @@ -1,41 +1,43 @@ import * as Dialog from "@radix-ui/react-dialog"; -import { Calendar } from "../month-calendar"; -import CustomSelect from "../custom-select"; -import WeekdayCalendar from "../weekday-calendar"; -import DateRangeInput from "./date-range-input"; -import { DateRangeProps } from "@/app/_types/date-range-types"; + +import { DateRangeProps } from "@/app/_lib/types/date-range-props"; +import { fromZonedTime } from "date-fns-tz"; + +import { Calendar } from "@/app/ui/components/month-calendar"; +import WeekdayCalendar from "@/app/ui/components/weekday-calendar"; +import DateRangeInput from "@/app/ui/components/date-range/date-range-input"; +import EventTypeSelect from "@/app/ui/components/selectors/event-type-select"; +import { DateRange } from "react-day-picker"; +import { useState } from "react"; +import { checkInvalidDateRangeLength } from "@/app/_lib/schedule/utils"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; export default function DateRangeDrawer({ + earliestDate, eventRange, - onChangeRangeType, - onChangeSpecific, - onChangeWeekday, + editing = false, + setEventType = () => {}, + setWeekdayRange = () => {}, + setDateRange = () => {}, }: DateRangeProps) { const rangeType = eventRange?.type ?? "specific"; + const [tooManyDays, setTooManyDays] = useState(false); + + const checkDateRange = (range: DateRange | undefined) => { + setTooManyDays(checkInvalidDateRangeLength(range)); + setDateRange(range); + }; - const select = ( - - onChangeRangeType?.(value === "Specific Dates" ? "specific" : "weekday") - } - className="min-h-9 min-w-[100px] border-none px-2" - /> - ); return ( - +
@@ -44,21 +46,27 @@ export default function DateRangeDrawer({ className="fixed right-0 bottom-0 left-0 z-50 flex h-[500px] w-full animate-slideUp flex-col data-[state=closed]:animate-slideDown" aria-label="Date range picker" > -
+
- Select Date Range - {select} + +
@@ -68,52 +76,62 @@ export default function DateRangeDrawer({ } const DateRangeDrawerSelector = ({ + earliestDate, eventRange, - onChangeSpecific, - onChangeWeekday = () => {}, - displayCalendar = false, + displayCalendar, + tooManyDays, + setWeekdayRange = () => {}, + setDateRange = () => {}, }: DateRangeProps) => { if (eventRange?.type === "specific") { - const specificRange = eventRange.dateRange; + const startDate = fromZonedTime( + eventRange.dateRange.from, + eventRange.timezone, + ); + const endDate = fromZonedTime(eventRange.dateRange.to, eventRange.timezone); return ( -
- - {displayCalendar && ( +
+ {displayCalendar ? ( { - if (range?.from) { - onChangeSpecific?.("from", range.from); - } - if (range?.to) { - onChangeSpecific?.("to", range.to); - } + from: startDate || undefined, + to: endDate || undefined, }} + setDateRange={setDateRange} /> + ) : ( + <> + + + )}
); } return ( - + {!displayCalendar && } + + onChange={setWeekdayRange} + inDrawer={true} + /> +
); }; diff --git a/app/ui/components/date-range/date-range-input.tsx b/app/ui/components/date-range/date-range-input.tsx index c9badbe7..5c28ed84 100644 --- a/app/ui/components/date-range/date-range-input.tsx +++ b/app/ui/components/date-range/date-range-input.tsx @@ -1,36 +1,47 @@ import { format } from "date-fns"; type DateRangeInputProps = { - specificRange: { from: Date | null; to: Date | null } | undefined; - onChangeSpecific?: (key: "from" | "to", value: Date) => void; + startDate: Date; + endDate: Date; }; export default function DateRangeInput({ - specificRange, - onChangeSpecific, + startDate, + endDate, }: DateRangeInputProps) { - const displayFrom = specificRange?.from - ? format(specificRange.from, "EEE, MMM d") - : ""; - const displayTo = specificRange?.to - ? format(specificRange.to, "EEE, MMM d") - : ""; + const displayFrom = startDate ? format(startDate, "EEE MMMM d, yyyy") : ""; + const displayTo = endDate ? format(endDate, "EEE MMMM d, yyyy") : ""; return ( -
- onChangeSpecific?.("from", new Date(e.target.value))} - className="rounded-l-full border-1 border-violet-500 px-4 py-1 text-center hover:border-lion focus:outline-none dark:border-gray-400 dark:hover:border-bone" - aria-label="Start date" - /> - onChangeSpecific?.("to", new Date(e.target.value))} - className="rounded-r-full border-1 border-violet-500 px-4 py-1 text-center hover:border-lion focus:outline-none dark:border-gray-400 dark:hover:border-bone" - aria-label="End date" - /> + + {/* Start Date */} +
+ + + {displayFrom} + +
+ + TO + + {/* End Date */} +
+ + + {displayTo} + +
); } diff --git a/app/ui/components/date-range/date-range-popover.tsx b/app/ui/components/date-range/date-range-popover.tsx index 82d6b50c..11c4a9ea 100644 --- a/app/ui/components/date-range/date-range-popover.tsx +++ b/app/ui/components/date-range/date-range-popover.tsx @@ -1,23 +1,32 @@ -"use client"; - import * as Popover from "@radix-ui/react-popover"; -import { Calendar } from "../month-calendar"; -import { format } from "date-fns"; -import { DateRangeProps } from "@/app/_types/date-range-types"; -import DateRangeInput from "./date-range-input"; +import { fromZonedTime } from "date-fns-tz"; + +import { Calendar } from "@/app/ui/components/month-calendar"; +import DateRangeInput from "@/app/ui/components/date-range/date-range-input"; + +import { DateRangeProps } from "@/app/_lib/types/date-range-props"; export default function DateRangePopover({ - specificRange, - onChangeSpecific, + earliestDate, + eventRange, + setDateRange = () => {}, }: DateRangeProps) { + // If the event range is not specific, return null + if (eventRange.type !== "specific") { + return null; + } + + const startDate = fromZonedTime( + eventRange.dateRange.from, + eventRange.timezone, + ); + const endDate = fromZonedTime(eventRange.dateRange.to, eventRange.timezone); + return (
- +
@@ -28,19 +37,13 @@ export default function DateRangePopover({ aria-label="Date range picker" > { - if (range?.from) { - onChangeSpecific?.("from", range.from); - } - if (range?.to) { - onChangeSpecific?.("to", range.to); - } + from: startDate || undefined, + to: endDate || undefined, }} + setDateRange={setDateRange} /> diff --git a/app/ui/components/date-range/date-range-selector.tsx b/app/ui/components/date-range/date-range-selector.tsx index a1cc4ea5..f4205506 100644 --- a/app/ui/components/date-range/date-range-selector.tsx +++ b/app/ui/components/date-range/date-range-selector.tsx @@ -1,124 +1,89 @@ -import CustomSelect from "../custom-select"; -import DateRangeDrawer from "./date-range-drawer"; -import DateRangePopover from "./date-range-popover"; -import WeekdayCalendar from "../weekday-calendar"; -import { DateRangeProps } from "@/app/_types/date-range-types"; -import { WeekdayMap } from "@/app/_types/schedule-types"; +import { DateRangeProps } from "@/app/_lib/types/date-range-props"; -import useCheckMobile from "@/app/_utils/use-check-mobile"; +// Import child components +import useCheckMobile from "@/app/_lib/use-check-mobile"; +import WeekdayCalendar from "@/app/ui/components/weekday-calendar"; +import DateRangeDrawer from "@/app/ui/components/date-range/date-range-drawer"; +import DateRangePopover from "@/app/ui/components/date-range/date-range-popover"; +import EventTypeSelect from "@/app/ui/components/selectors/event-type-select"; +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { useState } from "react"; +import { DateRange } from "react-day-picker"; +import { checkInvalidDateRangeLength } from "@/app/_lib/schedule/utils"; export default function DateRangeSelector({ + earliestDate, eventRange, - onChangeEventRange, + editing = false, + setEventType = () => {}, + setWeekdayRange = () => {}, + setDateRange = () => {}, }: DateRangeProps) { const isMobile = useCheckMobile(); - const rangeType = eventRange?.type ?? "specific"; + const [tooManyDays, setTooManyDays] = useState(false); - const handleRangeTypeChange = (value: string | number) => { - console.log("handleRangeTypeChange", value); - const newType = value === "specific" ? "specific" : "weekday"; - if (newType !== eventRange?.type) { - onChangeEventRange?.( - newType === "specific" - ? { - type: "specific", - duration: 60, - dateRange: { from: new Date(), to: new Date() }, - timeRange: eventRange?.timeRange ?? { from: null, to: null }, - timezone: - eventRange?.timezone ?? - Intl.DateTimeFormat().resolvedOptions().timeZone, - } - : { - type: "weekday", - duration: 60, - weekdays: { - Sun: 0, - Mon: 0, - Tue: 0, - Wed: 0, - Thu: 0, - Fri: 0, - Sat: 0, - }, - timeRange: eventRange?.timeRange ?? { from: null, to: null }, - timezone: - eventRange?.timezone ?? - Intl.DateTimeFormat().resolvedOptions().timeZone, - }, - ); - } - - console.log("handleRangeTypeChange", newType); - console.log("eventRange", eventRange); - }; - - const updateSpecificRange = (key: "from" | "to", value: Date) => { - if (eventRange?.type === "specific") { - onChangeEventRange?.({ - ...eventRange, - dateRange: { - ...eventRange.dateRange, - [key]: value, - }, - }); - } - }; - - const updateWeekdayRange = (map: WeekdayMap) => { - if (eventRange?.type === "weekday") { - onChangeEventRange?.({ ...eventRange, weekdays: map }); - } + const checkDateRange = (range: DateRange | undefined) => { + setTooManyDays(checkInvalidDateRangeLength(range)); + setDateRange(range); }; - const select = ( - - ); - if (isMobile) { return ( ); + } else { + return ( +
+
+ + +
+
+ {eventRange?.type === "specific" ? ( + <> + + + + ) : ( + + )} +
+
+ ); } - - return ( -
- {select} - {eventRange?.type === "specific" ? ( - - ) : ( - - )} -
- ); } diff --git a/app/ui/components/event-info-drawer.tsx b/app/ui/components/event-info-drawer.tsx new file mode 100644 index 00000000..566d98f8 --- /dev/null +++ b/app/ui/components/event-info-drawer.tsx @@ -0,0 +1,116 @@ +"use client"; + +import * as Dialog from "@radix-ui/react-dialog"; +import { InfoCircledIcon } from "@radix-ui/react-icons"; + +import { EventRange } from "@/app/_lib/schedule/types"; +import { formatLabel } from "@/app/_lib/timezone-file-generator"; + +export default function EventInfoDrawer({ + eventRange, +}: { + eventRange: EventRange; +}) { + return ( + + + + + + + + +
+ + + + + ); +} + +export function EventInfo({ eventRange }: { eventRange: EventRange }) { + return ( +
+
+

Event Details

+

+ Please note that these details are presented in respect to the{" "} + original event's timezone{" "} + which is{" "} + + {formatLabel(eventRange.timezone)} + +

+
+ +
+ {eventRange.type === "specific" ? ( + + {prettyDate(new Date(eventRange.dateRange.from!), "date")} –{" "} + {prettyDate(new Date(eventRange.dateRange.to!), "date")} + + ) : ( + + {Object.entries(eventRange.weekdays) + .filter(([, val]) => val === 1) + .map(([day]) => day) + .join(", ")} + + )} + + + {eventRange.timeRange.from === 0 && eventRange.timeRange.to === 24 + ? "Anytime" + : `${prettyDate( + new Date(new Date().setHours(eventRange.timeRange.from, 0)), + "time", + )} - ${prettyDate( + new Date(new Date().setHours(eventRange.timeRange.to, 0)), + "time", + )}`} + + + {eventRange.duration > 0 && ( + + {eventRange.duration} minutes + + )} +
+
+ ); +} + +function InfoRow({ + label, + children, +}: { + label: string; + children: React.ReactNode; +}) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +function prettyDate(date: Date, type?: "date" | "time") { + return new Intl.DateTimeFormat("en-US", { + weekday: type === "date" ? "short" : undefined, + month: type === "date" ? "long" : undefined, + day: type === "date" ? "numeric" : undefined, + year: undefined, + hour: type === "time" ? "numeric" : undefined, + minute: type === "time" ? "numeric" : undefined, + hour12: true, + }).format(date); +} diff --git a/app/ui/components/header/account-button.tsx b/app/ui/components/header/account-button.tsx new file mode 100644 index 00000000..2da642e7 --- /dev/null +++ b/app/ui/components/header/account-button.tsx @@ -0,0 +1,56 @@ +"use client"; + +import { PersonIcon } from "@radix-ui/react-icons"; +import Link from "next/link"; +import { useContext, useEffect } from "react"; +import { LoginContext } from "@/app/_lib/providers"; +import AccountDropdown from "./account-dropdown"; + +export default function AccountButton() { + const { loggedIn, setLoggedIn } = useContext(LoginContext); + + useEffect(() => { + const checkLogin = async () => { + if (loggedIn) return; + + try { + const res = await fetch("/api/auth/check-account-auth/", { + method: "GET", + headers: { "Content-Type": "application/json" }, + }); + if (res.ok) { + setLoggedIn(true); + } else { + setLoggedIn(false); + } + } catch (err) { + console.error("Fetch error:", err); + setLoggedIn(false); + } + }; + checkLogin(); + }, [loggedIn, setLoggedIn]); + + if (loggedIn) { + return ( + + + + ); + } else { + return ( + + Log In + + ); + } +} diff --git a/app/ui/components/header/account-dropdown.tsx b/app/ui/components/header/account-dropdown.tsx new file mode 100644 index 00000000..4de397c4 --- /dev/null +++ b/app/ui/components/header/account-dropdown.tsx @@ -0,0 +1,82 @@ +import { cn } from "@/app/_lib/classname"; +import formatApiError from "@/app/_utils/format-api-error"; +import { LoginContext } from "@/app/_lib/providers"; +import * as DropdownMenu from "@radix-ui/react-dropdown-menu"; +import { ExitIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/navigation"; + +import { forwardRef, ReactNode, useContext, useRef } from "react"; + +export default function AccountDropdown({ children }: { children: ReactNode }) { + const isSubmitting = useRef(false); + const { setLoggedIn } = useContext(LoginContext); + const router = useRouter(); + + const signOut = async () => { + if (isSubmitting.current) return; + isSubmitting.current = true; + + await fetch("/api/auth/logout/", { + method: "POST", + headers: { "Content-Type": "application/json" }, + }) + .then(async (res) => { + if (res.ok) { + setLoggedIn(false); + router.push("/login"); + } else { + alert(formatApiError(await res.json())); + } + }) + .catch((err) => { + console.error("Fetch error:", err); + alert("An error occurred. Please try again."); + }); + + isSubmitting.current = false; + }; + + return ( + + {children} + + + e.preventDefault()} + > + + + Sign Out + + + + + ); +} + +type DropdownItemProps = { + onSelect?: () => void; + children: ReactNode; +}; + +// this is a forwardRef for compatibility with Radix UI and its accessibility features +const DropdownItem = forwardRef( + ({ onSelect, children }, ref) => { + return ( + + {children} + + ); + }, +); + +DropdownItem.displayName = "DropdownItem"; diff --git a/app/ui/components/header/dashboard-button.tsx b/app/ui/components/header/dashboard-button.tsx new file mode 100644 index 00000000..9ecbbb14 --- /dev/null +++ b/app/ui/components/header/dashboard-button.tsx @@ -0,0 +1,17 @@ +import { DashboardIcon } from "@radix-ui/react-icons"; +import { useRouter } from "next/navigation"; + +export default function DashboardButton() { + const router = useRouter(); + + return ( + + ); +} diff --git a/app/ui/components/header/hamburger-menu.tsx b/app/ui/components/header/hamburger-menu.tsx new file mode 100644 index 00000000..b0dd7b14 --- /dev/null +++ b/app/ui/components/header/hamburger-menu.tsx @@ -0,0 +1,79 @@ +import React, { useState } from "react"; +import Link from "next/link"; + +export default function HamburgerMenu() { + const [isOpen, setIsOpen] = useState(false); + + const toggleMenu = () => setIsOpen(!isOpen); + + return ( +
+ + + {isOpen && ( +
+ {/* Vertical Line */} +
+ + {/* Dropdown Menu */} +
+
    +
  • + + Mix Your First Plan + +
  • +
  • + + Dashboard + +
  • +
  • + + About Plancake + +
  • +
  • + + Login + +
  • +
+
+
+ )} +
+ ); +} diff --git a/app/ui/components/header/header-spacer.tsx b/app/ui/components/header/header-spacer.tsx new file mode 100644 index 00000000..a1e281a0 --- /dev/null +++ b/app/ui/components/header/header-spacer.tsx @@ -0,0 +1,5 @@ +export default function HeaderSpacer() { + return ( +
+ ); +} diff --git a/app/ui/components/header/header.tsx b/app/ui/components/header/header.tsx new file mode 100644 index 00000000..961b9494 --- /dev/null +++ b/app/ui/components/header/header.tsx @@ -0,0 +1,35 @@ +"use client"; + +import { useEffect, useState } from "react"; +import LogoArea from "./logo-area"; +import AccountButton from "./account-button"; +import ThemeToggle from "./theme-toggle"; +import NewEventButton from "./new-event-button"; +import DashboardButton from "./dashboard-button"; + +export default function Header() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( +
+ +
+ ); +} diff --git a/app/ui/components/header/logo-area.tsx b/app/ui/components/header/logo-area.tsx new file mode 100644 index 00000000..aa1b9f5d --- /dev/null +++ b/app/ui/components/header/logo-area.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import Logo from "../logo"; + +export default function LogoArea() { + const [mounted, setMounted] = useState(false); + + useEffect(() => { + setMounted(true); + }, []); + + if (!mounted) { + return null; + } + + return ( +
+ {/* Text Container */} + + + + v0.1.0 +
+ ); +} diff --git a/app/ui/components/header/new-event-button.tsx b/app/ui/components/header/new-event-button.tsx new file mode 100644 index 00000000..1bc61e7d --- /dev/null +++ b/app/ui/components/header/new-event-button.tsx @@ -0,0 +1,24 @@ +"use client"; + +import Link from "next/link"; +import { PlusIcon } from "@radix-ui/react-icons"; +import { usePathname } from "next/navigation"; + +export default function NewEventButton() { + const pathname = usePathname(); + + if (pathname === "/new-event") { + return null; + } + + return ( + + + New Event + + ); +} diff --git a/app/ui/components/header/theme-toggle.tsx b/app/ui/components/header/theme-toggle.tsx new file mode 100644 index 00000000..29dd4a8c --- /dev/null +++ b/app/ui/components/header/theme-toggle.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { FiSun, FiMoon } from "react-icons/fi"; +import { useTheme } from "next-themes"; + +export default function FixedThemeToggle() { + const { setTheme, resolvedTheme } = useTheme(); + + const toggleTheme = () => { + setTheme(resolvedTheme === "dark" ? "light" : "dark"); + }; + + return ( + + ); +} diff --git a/app/ui/components/link-text.tsx b/app/ui/components/link-text.tsx new file mode 100644 index 00000000..44ad953a --- /dev/null +++ b/app/ui/components/link-text.tsx @@ -0,0 +1,13 @@ +import { ReactNode } from "react"; + +export default function LinkText({ children }: { children: ReactNode }) { + return ( + + {children} + + ); +} diff --git a/app/ui/components/logo.tsx b/app/ui/components/logo.tsx new file mode 100644 index 00000000..02e12e72 --- /dev/null +++ b/app/ui/components/logo.tsx @@ -0,0 +1,8 @@ +export default function Logo() { + return ( +
+
plan
+
cake
+
+ ); +} diff --git a/app/ui/components/month-calendar.tsx b/app/ui/components/month-calendar.tsx index 61e6b6ad..473d4cf2 100644 --- a/app/ui/components/month-calendar.tsx +++ b/app/ui/components/month-calendar.tsx @@ -1,25 +1,25 @@ "use client"; import { useState } from "react"; -import useCheckMobile from "@/app/_utils/use-check-mobile"; +import useCheckMobile from "@/app/_lib/use-check-mobile"; -import { - DateRange, - DayPicker, - SelectRangeEventHandler, - getDefaultClassNames, -} from "react-day-picker"; +import { DateRange, DayPicker, getDefaultClassNames } from "react-day-picker"; + +import { ExclamationTriangleIcon } from "@radix-ui/react-icons"; +import { checkInvalidDateRangeLength } from "@/app/_lib/schedule/utils"; type CalendarProps = { + earliestDate?: Date; className?: string; selectedRange: DateRange; - onRangeSelect: (range: { from: Date | null; to: Date | null }) => void; + setDateRange: (range: DateRange | undefined) => void; }; export function Calendar({ + earliestDate, className, selectedRange, - onRangeSelect, + setDateRange, }: CalendarProps) { const defaultClassNames = getDefaultClassNames(); @@ -29,14 +29,23 @@ export function Calendar({ const today = new Date(); - const [month, setMonth] = useState(today); + const startDate = + earliestDate && earliestDate < today + ? new Date( + earliestDate.getUTCFullYear(), + earliestDate.getUTCMonth(), + earliestDate.getUTCDate(), + ) + : today; + + const [month, setMonth] = useState(startDate); + const [tooManyDays, setTooManyDays] = useState(() => { + return checkInvalidDateRangeLength(selectedRange); + }); - const handleRangeSelect: SelectRangeEventHandler = ( - range: DateRange | undefined, - ) => { - const from = range?.from || null; - const to = range?.to || null; - onRangeSelect({ from, to }); + const checkDateRange = (range: DateRange | undefined) => { + setTooManyDays(checkInvalidDateRangeLength(range)); + setDateRange(range); }; return ( @@ -55,12 +64,18 @@ export function Calendar({ month={month} onMonthChange={setMonth} selected={selectedRange} - onSelect={handleRangeSelect} - disabled={{ before: new Date() }} + onSelect={checkDateRange} + disabled={{ before: startDate }} classNames={{ root: `${defaultClassNames.root} flex justify-center items-center`, }} /> + {!isMobile && tooManyDays && ( +
+ + Too many days selected. Max is 30 days. +
+ )}
); } diff --git a/app/ui/components/schedule/grid-preview-dialog.tsx b/app/ui/components/schedule/grid-preview-dialog.tsx index e33f3a1c..dfd25f20 100644 --- a/app/ui/components/schedule/grid-preview-dialog.tsx +++ b/app/ui/components/schedule/grid-preview-dialog.tsx @@ -1,14 +1,14 @@ "use client"; -import { EnterFullScreenIcon, Cross2Icon } from "@radix-ui/react-icons"; -import { motion } from "framer-motion"; -import ScheduleGrid from "./schedule-grid"; -import { EventRange } from "@/app/_types/schedule-types"; import { useState } from "react"; -import TimezoneSelect from "../timezone-select"; -import InteractiveScheduleGrid from "./interactive-schedule-grid"; -import { UserAvailability } from "@/app/_types/user-availability"; +import { motion } from "framer-motion"; +import { EnterFullScreenIcon, Cross2Icon } from "@radix-ui/react-icons"; + +import { EventRange } from "@/app/_lib/schedule/types"; + +import ScheduleGrid from "@/app/ui/components/schedule/schedule-grid"; +import TimeZoneSelector from "../selectors/timezone-selector"; interface GridPreviewDialogProps { eventRange: EventRange; @@ -17,11 +17,6 @@ interface GridPreviewDialogProps { export default function GridPreviewDialog({ eventRange, }: GridPreviewDialogProps) { - const [userAvailability, setUserAvailability] = useState({ - type: "specific", - selections: {}, - }); - const [isOpen, setIsOpen] = useState(false); const [timezone, setTimezone] = useState(eventRange.timezone); @@ -30,7 +25,7 @@ export default function GridPreviewDialog({ }; return ( -
+
{isOpen && (
{isOpen ? ( - + - {/* */} -
- -