├── .eslintrc.json ├── src ├── utils │ ├── constants.js │ └── helpers.js ├── data │ ├── cabins │ │ ├── cabin-001.jpg │ │ ├── cabin-002.jpg │ │ ├── cabin-003.jpg │ │ ├── cabin-004.jpg │ │ ├── cabin-005.jpg │ │ ├── cabin-006.jpg │ │ ├── cabin-007.jpg │ │ └── cabin-008.jpg │ ├── Uploader.jsx │ ├── data-cabins.js │ ├── data-guests.js │ └── data-bookings.js ├── ui │ ├── Empty.jsx │ ├── ButtonGroup.jsx │ ├── Flag.jsx │ ├── Textarea.jsx │ ├── ButtonText.jsx │ ├── Input.jsx │ ├── TableOperations.jsx │ ├── DarkModeToggle.jsx │ ├── SpinnerMini.jsx │ ├── Tag.jsx │ ├── SortBy.jsx │ ├── Row.jsx │ ├── ButtonIcon.jsx │ ├── Spinner.jsx │ ├── Header.jsx │ ├── Logo.jsx │ ├── DataItem.jsx │ ├── FileInput.jsx │ ├── Select.jsx │ ├── HeaderMenu.jsx │ ├── ProtecctedRoute.jsx │ ├── FormRowVertical.jsx │ ├── Checkbox.jsx │ ├── Form.jsx │ ├── AppLayout.jsx │ ├── ProtectedRoute.jsx │ ├── FormRow.jsx │ ├── ErrorFallback.jsx │ ├── ConfirmDelete.jsx │ ├── Heading.jsx │ ├── Modal-v1.jsx │ ├── Sidebar.jsx │ ├── Filter.jsx │ ├── Button.jsx │ ├── MainNav.jsx │ ├── Modal.jsx │ ├── Table.jsx │ ├── Pagination.jsx │ └── Menus.jsx ├── pages │ ├── Booking.jsx │ ├── Checkin.jsx │ ├── Users.jsx │ ├── Settings.jsx │ ├── Dashboard.jsx │ ├── Bookings.jsx │ ├── Cabins.jsx │ ├── Account.jsx │ ├── Login.jsx │ └── PageNotFound.jsx ├── hooks │ ├── useMoveBack.js │ ├── useLocalStorageState.js │ └── useOutsideClick.js ├── features │ ├── cabins │ │ ├── useCabins.js │ │ ├── useCreateCabin.js │ │ ├── useEditCabin.js │ │ ├── useDeleteCabin.js │ │ ├── useCabinDelete.js │ │ ├── AddCabin.jsx │ │ ├── CabinTableOperations.jsx │ │ ├── CabinTableOperation.jsx │ │ ├── CabinTable-v2.jsx │ │ ├── CabinTable-v1.jsx │ │ ├── CabinTable.jsx │ │ ├── CabinRow-v1.jsx │ │ ├── CabinRow.jsx │ │ ├── CreateCabinForm-v1.jsx │ │ └── CreateCabinForm.jsx │ ├── authentication │ │ ├── useUser.js │ │ ├── Logout.jsx │ │ ├── useSignup.js │ │ ├── useLogout.js │ │ ├── useUpdateUser.js │ │ ├── useLogin.js │ │ ├── UserAvatar.jsx │ │ ├── LoginForm.jsx │ │ ├── UpdatePasswordForm.jsx │ │ ├── UpdateUserDataForm.jsx │ │ └── SignupForm.jsx │ ├── check-in-out │ │ ├── useTodayActivity.js │ │ ├── CheckoutButton.jsx │ │ ├── useCheckout.js │ │ ├── useCheckin.js │ │ ├── TodayItem.jsx │ │ ├── TodayActivity.jsx │ │ └── CheckinBooking.jsx │ ├── settings │ │ ├── useSettings.js │ │ ├── useUpdateSetting.js │ │ └── UpdateSettingsForm.jsx │ ├── dashboard │ │ ├── DashboardBox.jsx │ │ ├── DashboardFilter.jsx │ │ ├── useRecentBookings.js │ │ ├── useRecentStays.js │ │ ├── DashboardLayout.jsx │ │ ├── TodayItem.jsx │ │ ├── Stat.jsx │ │ ├── Stats.jsx │ │ ├── SalesChart.jsx │ │ └── DurationChart.jsx │ ├── bookings │ │ ├── useBooking.js │ │ ├── useDeleteBooking.js │ │ ├── BookingTableOperations.jsx │ │ ├── BookingTable.jsx │ │ ├── useBookings.js │ │ ├── BookingDetail.jsx │ │ ├── BookingRow.jsx │ │ └── BookingDataBox.jsx │ └── guests │ │ ├── GuestListItem.jsx │ │ ├── GuestList.jsx │ │ └── CreateGuestForm.jsx ├── services │ ├── supabase.js │ ├── apiSettings.js │ ├── apiCabins.js │ ├── apiAuth.js │ └── apiBookings.js ├── main.jsx ├── context │ └── DarkModeContext.jsx ├── App.jsx └── styles │ ├── GlobalStyles.js │ └── GlobalStyle.js ├── netlify.toml ├── public ├── logo-dark.png ├── default-user.jpg └── logo-light.png ├── .hintrc ├── vite.config.js ├── .gitignore ├── README.md ├── index.html └── package.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "react-app" 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/constants.js: -------------------------------------------------------------------------------- 1 | export const PAGE_SIZE = 10; 2 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [[redirects]] 2 | from = "/*" 3 | to = "/index.html" 4 | status = 200 -------------------------------------------------------------------------------- /public/logo-dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/public/logo-dark.png -------------------------------------------------------------------------------- /public/default-user.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/public/default-user.jpg -------------------------------------------------------------------------------- /public/logo-light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/public/logo-light.png -------------------------------------------------------------------------------- /.hintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "development" 4 | ], 5 | "hints": { 6 | "compat-api/css": "off" 7 | } 8 | } -------------------------------------------------------------------------------- /src/data/cabins/cabin-001.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-001.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-002.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-002.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-003.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-003.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-004.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-004.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-005.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-005.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-006.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-006.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-007.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-007.jpg -------------------------------------------------------------------------------- /src/data/cabins/cabin-008.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Arbab-Mustafa/Wild_Oasisi-ffullStack/HEAD/src/data/cabins/cabin-008.jpg -------------------------------------------------------------------------------- /src/ui/Empty.jsx: -------------------------------------------------------------------------------- 1 | function Empty({ resourceName }) { 2 | return

No {resourceName} could be found.

; 3 | } 4 | 5 | export default Empty; 6 | -------------------------------------------------------------------------------- /src/pages/Booking.jsx: -------------------------------------------------------------------------------- 1 | import BookingDetail from "../features/bookings/BookingDetail"; 2 | 3 | function Booking() { 4 | return ; 5 | } 6 | 7 | export default Booking; 8 | -------------------------------------------------------------------------------- /src/hooks/useMoveBack.js: -------------------------------------------------------------------------------- 1 | import { useNavigate } from 'react-router-dom'; 2 | 3 | export function useMoveBack() { 4 | const navigate = useNavigate(); 5 | return () => navigate(-1); 6 | } 7 | -------------------------------------------------------------------------------- /src/pages/Checkin.jsx: -------------------------------------------------------------------------------- 1 | import CheckinBooking from "../features/check-in-out/CheckinBooking"; 2 | 3 | function Checkin() { 4 | return ; 5 | } 6 | 7 | export default Checkin; 8 | -------------------------------------------------------------------------------- /src/ui/ButtonGroup.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ButtonGroup = styled.div` 4 | display: flex; 5 | gap: 1.2rem; 6 | justify-content: flex-end; 7 | `; 8 | 9 | export default ButtonGroup; 10 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import eslint from "vite-plugin-eslint"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [react(), eslint()], 8 | }); 9 | -------------------------------------------------------------------------------- /src/ui/Flag.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | export const Flag = styled.img` 4 | max-width: 2rem; 5 | border-radius: var(--border-radius-tiny); 6 | display: block; 7 | border: 1px solid var(--color-grey-100); 8 | @media screen and (max-width: 790px) { 9 | width: 1.3rem; 10 | } 11 | `; 12 | -------------------------------------------------------------------------------- /src/pages/Users.jsx: -------------------------------------------------------------------------------- 1 | import SignupForm from "../features/authentication/SignupForm"; 2 | import Heading from "../ui/Heading"; 3 | 4 | function NewUsers() { 5 | return ( 6 | <> 7 | Create a new user 8 | 9 | 10 | ); 11 | } 12 | 13 | export default NewUsers; 14 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /src/ui/Textarea.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Textarea = styled.textarea` 4 | padding: 0.8rem 1.2rem; 5 | border: 1px solid var(--color-grey-300); 6 | border-radius: 5px; 7 | background-color: var(--color-grey-0); 8 | box-shadow: var(--shadow-sm); 9 | width: 100%; 10 | height: 8rem; 11 | `; 12 | 13 | export default Textarea; 14 | -------------------------------------------------------------------------------- /src/pages/Settings.jsx: -------------------------------------------------------------------------------- 1 | import UpdateSettingsForm from "../features/settings/UpdateSettingsForm"; 2 | import Heading from "../ui/Heading"; 3 | import Row from "../ui/Row"; 4 | 5 | function Settings() { 6 | return ( 7 | 8 | Update hotel settings 9 | 10 | 11 | ); 12 | } 13 | 14 | export default Settings; 15 | -------------------------------------------------------------------------------- /src/features/cabins/useCabins.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { getCabins } from "../../services/apiCabins"; 3 | 4 | export function useCabins() { 5 | const { 6 | isLoading, 7 | data: cabins, 8 | error, 9 | } = useQuery({ 10 | queryKey: ["cabins"], 11 | queryFn: getCabins, 12 | }); 13 | 14 | return { isLoading, error, cabins }; 15 | } 16 | -------------------------------------------------------------------------------- /src/features/authentication/useUser.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { getCurrentUser } from "../../services/apiAuth"; 3 | 4 | export function useUser() { 5 | const { isLoading, data: user } = useQuery({ 6 | queryKey: ["user"], 7 | queryFn: getCurrentUser, 8 | }); 9 | 10 | return { isLoading, user, isAuthenticated: user?.role === "authenticated" }; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/check-in-out/useTodayActivity.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { getStaysTodayActivity } from "../../services/apiBookings"; 3 | 4 | export function useTodayActivity() { 5 | const { isLoading, data: activities } = useQuery({ 6 | queryFn: getStaysTodayActivity, 7 | queryKey: ["today-activity"], 8 | }); 9 | 10 | return { activities, isLoading }; 11 | } 12 | -------------------------------------------------------------------------------- /src/features/settings/useSettings.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { getSettings } from "../../services/apiSettings"; 3 | 4 | export function useSettings() { 5 | const { 6 | isLoading, 7 | error, 8 | data: settings, 9 | } = useQuery({ 10 | queryKey: ["settings"], 11 | queryFn: getSettings, 12 | }); 13 | 14 | return { isLoading, error, settings }; 15 | } 16 | -------------------------------------------------------------------------------- /src/features/dashboard/DashboardBox.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const DashboardBox = styled.div` 4 | /* Box */ 5 | background-color: var(--color-grey-0); 6 | border: 1px solid var(--color-grey-100); 7 | border-radius: var(--border-radius-md); 8 | 9 | padding: 3.2rem; 10 | 11 | display: flex; 12 | flex-direction: column; 13 | gap: 2.4rem; 14 | `; 15 | 16 | export default DashboardBox; 17 | -------------------------------------------------------------------------------- /src/features/dashboard/DashboardFilter.jsx: -------------------------------------------------------------------------------- 1 | import Filter from "../../ui/Filter"; 2 | 3 | function DashboardFilter() { 4 | return ( 5 | 13 | ); 14 | } 15 | 16 | export default DashboardFilter; 17 | -------------------------------------------------------------------------------- /src/ui/ButtonText.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | 3 | const ButtonText = styled.button` 4 | color: var(--color-brand-600); 5 | font-weight: 500; 6 | text-align: center; 7 | transition: all 0.3s; 8 | background: none; 9 | border: none; 10 | border-radius: var(--border-radius-sm); 11 | 12 | &:hover, 13 | &:active { 14 | color: var(--color-brand-700); 15 | } 16 | `; 17 | 18 | export default ButtonText; 19 | -------------------------------------------------------------------------------- /src/ui/Input.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Input = styled.input` 4 | border: 1px solid var(--color-grey-300); 5 | background-color: var(--color-grey-0); 6 | border-radius: var(--border-radius-sm); 7 | padding: 0.8rem 1.2rem; 8 | box-shadow: var(--shadow-sm); 9 | @media screen and (max-width: 768px) { 10 | padding: 0.6rem 1rem; 11 | width: 100%; 12 | } 13 | `; 14 | 15 | export default Input; 16 | -------------------------------------------------------------------------------- /src/ui/TableOperations.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const TableOperations = styled.div` 4 | display: flex; 5 | align-items: center; 6 | gap: 1.6rem; 7 | 8 | @media screen and (max-width: 768px) { 9 | flex-wrap: wrap; 10 | gap: 0.8rem; 11 | justify-content: center; 12 | } 13 | @media screen and (min-width: 889px) and (max-width: 1260px) { 14 | flex-wrap: wrap; 15 | } 16 | `; 17 | 18 | export default TableOperations; 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React + Vite 2 | 3 | This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. 4 | 5 | Currently, two official plugins are available: 6 | 7 | - [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh 8 | - [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh 9 | -------------------------------------------------------------------------------- /src/services/supabase.js: -------------------------------------------------------------------------------- 1 | import { createClient } from "@supabase/supabase-js"; 2 | 3 | export const supabaseUrl = "https://ibryuknrnjnrdzqomrif.supabase.co"; 4 | const supabaseKey = 5 | "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Imlicnl1a25ybmpucmR6cW9tcmlmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MDA5MzA3NDAsImV4cCI6MjAxNjUwNjc0MH0.gZ1RkvoIt8F5bVfX9osv_wPCRiDWqLTvIn1QfBzDgS8"; 6 | const supabase = createClient(supabaseUrl, supabaseKey); 7 | 8 | export default supabase; 9 | -------------------------------------------------------------------------------- /src/ui/DarkModeToggle.jsx: -------------------------------------------------------------------------------- 1 | import { HiOutlineMoon, HiOutlineSun } from "react-icons/hi2"; 2 | import ButtonIcon from "./ButtonIcon"; 3 | import { useDarkMode } from "../context/DarkModeContext"; 4 | 5 | function DarkModeToggle() { 6 | const { isDarkMode, toggleDarkMode } = useDarkMode(); 7 | 8 | return ( 9 | 10 | {isDarkMode ? : } 11 | 12 | ); 13 | } 14 | 15 | export default DarkModeToggle; 16 | -------------------------------------------------------------------------------- /src/ui/SpinnerMini.jsx: -------------------------------------------------------------------------------- 1 | import styled, { keyframes } from "styled-components"; 2 | import { BiLoaderAlt } from "react-icons/bi"; 3 | 4 | const rotate = keyframes` 5 | to { 6 | transform: rotate(1turn) 7 | } 8 | `; 9 | 10 | const SpinnerMini = styled(BiLoaderAlt)` 11 | width: 2.4rem; 12 | height: 2.4rem; 13 | animation: ${rotate} 1.5s infinite linear; 14 | @media screen and (max-width: 768px) { 15 | width: 1.4rem; 16 | height: 1.4rem; 17 | } 18 | `; 19 | 20 | export default SpinnerMini; 21 | -------------------------------------------------------------------------------- /src/features/check-in-out/CheckoutButton.jsx: -------------------------------------------------------------------------------- 1 | import Button from "../../ui/Button"; 2 | import { useCheckout } from "./useCheckout"; 3 | 4 | function CheckoutButton({ bookingId }) { 5 | const { checkout, isCheckingOut } = useCheckout(); 6 | 7 | return ( 8 | 16 | ); 17 | } 18 | 19 | export default CheckoutButton; 20 | -------------------------------------------------------------------------------- /src/hooks/useLocalStorageState.js: -------------------------------------------------------------------------------- 1 | import { useState, useEffect } from "react"; 2 | 3 | export function useLocalStorageState(initialState, key) { 4 | const [value, setValue] = useState(function () { 5 | const storedValue = localStorage.getItem(key); 6 | return storedValue ? JSON.parse(storedValue) : initialState; 7 | }); 8 | 9 | useEffect( 10 | function () { 11 | localStorage.setItem(key, JSON.stringify(value)); 12 | }, 13 | [value, key] 14 | ); 15 | 16 | return [value, setValue]; 17 | } 18 | -------------------------------------------------------------------------------- /src/main.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import { ErrorBoundary } from "react-error-boundary"; 5 | import ErrorFallback from "./ui/ErrorFallback"; 6 | 7 | ReactDOM.createRoot(document.getElementById("root")).render( 8 | 9 | window.location.replace("/")} 12 | > 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /src/pages/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import DashboardLayout from "../features/dashboard/DashboardLayout"; 2 | import DashboardFilter from "../features/dashboard/DashboardFilter"; 3 | import Heading from "../ui/Heading"; 4 | import Row from "../ui/Row"; 5 | 6 | function Dashboard() { 7 | return ( 8 | <> 9 | 10 | Dashboard 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default Dashboard; 20 | -------------------------------------------------------------------------------- /src/features/authentication/Logout.jsx: -------------------------------------------------------------------------------- 1 | import { HiArrowRightOnRectangle } from "react-icons/hi2"; 2 | import ButtonIcon from "../../ui/ButtonIcon"; 3 | import { useLogout } from "./useLogout"; 4 | import SpinnerMini from "../../ui/SpinnerMini"; 5 | 6 | function Logout() { 7 | const { logout, isLoading } = useLogout(); 8 | 9 | return ( 10 | 11 | {!isLoading ? : } 12 | 13 | ); 14 | } 15 | 16 | export default Logout; 17 | -------------------------------------------------------------------------------- /src/pages/Bookings.jsx: -------------------------------------------------------------------------------- 1 | import Heading from "../ui/Heading"; 2 | import Row from "../ui/Row"; 3 | import BookingTable from "../features/bookings/BookingTable"; 4 | import BookingTableOperations from "../features/bookings/BookingTableOperations"; 5 | 6 | function Bookings() { 7 | return ( 8 | <> 9 | 10 | All bookings 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | } 18 | 19 | export default Bookings; 20 | -------------------------------------------------------------------------------- /src/features/bookings/useBooking.js: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import { useParams } from "react-router-dom"; 3 | import { getBooking } from "../../services/apiBookings"; 4 | 5 | export function useBooking() { 6 | const { bookingId } = useParams(); 7 | 8 | const { 9 | isLoading, 10 | data: booking, 11 | error, 12 | } = useQuery({ 13 | queryKey: ["booking", bookingId], 14 | queryFn: () => getBooking(bookingId), 15 | retry: false, 16 | }); 17 | 18 | return { isLoading, error, booking }; 19 | } 20 | -------------------------------------------------------------------------------- /src/features/authentication/useSignup.js: -------------------------------------------------------------------------------- 1 | import { useMutation } from "@tanstack/react-query"; 2 | import { signup as signupApi } from "../../services/apiAuth"; 3 | import { toast } from "react-hot-toast"; 4 | 5 | export function useSignup() { 6 | const { mutate: signup, isLoading } = useMutation({ 7 | mutationFn: signupApi, 8 | onSuccess: (user) => { 9 | toast.success( 10 | "Account successfully created! Please verufy the new account from the user's email address." 11 | ); 12 | }, 13 | }); 14 | 15 | return { signup, isLoading }; 16 | } 17 | -------------------------------------------------------------------------------- /src/ui/Tag.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const Tag = styled.span` 4 | width: max-content; 5 | text-transform: uppercase; 6 | font-size: 1.1rem; 7 | font-weight: 600; 8 | padding: 0.4rem 1.2rem; 9 | border-radius: 100px; 10 | 11 | /* Make these dynamic, based on the received prop */ 12 | color: var(--color-${(props) => props.type}-700); 13 | background-color: var(--color-${(props) => props.type}-100); 14 | @media screen and (max-width: 768px) { 15 | font-size: 0.7rem; 16 | width: max-content; 17 | } 18 | `; 19 | 20 | export default Tag; 21 | -------------------------------------------------------------------------------- /src/ui/SortBy.jsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from "react-router-dom"; 2 | import Select from "./Select"; 3 | 4 | function SortBy({ options }) { 5 | const [searchParams, setSearchParams] = useSearchParams(); 6 | const sortBy = searchParams.get("sortBy") || ""; 7 | 8 | function handleChange(e) { 9 | searchParams.set("sortBy", e.target.value); 10 | setSearchParams(searchParams); 11 | } 12 | 13 | return ( 14 | 38 | 39 | 40 | ); 41 | } 42 | 43 | export default Checkbox; 44 | -------------------------------------------------------------------------------- /src/features/guests/GuestListItem.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { Flag } from 'ui/Flag'; 3 | 4 | const StyledGuestListItem = styled.li` 5 | display: grid; 6 | grid-template-columns: 2rem 2fr 1fr; 7 | gap: 0.8rem; 8 | align-items: center; 9 | padding: 0.6rem 1.6rem; 10 | transition: all 0.2s; 11 | 12 | &:not(:last-child) { 13 | border-bottom: 1px solid var(--color-grey-100); 14 | } 15 | 16 | &:hover { 17 | background-color: var(--color-grey-50); 18 | cursor: pointer; 19 | } 20 | `; 21 | 22 | const ID = styled.div` 23 | justify-self: right; 24 | font-size: 1.2rem; 25 | color: var(--color-grey-500); 26 | `; 27 | 28 | function GuestListItem({ guest, onClick }) { 29 | return ( 30 | onClick(guest)} role='button'> 31 | 32 |
{guest.fullName}
33 | ID: {guest.nationalID} 34 |
35 | ); 36 | } 37 | 38 | export default GuestListItem; 39 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "the-wild-oasis", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@supabase/supabase-js": "^2.21.0", 13 | "@tanstack/react-query": "^4.29.5", 14 | "@tanstack/react-query-devtools": "^4.29.6", 15 | "date-fns": "^2.30.0", 16 | "react": "^18.2.0", 17 | "react-dom": "^18.2.0", 18 | "react-error-boundary": "^4.0.4", 19 | "react-hook-form": "^7.43.9", 20 | "react-hot-toast": "^2.4.1", 21 | "react-icons": "^4.8.0", 22 | "react-router-dom": "^6.11.1", 23 | "recharts": "^2.6.2", 24 | "styled-components": "^5.3.10" 25 | }, 26 | "devDependencies": { 27 | "@types/react": "^18.0.28", 28 | "@types/react-dom": "^18.0.11", 29 | "@vitejs/plugin-react": "^3.1.0", 30 | "eslint": "^8.39.0", 31 | "eslint-config-react-app": "^7.0.1", 32 | "vite": "^4.2.0", 33 | "vite-plugin-eslint": "^1.8.1" 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/features/cabins/AddCabin.jsx: -------------------------------------------------------------------------------- 1 | import Button from "../../ui/Button"; 2 | import CreateCabinForm from "./CreateCabinForm"; 3 | import Modal from "../../ui/Modal"; 4 | 5 | function AddCabin() { 6 | return ( 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |
17 | ); 18 | } 19 | 20 | // function AddCabin() { 21 | // const [isOpenModal, setIsOpenModal] = useState(false); 22 | 23 | // return ( 24 | //
25 | // 28 | // {isOpenModal && ( 29 | // setIsOpenModal(false)}> 30 | // setIsOpenModal(false)} /> 31 | // 32 | // )} 33 | //
34 | // ); 35 | // } 36 | 37 | export default AddCabin; 38 | -------------------------------------------------------------------------------- /src/ui/Form.jsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const Form = styled.form` 4 | ${(props) => 5 | props.type === "regular" && 6 | css` 7 | padding: 2.4rem 4rem; 8 | 9 | /* Box */ 10 | background-color: var(--color-grey-0); 11 | border: 1px solid var(--color-grey-100); 12 | border-radius: var(--border-radius-md); 13 | `} 14 | 15 | ${(props) => 16 | props.type === "modal" && 17 | css` 18 | width: 80rem; 19 | @media screen and (max-width: 768px) { 20 | width: auto; 21 | overflow-y: scroll; 22 | font-size: 1rem; 23 | padding: 1rem 0rem; 24 | } 25 | `} 26 | 27 | font-size: 1.4rem; 28 | overflow: hidden; 29 | 30 | @media screen and (max-width: 768px) { 31 | ${(props) => 32 | props.type === "regular" && 33 | css` 34 | padding: 1rem 0.9rem; 35 | width: auto; 36 | margin: auto; 37 | `} 38 | } 39 | `; 40 | 41 | Form.defaultProps = { 42 | type: "regular", 43 | }; 44 | 45 | export default Form; 46 | -------------------------------------------------------------------------------- /src/features/bookings/BookingTableOperations.jsx: -------------------------------------------------------------------------------- 1 | import SortBy from "../../ui/SortBy"; 2 | import Filter from "../../ui/Filter"; 3 | import TableOperations from "../../ui/TableOperations"; 4 | 5 | function BookingTableOperations() { 6 | return ( 7 | 8 | 17 | 18 | 29 | 30 | ); 31 | } 32 | 33 | export default BookingTableOperations; 34 | -------------------------------------------------------------------------------- /src/features/cabins/CabinTableOperations.jsx: -------------------------------------------------------------------------------- 1 | import TableOperations from "../../ui/TableOperations"; 2 | import Filter from "../../ui/Filter"; 3 | import SortBy from "../../ui/SortBy"; 4 | 5 | function CabinTableOperations() { 6 | return ( 7 | 8 | 16 | 17 | 27 | 28 | ); 29 | } 30 | 31 | export default CabinTableOperations; 32 | -------------------------------------------------------------------------------- /src/features/cabins/CabinTableOperation.jsx: -------------------------------------------------------------------------------- 1 | import Filter from "../../ui/Filter"; 2 | import SortBy from "../../ui/SortBy"; 3 | import TableOperations from "../../ui/TableOperations"; 4 | // import Filter from "ui/Filter"; 5 | 6 | export const CabinTableOperation = () => { 7 | return ( 8 | 9 | 17 | 18 | 29 | 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /src/pages/PageNotFound.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import { useMoveBack } from "../hooks/useMoveBack"; 4 | import Heading from "../ui/Heading"; 5 | 6 | const StyledPageNotFound = styled.main` 7 | height: 100vh; 8 | background-color: var(--color-grey-50); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | padding: 4.8rem; 13 | `; 14 | 15 | const Box = styled.div` 16 | /* box */ 17 | background-color: var(--color-grey-0); 18 | border: 1px solid var(--color-grey-100); 19 | border-radius: var(--border-radius-md); 20 | 21 | padding: 4.8rem; 22 | flex: 0 1 96rem; 23 | text-align: center; 24 | 25 | & h1 { 26 | margin-bottom: 3.2rem; 27 | } 28 | `; 29 | 30 | function PageNotFound() { 31 | const moveBack = useMoveBack(); 32 | 33 | return ( 34 | 35 | 36 | 37 | The page you are looking for could not be found 😢 38 | 39 | 42 | 43 | 44 | ); 45 | } 46 | 47 | export default PageNotFound; 48 | -------------------------------------------------------------------------------- /src/ui/AppLayout.jsx: -------------------------------------------------------------------------------- 1 | import { Outlet } from "react-router-dom"; 2 | import Sidebar from "./Sidebar"; 3 | import Header from "./Header"; 4 | import styled from "styled-components"; 5 | 6 | const StyledAppLayout = styled.div` 7 | display: grid; 8 | grid-template-columns: 26rem 1fr; 9 | grid-template-rows: auto 1fr; 10 | height: 100vh; 11 | position: relative; 12 | 13 | @media screen and (max-width: 890px) { 14 | grid-template-columns: auto; 15 | } 16 | `; 17 | 18 | const Main = styled.main` 19 | background-color: var(--color-grey-50); 20 | padding: 4rem 4.8rem 6.4rem; 21 | overflow: scroll; 22 | 23 | @media screen and (max-width: 768px) { 24 | padding: 2rem 1rem 2rem 1rem; 25 | } 26 | `; 27 | 28 | const Container = styled.div` 29 | max-width: 120rem; 30 | margin: 0 auto; 31 | display: flex; 32 | flex-direction: column; 33 | gap: 3.2rem; 34 | `; 35 | 36 | function AppLayout() { 37 | return ( 38 | 39 |
40 | 41 |
42 | 43 | 44 | 45 |
46 | 47 | ); 48 | } 49 | 50 | export default AppLayout; 51 | -------------------------------------------------------------------------------- /src/ui/ProtectedRoute.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useUser } from "../features/authentication/useUser"; 3 | import Spinner from "./Spinner"; 4 | import { useNavigate } from "react-router-dom"; 5 | import { useEffect } from "react"; 6 | 7 | const FullPage = styled.div` 8 | height: 100vh; 9 | background-color: var(--color-grey-50); 10 | display: flex; 11 | align-items: center; 12 | justify-content: center; 13 | `; 14 | 15 | function ProtectedRoute({ children }) { 16 | const navigate = useNavigate(); 17 | 18 | // 1. Load the authenticated user 19 | const { isLoading, isAuthenticated } = useUser(); 20 | 21 | // 2. If there is NO authenticated user, redirect to the /login 22 | useEffect( 23 | function () { 24 | if (!isAuthenticated && !isLoading) navigate("/login"); 25 | }, 26 | [isAuthenticated, isLoading, navigate] 27 | ); 28 | 29 | // 3. While loading, show a spinner 30 | if (isLoading) 31 | return ( 32 | 33 | 34 | 35 | ); 36 | 37 | // 4. If there IS a user, render the app 38 | if (isAuthenticated) return children; 39 | } 40 | 41 | export default ProtectedRoute; 42 | -------------------------------------------------------------------------------- /src/features/bookings/BookingTable.jsx: -------------------------------------------------------------------------------- 1 | import BookingRow from "./BookingRow"; 2 | import Table from "../../ui/Table"; 3 | import Menus from "../../ui/Menus"; 4 | import Empty from "../../ui/Empty"; 5 | 6 | import { useBookings } from "./useBookings"; 7 | import Spinner from "../../ui/Spinner"; 8 | import Pagination from "../../ui/Pagination"; 9 | 10 | function BookingTable() { 11 | const { bookings, isLoading, count } = useBookings(); 12 | 13 | if (isLoading) return ; 14 | 15 | if (!bookings.length) return ; 16 | 17 | return ( 18 | 19 | 20 | 21 |
Cabin
22 |
Guest
23 |
Dates
24 |
Status
25 |
Amount
26 |
27 |
28 | 29 | ( 32 | 33 | )} 34 | /> 35 | 36 | 37 | 38 | 39 |
40 |
41 | ); 42 | } 43 | 44 | export default BookingTable; 45 | -------------------------------------------------------------------------------- /src/ui/FormRow.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledFormRow = styled.div` 4 | display: grid; 5 | align-items: center; 6 | grid-template-columns: 24rem 1fr 1.2fr; 7 | gap: 2.4rem; 8 | 9 | padding: 1.2rem 0; 10 | 11 | &:first-child { 12 | padding-top: 0; 13 | } 14 | 15 | &:last-child { 16 | padding-bottom: 0; 17 | } 18 | 19 | &:not(:last-child) { 20 | border-bottom: 1px solid var(--color-grey-100); 21 | } 22 | 23 | &:has(button) { 24 | display: flex; 25 | justify-content: flex-end; 26 | gap: 1.2rem; 27 | } 28 | 29 | @media screen and (max-width: 1099px) { 30 | grid-template-columns: 1fr; 31 | gap: 1.2rem; 32 | width: 100%; 33 | } 34 | `; 35 | 36 | const Label = styled.label` 37 | font-weight: 500; 38 | `; 39 | 40 | const Error = styled.span` 41 | font-size: 1.4rem; 42 | color: var(--color-red-700); 43 | @media screen and (max-width: 768px) { 44 | font-size: 1.2rem; 45 | } 46 | `; 47 | 48 | function FormRow({ label, error, children }) { 49 | return ( 50 | 51 | {label && } 52 | {children} 53 | {error && {error}} 54 | 55 | ); 56 | } 57 | 58 | export default FormRow; 59 | -------------------------------------------------------------------------------- /src/features/authentication/UserAvatar.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useUser } from "./useUser"; 3 | 4 | const StyledUserAvatar = styled.div` 5 | display: flex; 6 | gap: 1.2rem; 7 | align-items: center; 8 | font-weight: 500; 9 | font-size: 1.4rem; 10 | color: var(--color-grey-600); 11 | @media screen and (max-width: 768px) { 12 | gap: 0.5rem; 13 | font-size: 0.9rem; 14 | flex-wrap: wrap; 15 | } 16 | `; 17 | 18 | const Avatar = styled.img` 19 | display: block; 20 | width: 3.6rem; 21 | aspect-ratio: 1; 22 | object-fit: cover; 23 | object-position: center; 24 | border-radius: 50%; 25 | outline: 2px solid var(--color-grey-100); 26 | 27 | @media screen and (max-width: 768px) { 28 | width: 2.5rem; 29 | } 30 | `; 31 | const AvatarName = styled.span` 32 | @media screen and (max-width: 768px) { 33 | display: none; 34 | } 35 | `; 36 | 37 | function UserAvatar() { 38 | const { user } = useUser(); 39 | const { fullName, avatar } = user.user_metadata; 40 | 41 | return ( 42 | 43 | 47 | {fullName} 48 | 49 | ); 50 | } 51 | 52 | export default UserAvatar; 53 | -------------------------------------------------------------------------------- /src/ui/ErrorFallback.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import Heading from "./Heading"; 3 | import GlobalStyles from "../styles/GlobalStyles"; 4 | import Button from "./Button"; 5 | 6 | const StyledErrorFallback = styled.main` 7 | height: 100vh; 8 | background-color: var(--color-grey-50); 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | padding: 4.8rem; 13 | `; 14 | 15 | const Box = styled.div` 16 | /* Box */ 17 | background-color: var(--color-grey-0); 18 | border: 1px solid var(--color-grey-100); 19 | border-radius: var(--border-radius-md); 20 | 21 | padding: 4.8rem; 22 | flex: 0 1 96rem; 23 | text-align: center; 24 | 25 | & h1 { 26 | margin-bottom: 1.6rem; 27 | } 28 | 29 | & p { 30 | font-family: "Sono"; 31 | margin-bottom: 3.2rem; 32 | color: var(--color-grey-500); 33 | } 34 | `; 35 | function ErrorFallback({ error, resetErrorBoundary }) { 36 | return ( 37 | <> 38 | 39 | 40 | 41 | Something went wrong 🧐 42 |

{error.message}

43 | 46 |
47 |
48 | 49 | ); 50 | } 51 | 52 | export default ErrorFallback; 53 | -------------------------------------------------------------------------------- /src/context/DarkModeContext.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useEffect } from "react"; 2 | import { useLocalStorageState } from "../hooks/useLocalStorageState"; 3 | 4 | const DarkModeContext = createContext(); 5 | 6 | function DarkModeProvider({ children }) { 7 | const [isDarkMode, setIsDarkMode] = useLocalStorageState( 8 | window.matchMedia("(prefers-color-scheme: dark)").matches, 9 | "isDarkMode" 10 | ); 11 | 12 | useEffect( 13 | function () { 14 | if (isDarkMode) { 15 | document.documentElement.classList.add("dark-mode"); 16 | document.documentElement.classList.remove("light-mode"); 17 | } else { 18 | document.documentElement.classList.add("light-mode"); 19 | document.documentElement.classList.remove("dark-mode"); 20 | } 21 | }, 22 | [isDarkMode] 23 | ); 24 | 25 | function toggleDarkMode() { 26 | setIsDarkMode((isDark) => !isDark); 27 | } 28 | 29 | return ( 30 | 31 | {children} 32 | 33 | ); 34 | } 35 | 36 | function useDarkMode() { 37 | const context = useContext(DarkModeContext); 38 | if (context === undefined) 39 | throw new Error("DarkModeContext was used outside of DarkModeProvider"); 40 | return context; 41 | } 42 | 43 | export { DarkModeProvider, useDarkMode }; 44 | -------------------------------------------------------------------------------- /src/utils/helpers.js: -------------------------------------------------------------------------------- 1 | import { formatDistance, parseISO } from 'date-fns'; 2 | import { differenceInDays } from 'date-fns/esm'; 3 | 4 | // We want to make this function work for both Date objects and strings (which come from Supabase) 5 | export const subtractDates = (dateStr1, dateStr2) => 6 | differenceInDays(parseISO(String(dateStr1)), parseISO(String(dateStr2))); 7 | 8 | export const formatDistanceFromNow = (dateStr) => 9 | formatDistance(parseISO(dateStr), new Date(), { 10 | addSuffix: true, 11 | }) 12 | .replace('about ', '') 13 | .replace('in', 'In'); 14 | 15 | // Supabase needs an ISO date string. However, that string will be different on every render because the MS or SEC have changed, which isn't good. So we use this trick to remove any time 16 | export const getToday = function (options = {}) { 17 | const today = new Date(); 18 | 19 | // This is necessary to compare with created_at from Supabase, because it it not at 0.0.0.0, so we need to set the date to be END of the day when we compare it with earlier dates 20 | if (options?.end) 21 | // Set to the last second of the day 22 | today.setUTCHours(23, 59, 59, 999); 23 | else today.setUTCHours(0, 0, 0, 0); 24 | return today.toISOString(); 25 | }; 26 | 27 | export const formatCurrency = (value) => 28 | new Intl.NumberFormat('en', { style: 'currency', currency: 'USD' }).format( 29 | value 30 | ); 31 | -------------------------------------------------------------------------------- /src/features/cabins/CabinTable-v2.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import Spinner from "../../ui/Spinner"; 4 | import CabinRow from "./CabinRow"; 5 | import { useCabins } from "./useCabins"; 6 | 7 | const Table = styled.div` 8 | border: 1px solid var(--color-grey-200); 9 | 10 | font-size: 1.4rem; 11 | background-color: var(--color-grey-0); 12 | border-radius: 7px; 13 | overflow: hidden; 14 | `; 15 | 16 | const TableHeader = styled.header` 17 | display: grid; 18 | grid-template-columns: 0.6fr 1.8fr 2.2fr 1fr 1fr 1fr; 19 | column-gap: 2.4rem; 20 | align-items: center; 21 | 22 | background-color: var(--color-grey-50); 23 | border-bottom: 1px solid var(--color-grey-100); 24 | text-transform: uppercase; 25 | letter-spacing: 0.4px; 26 | font-weight: 600; 27 | color: var(--color-grey-600); 28 | padding: 1.6rem 2.4rem; 29 | `; 30 | 31 | function CabinTable() { 32 | const { isLoading, cabins } = useCabins(); 33 | 34 | if (isLoading) return ; 35 | 36 | return ( 37 | 38 | 39 |
40 |
Cabin
41 |
Capacity
42 |
Price
43 |
Discount
44 |
45 |
46 | {cabins.map((cabin) => ( 47 | 48 | ))} 49 |
50 | ); 51 | } 52 | 53 | export default CabinTable; 54 | -------------------------------------------------------------------------------- /src/ui/ConfirmDelete.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import Button from "./Button"; 3 | import Heading from "./Heading"; 4 | 5 | const StyledConfirmDelete = styled.div` 6 | width: 40rem; 7 | display: flex; 8 | flex-direction: column; 9 | gap: 1.2rem; 10 | 11 | & p { 12 | color: var(--color-grey-500); 13 | margin-bottom: 1.2rem; 14 | } 15 | 16 | & div { 17 | display: flex; 18 | justify-content: flex-end; 19 | gap: 1.2rem; 20 | @media screen and (max-width: 768px) { 21 | justify-content: flex-start; 22 | gap: 0.9rem; 23 | } 24 | } 25 | 26 | @media screen and (max-width: 768px) { 27 | flex-wrap: wrap; 28 | width: 70%; 29 | gap: 2.1rem; 30 | } 31 | `; 32 | 33 | function ConfirmDelete({ resourceName, onConfirm, disabled, onCloseModal }) { 34 | return ( 35 | 36 | Delete {resourceName} 37 |

38 | Are you sure you want to delete this {resourceName} permanently? This 39 | action cannot be undone. 40 |

41 | 42 |
43 | 50 | 53 |
54 |
55 | ); 56 | } 57 | 58 | export default ConfirmDelete; 59 | -------------------------------------------------------------------------------- /src/ui/Heading.jsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | // const test = css` 4 | // text-align: center; 5 | // ${10 > 5 && "background-color: yellow"} 6 | // `; 7 | 8 | const Heading = styled.h1` 9 | ${(props) => 10 | props.as === "h1" && 11 | css` 12 | font-size: 3rem; 13 | font-weight: 600; 14 | `} 15 | 16 | ${(props) => 17 | props.as === "h2" && 18 | css` 19 | font-size: 2rem; 20 | font-weight: 600; 21 | `} 22 | 23 | ${(props) => 24 | props.as === "h3" && 25 | css` 26 | font-size: 2rem; 27 | font-weight: 500; 28 | `} 29 | 30 | ${(props) => 31 | props.as === "h4" && 32 | css` 33 | font-size: 3rem; 34 | font-weight: 600; 35 | text-align: center; 36 | `} 37 | 38 | line-height: 1.4; 39 | 40 | @media screen and (max-width: 768px) { 41 | ${(props) => 42 | props.as === "h4" && 43 | css` 44 | font-size: 1.3rem; 45 | `} 46 | ${(props) => 47 | props.as === "h1" && 48 | css` 49 | font-size: 2rem; 50 | font-weight: 400; 51 | text-align: center; 52 | `} 53 | ${(props) => 54 | props.as === "h3" && 55 | css` 56 | font-size: 1rem; 57 | font-weight: 300; 58 | `} 59 | 60 | ${(props) => 61 | props.as === "h2" && 62 | css` 63 | font-size: 1.3rem; 64 | font-weight: 400; 65 | `} 66 | } 67 | `; 68 | 69 | export default Heading; 70 | -------------------------------------------------------------------------------- /src/features/cabins/CabinTable-v1.jsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from "@tanstack/react-query"; 2 | import styled from "styled-components"; 3 | import { getCabins } from "../../services/apiCabins"; 4 | import Spinner from "../../ui/Spinner"; 5 | import CabinRow from "./CabinRow"; 6 | 7 | const Table = styled.div` 8 | border: 1px solid var(--color-grey-200); 9 | 10 | font-size: 1.4rem; 11 | background-color: var(--color-grey-0); 12 | border-radius: 7px; 13 | overflow: hidden; 14 | `; 15 | 16 | const TableHeader = styled.header` 17 | display: grid; 18 | grid-template-columns: 0.6fr 1.8fr 2.2fr 1fr 1fr 1fr; 19 | column-gap: 2.4rem; 20 | align-items: center; 21 | 22 | background-color: var(--color-grey-50); 23 | border-bottom: 1px solid var(--color-grey-100); 24 | text-transform: uppercase; 25 | letter-spacing: 0.4px; 26 | font-weight: 600; 27 | color: var(--color-grey-600); 28 | padding: 1.6rem 2.4rem; 29 | `; 30 | 31 | function CabinTable() { 32 | const { 33 | isLoading, 34 | data: cabins, 35 | error, 36 | } = useQuery({ 37 | queryKey: ["cabins"], 38 | queryFn: getCabins, 39 | }); 40 | 41 | if (isLoading) return ; 42 | 43 | return ( 44 | 45 | 46 |
47 |
Cabin
48 |
Capacity
49 |
Price
50 |
Discount
51 |
52 |
53 | {cabins.map((cabin) => ( 54 | 55 | ))} 56 |
57 | ); 58 | } 59 | 60 | export default CabinTable; 61 | -------------------------------------------------------------------------------- /src/ui/Modal-v1.jsx: -------------------------------------------------------------------------------- 1 | import { createPortal } from "react-dom"; 2 | import { HiXMark } from "react-icons/hi2"; 3 | import styled from "styled-components"; 4 | 5 | const StyledModal = styled.div` 6 | position: fixed; 7 | top: 50%; 8 | left: 50%; 9 | transform: translate(-50%, -50%); 10 | background-color: var(--color-grey-0); 11 | border-radius: var(--border-radius-lg); 12 | box-shadow: var(--shadow-lg); 13 | padding: 3.2rem 4rem; 14 | transition: all 0.5s; 15 | `; 16 | 17 | const Overlay = styled.div` 18 | position: fixed; 19 | top: 0; 20 | left: 0; 21 | width: 100%; 22 | height: 100vh; 23 | background-color: var(--backdrop-color); 24 | backdrop-filter: blur(4px); 25 | z-index: 1000; 26 | transition: all 0.5s; 27 | `; 28 | 29 | const Button = styled.button` 30 | background: none; 31 | border: none; 32 | padding: 0.4rem; 33 | border-radius: var(--border-radius-sm); 34 | transform: translateX(0.8rem); 35 | transition: all 0.2s; 36 | position: absolute; 37 | top: 1.2rem; 38 | right: 1.9rem; 39 | 40 | &:hover { 41 | background-color: var(--color-grey-100); 42 | } 43 | 44 | & svg { 45 | width: 2.4rem; 46 | height: 2.4rem; 47 | /* Sometimes we need both */ 48 | /* fill: var(--color-grey-500); 49 | stroke: var(--color-grey-500); */ 50 | color: var(--color-grey-500); 51 | } 52 | `; 53 | 54 | function Modal({ children, onClose }) { 55 | return createPortal( 56 | 57 | 58 | 61 | 62 |
{children}
63 |
64 |
, 65 | document.body 66 | ); 67 | } 68 | 69 | export default Modal; 70 | -------------------------------------------------------------------------------- /src/features/guests/GuestList.jsx: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components'; 2 | import { useGuests } from 'features/guests/useGuests'; 3 | import Pagination from 'ui/Pagination'; 4 | import Spinner from 'ui/Spinner'; 5 | import GuestListItem from './GuestListItem'; 6 | 7 | const StyledGuestList = styled.div` 8 | border: 1px solid var(--color-grey-200); 9 | border-top: none; 10 | border-bottom-left-radius: var(--border-radius-md); 11 | border-bottom-right-radius: var(--border-radius-md); 12 | overflow: hidden; 13 | padding-top: 0.8rem; 14 | transform: translateY(-4px); 15 | `; 16 | 17 | const List = styled.ul``; 18 | 19 | const PaginationContainer = styled.div` 20 | border-top: 1px solid var(--color-grey-100); 21 | background-color: var(--color-grey-50); 22 | display: flex; 23 | justify-content: center; 24 | padding: 0.8rem; 25 | 26 | &:not(:has(*)) { 27 | display: none; 28 | } 29 | `; 30 | 31 | function GuestList({ onClick }) { 32 | const { isLoading, guests, count } = useGuests(); 33 | 34 | if (isLoading) return ; 35 | if (count === undefined) return null; 36 | if (count === 0) return

No guests found...

; 37 | 38 | return ( 39 | 40 | 41 | {guests.map((guest) => ( 42 | {}} 47 | /> 48 | ))} 49 | 50 | 51 | 52 | 53 | 54 | 55 | ); 56 | } 57 | 58 | export default GuestList; 59 | -------------------------------------------------------------------------------- /src/features/check-in-out/TodayItem.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { Link } from "react-router-dom"; 3 | 4 | import Tag from "../../ui/Tag"; 5 | import { Flag } from "../../ui/Flag"; 6 | import Button from "../../ui/Button"; 7 | import CheckoutButton from "./CheckoutButton"; 8 | 9 | const StyledTodayItem = styled.li` 10 | display: grid; 11 | grid-template-columns: 9rem 2rem 1fr 7rem 9rem; 12 | gap: 1.2rem; 13 | align-items: center; 14 | 15 | font-size: 1.4rem; 16 | padding: 0.8rem 0; 17 | border-bottom: 1px solid var(--color-grey-100); 18 | 19 | @media screen and (max-width: 790px) { 20 | font-size: 0.8rem; 21 | gap: 0.6rem; 22 | overflow: scroll; 23 | } 24 | 25 | &:first-child { 26 | border-top: 1px solid var(--color-grey-100); 27 | } 28 | `; 29 | 30 | const Guest = styled.div` 31 | font-weight: 500; 32 | `; 33 | 34 | function TodayItem({ activity }) { 35 | const { id, status, guests, numNights } = activity; 36 | 37 | return ( 38 | 39 | {status === "unconfirmed" && Arriving} 40 | {status === "checked-in" && Departing} 41 | 42 | 43 | {guests.fullName} 44 |
{numNights} nights
45 | 46 | {status === "unconfirmed" && ( 47 | 55 | )} 56 | {status === "checked-in" && } 57 |
58 | ); 59 | } 60 | 61 | export default TodayItem; 62 | -------------------------------------------------------------------------------- /src/ui/Sidebar.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import Logo from "./Logo"; 3 | import MainNav from "./MainNav"; 4 | 5 | import { useState } from "react"; 6 | import { IoMenu } from "react-icons/io5"; 7 | import { CiCircleRemove } from "react-icons/ci"; 8 | import Uploader from "./../data/Uploader"; 9 | 10 | const StyledSidebar = styled.aside` 11 | background-color: var(--color-grey-0); 12 | padding: 3.2rem 2.4rem; 13 | border-right: 1px solid var(--color-grey-100); 14 | position: relative; 15 | 16 | grid-row: 1 / -1; 17 | display: flex; 18 | flex-direction: column; 19 | gap: 3.2rem; 20 | @media screen and (max-width: 890px) { 21 | gap: 0.2rem; 22 | padding: 3rem 0 1rem 0.5rem; 23 | width: 50%; 24 | height: 100vh; 25 | position: fixed; 26 | left: -100%; 27 | z-index: 9999; 28 | &.active { 29 | left: 0; 30 | } 31 | } 32 | `; 33 | 34 | const Menubar = styled.div` 35 | position: absolute; 36 | top: 1rem; 37 | left: 0.9rem; 38 | font-size: 2rem; 39 | padding: auto; 40 | @media screen and (min-width: 890px) { 41 | display: none; 42 | } 43 | `; 44 | 45 | function Sidebar() { 46 | const [navOpen, setIsOpen] = useState(false); 47 | function handleClickMenu() { 48 | setIsOpen(!navOpen); 49 | } 50 | return ( 51 | <> 52 | handleClickMenu()}> 53 | 54 | 55 | 56 | handleClickMenu()}> 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | ); 65 | } 66 | 67 | export default Sidebar; 68 | -------------------------------------------------------------------------------- /src/features/bookings/useBookings.js: -------------------------------------------------------------------------------- 1 | import { useQuery, useQueryClient } from "@tanstack/react-query"; 2 | import { getBookings } from "../../services/apiBookings"; 3 | import { useSearchParams } from "react-router-dom"; 4 | import { PAGE_SIZE } from "../../utils/constants"; 5 | 6 | export function useBookings() { 7 | const queryClient = useQueryClient(); 8 | const [searchParams] = useSearchParams(); 9 | 10 | // FILTER 11 | const filterValue = searchParams.get("status"); 12 | const filter = 13 | !filterValue || filterValue === "all" 14 | ? null 15 | : { field: "status", value: filterValue }; 16 | // { field: "totalPrice", value: 5000, method: "gte" }; 17 | 18 | // SORT 19 | const sortByRaw = searchParams.get("sortBy") || "startDate-desc"; 20 | const [field, direction] = sortByRaw.split("-"); 21 | const sortBy = { field, direction }; 22 | 23 | // PAGINATION 24 | const page = !searchParams.get("page") ? 1 : Number(searchParams.get("page")); 25 | 26 | // QUERY 27 | const { 28 | isLoading, 29 | data: { data: bookings, count } = {}, 30 | error, 31 | } = useQuery({ 32 | queryKey: ["bookings", filter, sortBy, page], 33 | queryFn: () => getBookings({ filter, sortBy, page }), 34 | }); 35 | 36 | // PRE-FETCHING 37 | const pageCount = Math.ceil(count / PAGE_SIZE); 38 | 39 | if (page < pageCount) 40 | queryClient.prefetchQuery({ 41 | queryKey: ["bookings", filter, sortBy, page + 1], 42 | queryFn: () => getBookings({ filter, sortBy, page: page + 1 }), 43 | }); 44 | 45 | if (page > 1) 46 | queryClient.prefetchQuery({ 47 | queryKey: ["bookings", filter, sortBy, page - 1], 48 | queryFn: () => getBookings({ filter, sortBy, page: page - 1 }), 49 | }); 50 | 51 | return { isLoading, error, bookings, count }; 52 | } 53 | -------------------------------------------------------------------------------- /src/features/cabins/CabinTable.jsx: -------------------------------------------------------------------------------- 1 | import Spinner from "../../ui/Spinner"; 2 | import CabinRow from "./CabinRow"; 3 | import { useCabins } from "./useCabins"; 4 | import Table from "../../ui/Table"; 5 | import Menus from "../../ui/Menus"; 6 | import { useSearchParams } from "react-router-dom"; 7 | import Empty from "../../ui/Empty"; 8 | 9 | function CabinTable() { 10 | const { isLoading, cabins } = useCabins(); 11 | const [searchParams] = useSearchParams(); 12 | 13 | if (isLoading) return ; 14 | if (!cabins.length) return ; 15 | 16 | // 1) FILTER 17 | const filterValue = searchParams.get("discount") || "all"; 18 | 19 | let filteredCabins; 20 | if (filterValue === "all") filteredCabins = cabins; 21 | if (filterValue === "no-discount") 22 | filteredCabins = cabins.filter((cabin) => cabin.discount === 0); 23 | if (filterValue === "with-discount") 24 | filteredCabins = cabins.filter((cabin) => cabin.discount > 0); 25 | 26 | // 2) SORT 27 | const sortBy = searchParams.get("sortBy") || "startDate-asc"; 28 | const [field, direction] = sortBy.split("-"); 29 | const modifier = direction === "asc" ? 1 : -1; 30 | const sortedCabins = filteredCabins.sort( 31 | (a, b) => (a[field] - b[field]) * modifier 32 | ); 33 | 34 | return ( 35 | 36 | 37 | 38 |
39 |
Cabin
40 |
Capacity
41 |
Price
42 |
Discount
43 |
44 |
45 | 46 | } 49 | /> 50 |
51 |
52 | ); 53 | } 54 | 55 | export default CabinTable; 56 | -------------------------------------------------------------------------------- /src/features/authentication/LoginForm.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import Button from "../../ui/Button"; 3 | import Form from "../../ui/Form"; 4 | import Input from "../../ui/Input"; 5 | import FormRowVertical from "../../ui/FormRowVertical"; 6 | import { useLogin } from "./useLogin"; 7 | import SpinnerMini from "../../ui/SpinnerMini"; 8 | 9 | function LoginForm() { 10 | const [email, setEmail] = useState(""); 11 | const [password, setPassword] = useState(""); 12 | const { login, isLoading } = useLogin(); 13 | 14 | function handleSubmit(e) { 15 | e.preventDefault(); 16 | if (!email || !password) return; 17 | login( 18 | { email, password }, 19 | { 20 | onSettled: () => { 21 | setEmail(""); 22 | setPassword(""); 23 | }, 24 | } 25 | ); 26 | } 27 | 28 | return ( 29 |
30 | 31 | setEmail(e.target.value)} 38 | disabled={isLoading} 39 | /> 40 | 41 | 42 | 43 | setPassword(e.target.value)} 49 | disabled={isLoading} 50 | /> 51 | 52 | 53 | 56 | 57 |
58 | ); 59 | } 60 | 61 | export default LoginForm; 62 | -------------------------------------------------------------------------------- /src/features/dashboard/DashboardLayout.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import { useRecentStays } from "./useRecentStays"; 3 | import { useRecentBookings } from "./useRecentBookings"; 4 | import Spinner from "../../ui/Spinner"; 5 | import Stats from "./Stats"; 6 | import { useCabins } from "../cabins/useCabins"; 7 | import SalesChart from "./SalesChart"; 8 | import DurationChart from "./DurationChart"; 9 | // import TodayActivity from "../check-in-out/TodayActivity"; 10 | 11 | import TodayActivity from "./../check-in-out/TodayActivity"; 12 | 13 | const StyledDashboardLayout = styled.div` 14 | display: grid; 15 | grid-template-columns: 1fr 1fr 1fr 1fr; 16 | grid-template-rows: auto 34rem auto; 17 | gap: 2.4rem; 18 | 19 | @media screen and (max-width: 768px) { 20 | gap: 1rem; 21 | /* grid-template-columns: auto; */ 22 | } 23 | `; 24 | 25 | const StyledMain = styled.div` 26 | display: flex; 27 | flex-wrap: wrap; 28 | gap: 1rem; 29 | 30 | @media screen and (min-width: 769px) { 31 | justify-content: space-around; 32 | } 33 | `; 34 | 35 | function DashboardLayout() { 36 | const { bookings, isLoading: isLoading1 } = useRecentBookings(); 37 | const { confirmedStays, isLoading: isLoading2, numDays } = useRecentStays(); 38 | const { cabins, isLoading: isLoading3 } = useCabins(); 39 | 40 | if (isLoading1 || isLoading2 || isLoading3) return ; 41 | 42 | return ( 43 | <> 44 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | ); 60 | } 61 | 62 | export default DashboardLayout; 63 | -------------------------------------------------------------------------------- /src/features/dashboard/TodayItem.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import styled from "styled-components"; 3 | 4 | import { Flag } from "../../ui/Flag"; 5 | import Tag from "../../ui/Tag"; 6 | import CheckoutButton from "./../check-in-out/CheckoutButton"; 7 | import Button from "../../ui/Button"; 8 | 9 | const StyledTodayItem = styled.li` 10 | display: grid; 11 | grid-template-columns: 9rem 2rem 1fr 7rem 9rem; 12 | gap: 1.2rem; 13 | align-items: center; 14 | 15 | font-size: 1.4rem; 16 | padding: 0.8rem 0; 17 | border-bottom: 1px solid var(--color-grey-100); 18 | 19 | &:first-child { 20 | border-top: 1px solid var(--color-grey-100); 21 | } 22 | /* &:not(:last-child) { 23 | border-bottom: 1px solid var(--color-grey-100); 24 | } */ 25 | `; 26 | 27 | const Guest = styled.div` 28 | font-weight: 500; 29 | `; 30 | 31 | function TodayItem({ stay }) { 32 | const { id, status, guests, numNights } = stay; 33 | 34 | const statusToAction = { 35 | unconfirmed: { 36 | action: "arriving", 37 | tag: "green", 38 | button: ( 39 | 47 | ), 48 | }, 49 | "checked-in": { 50 | action: "departing", 51 | tag: "blue", 52 | button: , 53 | }, 54 | }; 55 | if (!(status in statusToAction)) { 56 | return null; 57 | } 58 | 59 | return ( 60 | 61 | 62 | {statusToAction[status].action} 63 | 64 | 65 | {guests.fullName} 66 |
{numNights} nights
67 | 68 | {statusToAction[status].button} 69 |
70 | ); 71 | } 72 | 73 | export default TodayItem; 74 | -------------------------------------------------------------------------------- /src/services/apiCabins.js: -------------------------------------------------------------------------------- 1 | import supabase, { supabaseUrl } from "./supabase"; 2 | 3 | export async function getCabins() { 4 | const { data, error } = await supabase.from("cabins").select("*"); 5 | 6 | if (error) { 7 | console.error(error); 8 | throw new Error("Cabins could not be loaded"); 9 | } 10 | 11 | return data; 12 | } 13 | 14 | export async function createEditCabin(newCabin, id) { 15 | const hasImagePath = newCabin.image?.startsWith?.(supabaseUrl); 16 | 17 | const imageName = `${Math.random()}-${newCabin.image.name}`.replaceAll( 18 | "/", 19 | "" 20 | ); 21 | const imagePath = hasImagePath 22 | ? newCabin.image 23 | : `${supabaseUrl}/storage/v1/object/public/cabin-images/${imageName}`; 24 | 25 | // 1. Create/edit cabin 26 | let query = supabase.from("cabins"); 27 | 28 | // A) CREATE 29 | if (!id) query = query.insert([{ ...newCabin, image: imagePath }]); 30 | 31 | // B) EDIT 32 | if (id) query = query.update({ ...newCabin, image: imagePath }).eq("id", id); 33 | 34 | const { data, error } = await query.select().single(); 35 | 36 | if (error) { 37 | console.error(error); 38 | throw new Error("Cabin could not be created"); 39 | } 40 | 41 | // 2. Upload image 42 | if (hasImagePath) return data; 43 | 44 | const { error: storageError } = await supabase.storage 45 | .from("cabin-images") 46 | .upload(imageName, newCabin.image); 47 | 48 | // 3. Delete the cabin IF there was an error uplaoding image 49 | if (storageError) { 50 | await supabase.from("cabins").delete().eq("id", data.id); 51 | console.error(storageError); 52 | throw new Error( 53 | "Cabin image could not be uploaded and the cabin was not created" 54 | ); 55 | } 56 | 57 | return data; 58 | } 59 | 60 | export async function deleteCabin(id) { 61 | const { data, error } = await supabase.from("cabins").delete().eq("id", id); 62 | 63 | if (error) { 64 | console.error(error); 65 | throw new Error("Cabin could not be deleted"); 66 | } 67 | 68 | return data; 69 | } 70 | -------------------------------------------------------------------------------- /src/features/dashboard/Stat.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | const StyledStat = styled.div` 4 | /* Box */ 5 | background-color: var(--color-grey-0); 6 | border: 1px solid var(--color-grey-100); 7 | border-radius: var(--border-radius-md); 8 | 9 | padding: 1.6rem; 10 | display: grid; 11 | grid-template-columns: 6.4rem 1fr; 12 | grid-template-rows: auto auto; 13 | column-gap: 1.6rem; 14 | row-gap: 0.4rem; 15 | @media screen and (max-width: 768px) { 16 | column-gap: 0.8rem; 17 | width: 40%; 18 | padding: 0.9rem; 19 | grid-template-columns: auto 1fr; 20 | align-items: center; 21 | } 22 | 23 | @media screen and (min-width: 769px) { 24 | width: auto; 25 | } 26 | `; 27 | 28 | const Icon = styled.div` 29 | grid-row: 1 / -1; 30 | aspect-ratio: 1; 31 | border-radius: 50%; 32 | display: flex; 33 | align-items: center; 34 | justify-content: center; 35 | 36 | /* Make these dynamic, based on the received prop */ 37 | background-color: var(--color-${(props) => props.color}-100); 38 | 39 | & svg { 40 | width: 3.2rem; 41 | height: 3.2rem; 42 | color: var(--color-${(props) => props.color}-700); 43 | @media screen and (max-width: 768px) { 44 | width: 1.7rem; 45 | height: 1.7rem; 46 | } 47 | } 48 | `; 49 | 50 | const Title = styled.h5` 51 | align-self: end; 52 | font-size: 1.2rem; 53 | text-transform: uppercase; 54 | letter-spacing: 0.4px; 55 | font-weight: 600; 56 | color: var(--color-grey-500); 57 | @media screen and (max-width: 768px) { 58 | font-size: 0.7rem; 59 | } 60 | `; 61 | 62 | const Value = styled.p` 63 | font-size: 2.4rem; 64 | line-height: 1; 65 | font-weight: 500; 66 | @media screen and (max-width: 768px) { 67 | font-size: 0.9rem; 68 | } 69 | `; 70 | 71 | function Stat({ icon, title, value, color }) { 72 | return ( 73 | 74 | {icon} 75 | {title} 76 | {value} 77 | 78 | ); 79 | } 80 | 81 | export default Stat; 82 | -------------------------------------------------------------------------------- /src/features/check-in-out/TodayActivity.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import Heading from "../../ui/Heading"; 4 | import Row from "../../ui/Row"; 5 | 6 | import { useTodayActivity } from "./useTodayActivity"; 7 | import Spinner from "../../ui/Spinner"; 8 | import TodayItem from "./TodayItem"; 9 | 10 | const StyledToday = styled.div` 11 | /* Box */ 12 | background-color: var(--color-grey-0); 13 | border: 1px solid var(--color-grey-100); 14 | border-radius: var(--border-radius-md); 15 | 16 | padding: 3.2rem; 17 | display: flex; 18 | flex-direction: column; 19 | gap: 2.4rem; 20 | grid-column: 1 / span 2; 21 | width: 60%; 22 | padding-top: 2.4rem; 23 | @media screen and (max-width: 768px) { 24 | padding: 0.9rem; 25 | width: 100%; 26 | gap: 1rem; 27 | } 28 | @media screen and (min-width: 769px) and (max-width: 1100px) { 29 | width: 100%; 30 | } 31 | `; 32 | 33 | const TodayList = styled.ul` 34 | overflow: scroll; 35 | overflow-x: hidden; 36 | 37 | /* Removing scrollbars for webkit, firefox, and ms, respectively */ 38 | &::-webkit-scrollbar { 39 | width: 0 !important; 40 | } 41 | scrollbar-width: none; 42 | -ms-overflow-style: none; 43 | `; 44 | 45 | const NoActivity = styled.p` 46 | text-align: center; 47 | font-size: 1.8rem; 48 | font-weight: 500; 49 | margin-top: 0.8rem; 50 | `; 51 | 52 | function TodayActivity() { 53 | const { activities, isLoading } = useTodayActivity(); 54 | 55 | return ( 56 | 57 | 58 | Today 59 | 60 | 61 | {!isLoading ? ( 62 | activities?.length > 0 ? ( 63 | 64 | {activities.map((activity) => ( 65 | 66 | ))} 67 | 68 | ) : ( 69 | No activity today... 70 | ) 71 | ) : ( 72 | 73 | )} 74 | 75 | ); 76 | } 77 | 78 | export default TodayActivity; 79 | -------------------------------------------------------------------------------- /src/ui/Filter.jsx: -------------------------------------------------------------------------------- 1 | import { useSearchParams } from "react-router-dom"; 2 | import styled, { css } from "styled-components"; 3 | 4 | const StyledFilter = styled.div` 5 | border: 1px solid var(--color-grey-100); 6 | background-color: var(--color-grey-0); 7 | box-shadow: var(--shadow-sm); 8 | border-radius: var(--border-radius-sm); 9 | padding: 0.4rem; 10 | display: flex; 11 | gap: 0.4rem; 12 | @media screen and (max-width: 768px) { 13 | gap: 0.2rem; 14 | padding: 0.2rem; 15 | } 16 | 17 | `; 18 | 19 | const FilterButton = styled.button` 20 | background-color: var(--color-grey-0); 21 | border: none; 22 | 23 | ${(props) => 24 | props.active && 25 | css` 26 | background-color: var(--color-brand-600); 27 | color: var(--color-brand-50); 28 | `} 29 | 30 | border-radius: var(--border-radius-sm); 31 | font-weight: 500; 32 | font-size: 1.4rem; 33 | /* To give the same height as select */ 34 | padding: 0.44rem 0.8rem; 35 | transition: all 0.3s; 36 | @media screen and (max-width: 768px) { 37 | font-size: 1rem; 38 | } 39 | 40 | &:hover:not(:disabled) { 41 | background-color: var(--color-brand-600); 42 | color: var(--color-brand-50); 43 | } 44 | `; 45 | 46 | function Filter({ filterField, options }) { 47 | const [searchParams, setSearchParams] = useSearchParams(); 48 | const currentFilter = searchParams.get(filterField) || options.at(0).value; 49 | 50 | function handleClick(value) { 51 | searchParams.set(filterField, value); 52 | if (searchParams.get("page")) searchParams.set("page", 1); 53 | 54 | setSearchParams(searchParams); 55 | } 56 | 57 | return ( 58 | 59 | {options.map((option) => ( 60 | handleClick(option.value)} 63 | active={option.value === currentFilter} 64 | disabled={option.value === currentFilter} 65 | > 66 | {option.label} 67 | 68 | ))} 69 | 70 | ); 71 | } 72 | 73 | export default Filter; 74 | -------------------------------------------------------------------------------- /src/features/authentication/UpdatePasswordForm.jsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import Button from "../../ui/Button"; 3 | import Form from "../../ui/Form"; 4 | import FormRow from "../../ui/FormRow"; 5 | import Input from "../../ui/Input"; 6 | 7 | import { useUpdateUser } from "./useUpdateUser"; 8 | 9 | function UpdatePasswordForm() { 10 | const { register, handleSubmit, formState, getValues, reset } = useForm(); 11 | const { errors } = formState; 12 | 13 | const { updateUser, isUpdating } = useUpdateUser(); 14 | 15 | function onSubmit({ password }) { 16 | updateUser({ password }, { onSuccess: reset }); 17 | } 18 | 19 | return ( 20 |
21 | 25 | 38 | 39 | 40 | 44 | 52 | getValues().password === value || "Passwords need to match", 53 | })} 54 | /> 55 | 56 | 57 | 60 | 61 | 62 |
63 | ); 64 | } 65 | 66 | export default UpdatePasswordForm; 67 | -------------------------------------------------------------------------------- /src/features/dashboard/Stats.jsx: -------------------------------------------------------------------------------- 1 | import { 2 | HiOutlineBanknotes, 3 | HiOutlineBriefcase, 4 | HiOutlineCalendarDays, 5 | HiOutlineChartBar, 6 | } from "react-icons/hi2"; 7 | import Stat from "./Stat"; 8 | import { formatCurrency } from "../../utils/helpers"; 9 | import styled from "styled-components"; 10 | 11 | const Styledflex = styled.div` 12 | @media screen and (max-width: 768px) { 13 | display: flex; 14 | gap: 0.7rem; 15 | flex-wrap: wrap; 16 | justify-content: space-around; 17 | align-items: center; 18 | } 19 | 20 | @media screen and (min-width: 769px) and (max-width: 1100px) { 21 | display: grid; 22 | grid-template-columns: 1fr 1fr; 23 | gap: 0.9rem; 24 | } 25 | @media screen and (min-width: 1101px) { 26 | display: grid; 27 | grid-template-columns: 1fr 1fr 1fr 1fr; 28 | gap: 0.9rem; 29 | } 30 | `; 31 | function Stats({ bookings, confirmedStays, numDays, cabinCount }) { 32 | // 1. 33 | const numBookings = bookings.length; 34 | 35 | // 2. 36 | const sales = bookings.reduce((acc, cur) => acc + cur.totalPrice, 0); 37 | 38 | // 3. 39 | const checkins = confirmedStays.length; 40 | 41 | // 4. 42 | const occupation = 43 | confirmedStays.reduce((acc, cur) => acc + cur.numNights, 0) / 44 | (numDays * cabinCount); 45 | // num checked in nights / all available nights (num days * num cabins) 46 | 47 | return ( 48 | 49 | } 53 | value={numBookings} 54 | /> 55 | } 59 | value={formatCurrency(sales)} 60 | /> 61 | } 65 | value={checkins} 66 | /> 67 | } 71 | value={Math.round(occupation * 100) + "%"} 72 | /> 73 | 74 | ); 75 | } 76 | 77 | export default Stats; 78 | -------------------------------------------------------------------------------- /src/services/apiAuth.js: -------------------------------------------------------------------------------- 1 | import supabase, { supabaseUrl } from "./supabase"; 2 | 3 | export async function signup({ fullName, email, password }) { 4 | const { data, error } = await supabase.auth.signUp({ 5 | email, 6 | password, 7 | options: { 8 | data: { 9 | fullName, 10 | avatar: "", 11 | }, 12 | }, 13 | }); 14 | 15 | if (error) throw new Error(error.message); 16 | 17 | return data; 18 | } 19 | 20 | export async function login({ email, password }) { 21 | const { data, error } = await supabase.auth.signInWithPassword({ 22 | email, 23 | password, 24 | }); 25 | 26 | if (error) throw new Error(error.message); 27 | 28 | return data; 29 | } 30 | 31 | export async function getCurrentUser() { 32 | const { data: session } = await supabase.auth.getSession(); 33 | if (!session.session) return null; 34 | 35 | const { data, error } = await supabase.auth.getUser(); 36 | 37 | if (error) throw new Error(error.message); 38 | return data?.user; 39 | } 40 | 41 | export async function logout() { 42 | const { error } = await supabase.auth.signOut(); 43 | if (error) throw new Error(error.message); 44 | } 45 | 46 | export async function updateCurrentUser({ password, fullName, avatar }) { 47 | // 1. Update password OR fullName 48 | let updateData; 49 | if (password) updateData = { password }; 50 | if (fullName) updateData = { data: { fullName } }; 51 | 52 | const { data, error } = await supabase.auth.updateUser(updateData); 53 | 54 | if (error) throw new Error(error.message); 55 | if (!avatar) return data; 56 | 57 | // 2. Upload the avatar image 58 | const fileName = `avatar-${data.user.id}-${Math.random()}`; 59 | 60 | const { error: storageError } = await supabase.storage 61 | .from("avatars") 62 | .upload(fileName, avatar); 63 | 64 | if (storageError) throw new Error(storageError.message); 65 | 66 | // 3. Update avatar in the user 67 | const { data: updatedUser, error: error2 } = await supabase.auth.updateUser({ 68 | data: { 69 | avatar: `${supabaseUrl}/storage/v1/object/public/avatars/${fileName}`, 70 | }, 71 | }); 72 | 73 | if (error2) throw new Error(error2.message); 74 | return updatedUser; 75 | } 76 | -------------------------------------------------------------------------------- /src/features/settings/UpdateSettingsForm.jsx: -------------------------------------------------------------------------------- 1 | import Form from "../../ui/Form"; 2 | import FormRow from "../../ui/FormRow"; 3 | import Input from "../../ui/Input"; 4 | import Spinner from "../../ui/Spinner"; 5 | import { useSettings } from "./useSettings"; 6 | import { useUpdateSetting } from "./useUpdateSetting"; 7 | 8 | function UpdateSettingsForm() { 9 | const { 10 | isLoading, 11 | settings: { 12 | minBookingLength, 13 | maxBookingLength, 14 | maxGuestsPerBooking, 15 | breakfastPrice, 16 | } = {}, 17 | } = useSettings(); 18 | const { isUpdating, updateSetting } = useUpdateSetting(); 19 | 20 | if (isLoading) return ; 21 | 22 | function handleUpdate(e, field) { 23 | const { value } = e.target; 24 | 25 | if (!value) return; 26 | updateSetting({ [field]: value }); 27 | } 28 | 29 | return ( 30 |
31 | 32 | handleUpdate(e, "minBookingLength")} 38 | /> 39 | 40 | 41 | 42 | handleUpdate(e, "maxBookingLength")} 48 | /> 49 | 50 | 51 | 52 | handleUpdate(e, "maxGuestsPerBooking")} 58 | /> 59 | 60 | 61 | 62 | handleUpdate(e, "breakfastPrice")} 68 | /> 69 | 70 |
71 | ); 72 | } 73 | 74 | export default UpdateSettingsForm; 75 | -------------------------------------------------------------------------------- /src/ui/Button.jsx: -------------------------------------------------------------------------------- 1 | import styled, { css } from "styled-components"; 2 | 3 | const sizes = { 4 | small: css` 5 | font-size: 1.2rem; 6 | padding: 0.4rem 0.8rem; 7 | text-transform: uppercase; 8 | font-weight: 600; 9 | text-align: center; 10 | `, 11 | medium: css` 12 | font-size: 1.4rem; 13 | padding: 1.2rem 1.6rem; 14 | font-weight: 500; 15 | @media screen and (max-width: 868px) { 16 | font-size: 1rem; 17 | padding: 1rem 1.4rem; 18 | } 19 | `, 20 | large: css` 21 | font-size: 1.6rem; 22 | padding: 1.2rem 2.4rem; 23 | font-weight: 500; 24 | `, 25 | }; 26 | 27 | const variations = { 28 | primary: css` 29 | color: var(--color-brand-50); 30 | background-color: var(--color-brand-600); 31 | 32 | &:hover { 33 | background-color: var(--color-brand-700); 34 | } 35 | `, 36 | secondary: css` 37 | color: var(--color-grey-600); 38 | background: var(--color-grey-0); 39 | border: 1px solid var(--color-grey-200); 40 | 41 | &:hover { 42 | background-color: var(--color-grey-50); 43 | } 44 | `, 45 | danger: css` 46 | color: var(--color-red-100); 47 | background-color: var(--color-red-700); 48 | 49 | &:hover { 50 | background-color: var(--color-red-800); 51 | } 52 | `, 53 | }; 54 | 55 | const Button = styled.button` 56 | border: none; 57 | border-radius: var(--border-radius-sm); 58 | box-shadow: var(--shadow-sm); 59 | 60 | ${(props) => sizes[props.size]} 61 | ${(props) => variations[props.variation]}; 62 | 63 | /* media quer */ 64 | 65 | @media screen and (max-width: 768px) { 66 | ${(props) => 67 | props.size === "large" && 68 | css` 69 | font-size: 1rem; 70 | padding: 0.6rem 1rem; 71 | `}; 72 | ${(props) => 73 | props.size === "medium" && 74 | css` 75 | font-size: 0.9rem; 76 | padding: 0.4rem 0.6rem; 77 | `} 78 | ${(props) => 79 | props.size === "small" && 80 | css` 81 | font-size: 0.8rem; 82 | padding: 0.4rem 0.5rem; 83 | width: max-content; 84 | `} 85 | } 86 | `; 87 | 88 | Button.defaultProps = { 89 | variation: "primary", 90 | size: "medium", 91 | }; 92 | 93 | export default Button; 94 | -------------------------------------------------------------------------------- /src/features/authentication/UpdateUserDataForm.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | import Button from "../../ui/Button"; 4 | import FileInput from "../../ui/FileInput"; 5 | import Form from "../../ui/Form"; 6 | import FormRow from "../../ui/FormRow"; 7 | import Input from "../../ui/Input"; 8 | 9 | import { useUser } from "./useUser"; 10 | import { useUpdateUser } from "./useUpdateUser"; 11 | 12 | function UpdateUserDataForm() { 13 | // We don't need the loading state, and can immediately use the user data, because we know that it has already been loaded at this point 14 | const { 15 | user: { 16 | email, 17 | user_metadata: { fullName: currentFullName }, 18 | }, 19 | } = useUser(); 20 | 21 | const { updateUser, isUpdating } = useUpdateUser(); 22 | 23 | const [fullName, setFullName] = useState(currentFullName); 24 | const [avatar, setAvatar] = useState(null); 25 | 26 | function handleSubmit(e) { 27 | e.preventDefault(); 28 | if (!fullName) return; 29 | updateUser( 30 | { fullName, avatar }, 31 | { 32 | onSuccess: () => { 33 | setAvatar(null); 34 | e.target.reset(); 35 | }, 36 | } 37 | ); 38 | } 39 | 40 | function handleCancel() { 41 | setFullName(currentFullName); 42 | setAvatar(null); 43 | } 44 | 45 | return ( 46 |
47 | 48 | 49 | 50 | 51 | 52 | setFullName(e.target.value)} 56 | id="fullName" 57 | disabled={isUpdating} 58 | /> 59 | 60 | 61 | 62 | setAvatar(e.target.files[0])} 66 | disabled={isUpdating} 67 | /> 68 | 69 | 70 | 71 | 79 | 80 | 81 |
82 | ); 83 | } 84 | 85 | export default UpdateUserDataForm; 86 | -------------------------------------------------------------------------------- /src/ui/MainNav.jsx: -------------------------------------------------------------------------------- 1 | import { NavLink } from "react-router-dom"; 2 | import styled from "styled-components"; 3 | import { 4 | HiOutlineCalendarDays, 5 | HiOutlineCog6Tooth, 6 | HiOutlineHome, 7 | HiOutlineHomeModern, 8 | HiOutlineUsers, 9 | } from "react-icons/hi2"; 10 | 11 | const NavList = styled.ul` 12 | display: flex; 13 | flex-direction: column; 14 | gap: 0.8rem; 15 | 16 | @media screen and (max-width: 768px) { 17 | /* align-items: center; */ 18 | gap: 0.5rem; 19 | } 20 | `; 21 | 22 | const StyledNavLink = styled(NavLink)` 23 | &:link, 24 | &:visited { 25 | display: flex; 26 | align-items: center; 27 | gap: 1.2rem; 28 | 29 | color: var(--color-grey-600); 30 | font-size: 1.6rem; 31 | font-weight: 500; 32 | padding: 1.2rem 2.4rem; 33 | transition: all 0.3s; 34 | @media screen and (max-width: 768px) { 35 | gap: 2rem; 36 | font-size: 0.9rem; 37 | padding: 0.8rem 1.4rem; 38 | } 39 | } 40 | 41 | /* This works because react-router places the active class on the active NavLink */ 42 | &:hover, 43 | &:active, 44 | &.active:link, 45 | &.active:visited { 46 | color: var(--color-grey-800); 47 | background-color: var(--color-grey-50); 48 | border-radius: var(--border-radius-sm); 49 | } 50 | 51 | & svg { 52 | width: 2.4rem; 53 | height: 2.4rem; 54 | color: var(--color-grey-400); 55 | transition: all 0.3s; 56 | @media screen and (max-width: 768px) { 57 | width: 1.8rem; 58 | } 59 | } 60 | 61 | &:hover svg, 62 | &:active svg, 63 | &.active:link svg, 64 | &.active:visited svg { 65 | color: var(--color-brand-600); 66 | } 67 | `; 68 | 69 | function MainNav({ handleClickMenu }) { 70 | return ( 71 | 105 | ); 106 | } 107 | 108 | export default MainNav; 109 | -------------------------------------------------------------------------------- /src/features/authentication/SignupForm.jsx: -------------------------------------------------------------------------------- 1 | import { useForm } from "react-hook-form"; 2 | import Button from "../../ui/Button"; 3 | import Form from "../../ui/Form"; 4 | import FormRow from "../../ui/FormRow"; 5 | import Input from "../../ui/Input"; 6 | import { useSignup } from "./useSignup"; 7 | 8 | // Email regex: /\S+@\S+\.\S+/ 9 | 10 | function SignupForm() { 11 | const { signup, isLoading } = useSignup(); 12 | const { register, formState, getValues, handleSubmit, reset } = useForm(); 13 | const { errors } = formState; 14 | 15 | function onSubmit({ fullName, email, password }) { 16 | signup( 17 | { fullName, email, password }, 18 | { 19 | onSettled: () => reset(), 20 | } 21 | ); 22 | } 23 | 24 | return ( 25 |
26 | 27 | 33 | 34 | 35 | 36 | 48 | 49 | 50 | 54 | 66 | 67 | 68 | 69 | 76 | value === getValues().password || "Passwords need to match", 77 | })} 78 | /> 79 | 80 | 81 | 82 | {/* type is an HTML attribute! */} 83 | 91 | 92 | 93 |
94 | ); 95 | } 96 | 97 | export default SignupForm; 98 | -------------------------------------------------------------------------------- /src/ui/Modal.jsx: -------------------------------------------------------------------------------- 1 | import { cloneElement, createContext, useContext, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { HiXMark } from "react-icons/hi2"; 4 | import styled from "styled-components"; 5 | import { useOutsideClick } from "../hooks/useOutsideClick"; 6 | 7 | const StyledModal = styled.div` 8 | position: fixed; 9 | top: 50%; 10 | left: 50%; 11 | transform: translate(-50%, -50%); 12 | background-color: var(--color-grey-0); 13 | border-radius: var(--border-radius-lg); 14 | box-shadow: var(--shadow-lg); 15 | padding: 3.2rem 4rem; 16 | transition: all 0.5s; 17 | @media screen and (max-width: 768px) { 18 | width: 80%; 19 | font-size: 1rem; 20 | overflow-y: scroll; 21 | height: 60%; 22 | padding: 0.9rem; 23 | } 24 | `; 25 | 26 | const Overlay = styled.div` 27 | position: fixed; 28 | top: 0; 29 | left: 0; 30 | width: 100%; 31 | height: 100vh; 32 | background-color: var(--backdrop-color); 33 | backdrop-filter: blur(4px); 34 | z-index: 999999; 35 | transition: all 0.5s; 36 | overflow: scroll; 37 | @media screen and (max-width: 768px) { 38 | padding: 2rem 0.9rem; 39 | } 40 | `; 41 | 42 | const Button = styled.button` 43 | background: none; 44 | border: none; 45 | padding: 0.4rem; 46 | border-radius: var(--border-radius-sm); 47 | transform: translateX(0.8rem); 48 | transition: all 0.2s; 49 | position: absolute; 50 | top: 1.2rem; 51 | right: 1.9rem; 52 | 53 | &:hover { 54 | background-color: var(--color-grey-100); 55 | } 56 | 57 | & svg { 58 | width: 2.4rem; 59 | height: 2.4rem; 60 | /* Sometimes we need both */ 61 | /* fill: var(--color-grey-500); 62 | stroke: var(--color-grey-500); */ 63 | color: var(--color-grey-500); 64 | } 65 | `; 66 | 67 | const ModalContext = createContext(); 68 | 69 | function Modal({ children }) { 70 | const [openName, setOpenName] = useState(""); 71 | 72 | const close = () => setOpenName(""); 73 | const open = setOpenName; 74 | 75 | return ( 76 | 77 | {children} 78 | 79 | ); 80 | } 81 | 82 | function Open({ children, opens: opensWindowName }) { 83 | const { open } = useContext(ModalContext); 84 | 85 | return cloneElement(children, { onClick: () => open(opensWindowName) }); 86 | } 87 | 88 | function Window({ children, name }) { 89 | const { openName, close } = useContext(ModalContext); 90 | const ref = useOutsideClick(close); 91 | 92 | if (name !== openName) return null; 93 | 94 | return createPortal( 95 | 96 | 97 | 100 | 101 |
{cloneElement(children, { onCloseModal: close })}
102 |
103 |
, 104 | document.body 105 | ); 106 | } 107 | 108 | Modal.Open = Open; 109 | Modal.Window = Window; 110 | 111 | export default Modal; 112 | -------------------------------------------------------------------------------- /src/App.jsx: -------------------------------------------------------------------------------- 1 | import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; 2 | import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; 3 | import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 4 | import { Toaster } from "react-hot-toast"; 5 | 6 | import GlobalStyles from "./styles/GlobalStyles"; 7 | import Dashboard from "./pages/Dashboard"; 8 | import Bookings from "./pages/Bookings"; 9 | import Cabins from "./pages/Cabins"; 10 | import Users from "./pages/Users"; 11 | import Settings from "./pages/Settings"; 12 | import Account from "./pages/Account"; 13 | import Login from "./pages/Login"; 14 | import PageNotFound from "./pages/PageNotFound"; 15 | import AppLayout from "./ui/AppLayout"; 16 | import Booking from "./pages/Booking"; 17 | import Checkin from "./pages/Checkin"; 18 | import ProtectedRoute from "./ui/ProtectedRoute"; 19 | import { DarkModeProvider } from "./context/DarkModeContext"; 20 | 21 | const queryClient = new QueryClient({ 22 | defaultOptions: { 23 | queries: { 24 | // staleTime: 60 * 1000, 25 | staleTime: 0, 26 | }, 27 | }, 28 | }); 29 | 30 | function App() { 31 | return ( 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 42 | 43 | 44 | } 45 | > 46 | } /> 47 | } /> 48 | } /> 49 | } /> 50 | } /> 51 | } /> 52 | } /> 53 | } /> 54 | } /> 55 | 56 | 57 | } /> 58 | } /> 59 | 60 | 61 | 62 | 82 | 83 | 84 | ); 85 | } 86 | 87 | export default App; 88 | -------------------------------------------------------------------------------- /src/features/bookings/BookingDetail.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import BookingDataBox from "./BookingDataBox"; 4 | import Row from "../../ui/Row"; 5 | import Heading from "../../ui/Heading"; 6 | import Tag from "../../ui/Tag"; 7 | import ButtonGroup from "../../ui/ButtonGroup"; 8 | import Button from "../../ui/Button"; 9 | import ButtonText from "../../ui/ButtonText"; 10 | 11 | import { useMoveBack } from "../../hooks/useMoveBack"; 12 | import { useBooking } from "./useBooking"; 13 | import Spinner from "../../ui/Spinner"; 14 | import { useNavigate } from "react-router-dom"; 15 | import { HiArrowUpOnSquare } from "react-icons/hi2"; 16 | import { useCheckout } from "../check-in-out/useCheckout"; 17 | import Modal from "../../ui/Modal"; 18 | import ConfirmDelete from "../../ui/ConfirmDelete"; 19 | import { useDeleteBooking } from "./useDeleteBooking"; 20 | import Empty from "../../ui/Empty"; 21 | 22 | const HeadingGroup = styled.div` 23 | display: flex; 24 | gap: 2.4rem; 25 | align-items: center; 26 | `; 27 | 28 | function BookingDetail() { 29 | const { booking, isLoading } = useBooking(); 30 | const { checkout, isCheckingOut } = useCheckout(); 31 | const { deleteBooking, isDeleting } = useDeleteBooking(); 32 | 33 | const moveBack = useMoveBack(); 34 | const navigate = useNavigate(); 35 | 36 | if (isLoading) return ; 37 | if (!booking) return ; 38 | 39 | const { status, id: bookingId } = booking; 40 | 41 | const statusToTagName = { 42 | unconfirmed: "blue", 43 | "checked-in": "green", 44 | "checked-out": "silver", 45 | }; 46 | 47 | return ( 48 | <> 49 | 50 | 51 | Booking #{bookingId} 52 | {status.replace("-", " ")} 53 | 54 | ← Back 55 | 56 | 57 | 58 | 59 | 60 | {status === "unconfirmed" && ( 61 | 64 | )} 65 | 66 | {status === "checked-in" && ( 67 | 74 | )} 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 86 | deleteBooking(bookingId, { 87 | onSettled: () => navigate(-1), 88 | }) 89 | } 90 | /> 91 | 92 | 93 | 94 | 97 | 98 | 99 | ); 100 | } 101 | 102 | export default BookingDetail; 103 | -------------------------------------------------------------------------------- /src/features/cabins/CabinRow-v1.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import CreateCabinForm from "./CreateCabinForm"; 4 | import { useDeleteCabin } from "./useDeleteCabin"; 5 | import { formatCurrency } from "../../utils/helpers"; 6 | import { HiPencil, HiSquare2Stack, HiTrash } from "react-icons/hi2"; 7 | import { useCreateCabin } from "./useCreateCabin"; 8 | import Modal from "../../ui/Modal"; 9 | import ConfirmDelete from "../../ui/ConfirmDelete"; 10 | import Table from "../../ui/Table"; 11 | 12 | // const TableRow = styled.div` 13 | // display: grid; 14 | // grid-template-columns: 0.6fr 1.8fr 2.2fr 1fr 1fr 1fr; 15 | // column-gap: 2.4rem; 16 | // align-items: center; 17 | // padding: 1.4rem 2.4rem; 18 | 19 | // &:not(:last-child) { 20 | // border-bottom: 1px solid var(--color-grey-100); 21 | // } 22 | // `; 23 | 24 | const Img = styled.img` 25 | display: block; 26 | width: 6.4rem; 27 | aspect-ratio: 3 / 2; 28 | object-fit: cover; 29 | object-position: center; 30 | transform: scale(1.5) translateX(-7px); 31 | `; 32 | 33 | const Cabin = styled.div` 34 | font-size: 1.6rem; 35 | font-weight: 600; 36 | color: var(--color-grey-600); 37 | font-family: "Sono"; 38 | `; 39 | 40 | const Price = styled.div` 41 | font-family: "Sono"; 42 | font-weight: 600; 43 | `; 44 | 45 | const Discount = styled.div` 46 | font-family: "Sono"; 47 | font-weight: 500; 48 | color: var(--color-green-700); 49 | `; 50 | 51 | function CabinRow({ cabin }) { 52 | const { isDeleting, deleteCabin } = useDeleteCabin(); 53 | const { isCreating, createCabin } = useCreateCabin(); 54 | 55 | const { 56 | id: cabinId, 57 | name, 58 | maxCapacity, 59 | regularPrice, 60 | discount, 61 | image, 62 | description, 63 | } = cabin; 64 | 65 | function handleDuplicate() { 66 | createCabin({ 67 | name: `Copy of ${name}`, 68 | maxCapacity, 69 | regularPrice, 70 | discount, 71 | image, 72 | description, 73 | }); 74 | } 75 | 76 | return ( 77 | 78 | 79 | {name} 80 |
Fits up to {maxCapacity} guests
81 | {formatCurrency(regularPrice)} 82 | {discount ? ( 83 | {formatCurrency(discount)} 84 | ) : ( 85 | 86 | )} 87 |
88 | 91 | 92 | 93 | 94 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 106 | 107 | 108 | deleteCabin(cabinId)} 112 | /> 113 | 114 | 115 |
116 |
117 | ); 118 | } 119 | 120 | export default CabinRow; 121 | -------------------------------------------------------------------------------- /src/ui/Table.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext } from "react"; 2 | import styled from "styled-components"; 3 | 4 | const StyledTable = styled.div` 5 | border: 1px solid var(--color-grey-200); 6 | 7 | font-size: 1.4rem; 8 | background-color: var(--color-grey-0); 9 | border-radius: 7px; 10 | /* overflow: scroll; */ 11 | @media screen and (max-width: 768px) { 12 | font-size: 0.9rem; 13 | overflow-x: scroll; 14 | } 15 | @media screen and (min-width: 1120px) { 16 | margin-bottom: 2.5rem; 17 | overflow-y: hidden; 18 | } 19 | `; 20 | 21 | const CommonRow = styled.div` 22 | display: grid; 23 | grid-template-columns: ${(props) => props.columns}; 24 | column-gap: 2.9rem; 25 | align-items: center; 26 | transition: none; 27 | 28 | @media screen and (max-width: 768px) { 29 | column-gap: 1.3rem; 30 | } 31 | @media screen and (min-width: 889px) and (max-width: 1260px) { 32 | column-gap: 1.7rem; 33 | } 34 | `; 35 | 36 | const StyledHeader = styled(CommonRow)` 37 | padding: 1.6rem 2.4rem; 38 | 39 | background-color: var(--color-grey-50); 40 | border-bottom: 1px solid var(--color-grey-100); 41 | text-transform: uppercase; 42 | letter-spacing: 0.4px; 43 | font-weight: 600; 44 | color: var(--color-grey-600); 45 | @media screen and (max-width: 768px) { 46 | padding: 0.8rem 0.6rem; 47 | width: 100%; 48 | background-color: transparent; 49 | gap: 2.2rem; 50 | } 51 | `; 52 | 53 | const StyledRow = styled(CommonRow)` 54 | padding: 1.2rem 2.4rem; 55 | 56 | @media screen and (max-width: 768px) { 57 | padding: 0.6rem 0.3rem; 58 | gap: 1rem; 59 | } 60 | 61 | &:not(:last-child) { 62 | border-bottom: 1px solid var(--color-grey-100); 63 | } 64 | `; 65 | 66 | const StyledBody = styled.section` 67 | margin: 0.4rem 0; 68 | `; 69 | 70 | const Footer = styled.footer` 71 | background-color: var(--color-grey-50); 72 | display: flex; 73 | justify-content: center; 74 | padding: 1.2rem; 75 | 76 | /* This will hide the footer when it contains no child elements. Possible thanks to the parent selector :has 🎉 */ 77 | &:not(:has(*)) { 78 | display: none; 79 | } 80 | `; 81 | 82 | const Empty = styled.p` 83 | font-size: 1.6rem; 84 | font-weight: 500; 85 | text-align: center; 86 | margin: 2.4rem; 87 | `; 88 | 89 | const TableContext = createContext(); 90 | 91 | function Table({ columns, children }) { 92 | return ( 93 | 94 | {children} 95 | 96 | ); 97 | } 98 | 99 | function Header({ children }) { 100 | const { columns } = useContext(TableContext); 101 | return ( 102 | 103 | {children} 104 | 105 | ); 106 | } 107 | function Row({ children }) { 108 | const { columns } = useContext(TableContext); 109 | return ( 110 | 111 | {children} 112 | 113 | ); 114 | } 115 | 116 | function Body({ data, render }) { 117 | if (!data.length) return No data to show at the moment; 118 | 119 | return {data.map(render)}; 120 | } 121 | 122 | Table.Header = Header; 123 | Table.Body = Body; 124 | Table.Row = Row; 125 | Table.Footer = Footer; 126 | 127 | export default Table; 128 | -------------------------------------------------------------------------------- /src/ui/Pagination.jsx: -------------------------------------------------------------------------------- 1 | import { HiChevronLeft, HiChevronRight } from "react-icons/hi2"; 2 | import { useSearchParams } from "react-router-dom"; 3 | import styled from "styled-components"; 4 | import { PAGE_SIZE } from "../utils/constants"; 5 | 6 | const StyledPagination = styled.div` 7 | width: 100%; 8 | display: flex; 9 | align-items: center; 10 | justify-content: space-between; 11 | `; 12 | 13 | const P = styled.p` 14 | font-size: 1.4rem; 15 | margin-left: 0.8rem; 16 | @media screen and (max-width: 768px) { 17 | font-size: 0.9rem; 18 | } 19 | 20 | & span { 21 | font-weight: 600; 22 | @media screen and (max-width: 768px) { 23 | font-size: 0.7rem; 24 | } 25 | } 26 | `; 27 | 28 | const Buttons = styled.div` 29 | display: flex; 30 | gap: 0.6rem; 31 | @media screen and (max-width: 768px) { 32 | font-size: 0.7rem; 33 | gap: 0.4rem; 34 | } 35 | `; 36 | 37 | const PaginationButton = styled.button` 38 | background-color: ${(props) => 39 | props.active ? " var(--color-brand-600)" : "var(--color-grey-50)"}; 40 | color: ${(props) => (props.active ? " var(--color-brand-50)" : "inherit")}; 41 | border: none; 42 | border-radius: var(--border-radius-sm); 43 | font-weight: 500; 44 | font-size: 1.4rem; 45 | 46 | display: flex; 47 | align-items: center; 48 | justify-content: center; 49 | gap: 0.4rem; 50 | padding: 0.6rem 1.2rem; 51 | transition: all 0.3s; 52 | @media screen and (max-width: 768px) { 53 | font-size: 0.9rem; 54 | } 55 | 56 | &:has(span:last-child) { 57 | padding-left: 0.4rem; 58 | } 59 | 60 | &:has(span:first-child) { 61 | padding-right: 0.4rem; 62 | } 63 | 64 | & svg { 65 | height: 1.8rem; 66 | width: 1.8rem; 67 | @media screen and (max-width: 768px) { 68 | height: 1rem; 69 | width: 1rem; 70 | } 71 | } 72 | 73 | &:hover:not(:disabled) { 74 | background-color: var(--color-brand-600); 75 | color: var(--color-brand-50); 76 | } 77 | `; 78 | 79 | function Pagination({ count }) { 80 | const [searchParams, setSearchParams] = useSearchParams(); 81 | const currentPage = !searchParams.get("page") 82 | ? 1 83 | : Number(searchParams.get("page")); 84 | 85 | const pageCount = Math.ceil(count / PAGE_SIZE); 86 | 87 | function nextPage() { 88 | const next = currentPage === pageCount ? currentPage : currentPage + 1; 89 | 90 | searchParams.set("page", next); 91 | setSearchParams(searchParams); 92 | } 93 | 94 | function prevPage() { 95 | const prev = currentPage === 1 ? currentPage : currentPage - 1; 96 | 97 | searchParams.set("page", prev); 98 | setSearchParams(searchParams); 99 | } 100 | 101 | if (pageCount <= 1) return null; 102 | 103 | return ( 104 | 105 |

106 | Showing {(currentPage - 1) * PAGE_SIZE + 1} to{" "} 107 | 108 | {currentPage === pageCount ? count : currentPage * PAGE_SIZE} 109 | {" "} 110 | of {count} results 111 |

112 | 113 | 114 | 115 | Previous 116 | 117 | 118 | 122 | Next 123 | 124 | 125 | 126 |
127 | ); 128 | } 129 | 130 | export default Pagination; 131 | -------------------------------------------------------------------------------- /src/features/cabins/CabinRow.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | 3 | import CreateCabinForm from "./CreateCabinForm"; 4 | import { useDeleteCabin } from "./useDeleteCabin"; 5 | import { formatCurrency } from "../../utils/helpers"; 6 | import { HiPencil, HiSquare2Stack, HiTrash } from "react-icons/hi2"; 7 | import { useCreateCabin } from "./useCreateCabin"; 8 | import Modal from "../../ui/Modal"; 9 | import ConfirmDelete from "../../ui/ConfirmDelete"; 10 | import Table from "../../ui/Table"; 11 | import Menus from "../../ui/Menus"; 12 | 13 | const Img = styled.img` 14 | display: block; 15 | width: 6.4rem; 16 | aspect-ratio: 3 / 2; 17 | object-fit: cover; 18 | object-position: center; 19 | transform: scale(1.5) translateX(-7px); 20 | 21 | @media screen and (max-width: 999px) { 22 | /* display: none; */ 23 | visibility: hidden; 24 | } 25 | `; 26 | 27 | const Cabin = styled.div` 28 | font-size: 1.6rem; 29 | font-weight: 600; 30 | color: var(--color-grey-600); 31 | font-family: "Sono"; 32 | @media screen and (max-width: 768px) { 33 | font-size: 1rem; 34 | } 35 | `; 36 | 37 | const Price = styled.div` 38 | font-family: "Sono"; 39 | font-weight: 600; 40 | `; 41 | 42 | const Discount = styled.div` 43 | font-family: "Sono"; 44 | font-weight: 500; 45 | color: var(--color-green-700); 46 | `; 47 | 48 | function CabinRow({ cabin }) { 49 | const { isDeleting, deleteCabin } = useDeleteCabin(); 50 | const { isCreating, createCabin } = useCreateCabin(); 51 | 52 | const { 53 | id: cabinId, 54 | name, 55 | maxCapacity, 56 | regularPrice, 57 | discount, 58 | image, 59 | description, 60 | } = cabin; 61 | 62 | function handleDuplicate() { 63 | createCabin({ 64 | name: `Copy of ${name}`, 65 | maxCapacity, 66 | regularPrice, 67 | discount, 68 | image, 69 | description, 70 | }); 71 | } 72 | 73 | return ( 74 | 75 | 76 | {name} 77 |
Fits up to {maxCapacity} guests
78 | {formatCurrency(regularPrice)} 79 | {discount ? ( 80 | {formatCurrency(discount)} 81 | ) : ( 82 | 83 | )} 84 |
85 | 86 | 87 | 88 | 89 | 90 | } 92 | onClick={handleDuplicate} 93 | disabled={isCreating} 94 | > 95 | Duplicate 96 | 97 | 98 | 99 | }>Edit 100 | 101 | 102 | 103 | }>Delete 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | deleteCabin(cabinId)} 116 | /> 117 | 118 | 119 | 120 |
121 |
122 | ); 123 | } 124 | 125 | export default CabinRow; 126 | -------------------------------------------------------------------------------- /src/features/dashboard/SalesChart.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import DashboardBox from "./DashboardBox"; 3 | import Heading from "../../ui/Heading"; 4 | import { 5 | Area, 6 | AreaChart, 7 | CartesianGrid, 8 | ResponsiveContainer, 9 | Tooltip, 10 | XAxis, 11 | YAxis, 12 | } from "recharts"; 13 | import { useDarkMode } from "../../context/DarkModeContext"; 14 | import { eachDayOfInterval, format, isSameDay, subDays } from "date-fns"; 15 | 16 | const StyledSalesChart = styled(DashboardBox)` 17 | grid-column: 1 / -1; 18 | 19 | /* Hack to change grid line colors */ 20 | & .recharts-cartesian-grid-horizontal line, 21 | & .recharts-cartesian-grid-vertical line { 22 | stroke: var(--color-grey-300); 23 | } 24 | `; 25 | 26 | const ChartContainer = styled.div` 27 | width: 100%; 28 | height: 300px; /* Default height for larger screens */ 29 | 30 | @media only screen and (max-width: 1024px) { 31 | height: 250px; /* Adjust height for tablets */ 32 | } 33 | `; 34 | 35 | function SalesChart({ bookings, numDays }) { 36 | const { isDarkMode } = useDarkMode(); 37 | 38 | const allDates = eachDayOfInterval({ 39 | start: subDays(new Date(), numDays - 1), 40 | end: new Date(), 41 | }); 42 | 43 | const data = allDates.map((date) => { 44 | return { 45 | label: format(date, "MMM dd"), 46 | totalSales: bookings 47 | .filter((booking) => isSameDay(date, new Date(booking.created_at))) 48 | .reduce((acc, cur) => acc + cur.totalPrice, 0), 49 | extrasSales: bookings 50 | .filter((booking) => isSameDay(date, new Date(booking.created_at))) 51 | .reduce((acc, cur) => acc + cur.extrasPrice, 0), 52 | }; 53 | }); 54 | 55 | const colors = isDarkMode 56 | ? { 57 | totalSales: { stroke: "#4f46e5", fill: "#4f46e5" }, 58 | extrasSales: { stroke: "#22c55e", fill: "#22c55e" }, 59 | text: "#e5e7eb", 60 | background: "#18212f", 61 | } 62 | : { 63 | totalSales: { stroke: "#4f46e5", fill: "#c7d2fe" }, 64 | extrasSales: { stroke: "#16a34a", fill: "#dcfce7" }, 65 | text: "#374151", 66 | background: "#fff", 67 | }; 68 | 69 | return ( 70 | 71 | 72 | Sales from {format(allDates.at(0), "MMM dd yyyy")} —{" "} 73 | {format(allDates.at(-1), "MMM dd yyyy")}{" "} 74 | 75 | 76 | 77 | 78 | 83 | 88 | 89 | 90 | 99 | 108 | 109 | 110 | 111 | 112 | ); 113 | } 114 | 115 | export default SalesChart; 116 | -------------------------------------------------------------------------------- /src/ui/Menus.jsx: -------------------------------------------------------------------------------- 1 | import { createContext, useContext, useState } from "react"; 2 | import { createPortal } from "react-dom"; 3 | import { HiEllipsisVertical } from "react-icons/hi2"; 4 | import styled from "styled-components"; 5 | import { useOutsideClick } from "../hooks/useOutsideClick"; 6 | 7 | const Menu = styled.div` 8 | display: flex; 9 | align-items: center; 10 | justify-content: flex-end; 11 | `; 12 | 13 | const StyledToggle = styled.button` 14 | background: none; 15 | border: none; 16 | padding: 0.4rem; 17 | border-radius: var(--border-radius-sm); 18 | transform: translateX(0.8rem); 19 | transition: all 0.2s; 20 | 21 | &:hover { 22 | background-color: var(--color-grey-100); 23 | } 24 | 25 | & svg { 26 | width: 2.4rem; 27 | height: 2.4rem; 28 | color: var(--color-grey-700); 29 | @media screen and (max-width: 768px) { 30 | width: 1.7rem; 31 | height: 1.7rem; 32 | } 33 | } 34 | `; 35 | 36 | const StyledList = styled.ul` 37 | position: fixed; 38 | 39 | background-color: var(--color-grey-0); 40 | box-shadow: var(--shadow-md); 41 | border-radius: var(--border-radius-md); 42 | 43 | right: ${(props) => props.position.x}px; 44 | top: ${(props) => props.position.y}px; 45 | `; 46 | 47 | const StyledButton = styled.button` 48 | width: 100%; 49 | text-align: left; 50 | background: none; 51 | border: none; 52 | padding: 1.2rem 2.4rem; 53 | font-size: 1.4rem; 54 | transition: all 0.2s; 55 | 56 | display: flex; 57 | align-items: center; 58 | gap: 1.6rem; 59 | 60 | @media screen and (max-width: 768px) { 61 | gap: 1.2rem; 62 | font-size: 1rem; 63 | } 64 | 65 | &:hover { 66 | background-color: var(--color-grey-50); 67 | } 68 | 69 | & svg { 70 | width: 1.6rem; 71 | height: 1.6rem; 72 | color: var(--color-grey-400); 73 | transition: all 0.3s; 74 | } 75 | `; 76 | 77 | const MenusContext = createContext(); 78 | 79 | function Menus({ children }) { 80 | const [openId, setOpenId] = useState(""); 81 | const [position, setPosition] = useState(null); 82 | 83 | const close = () => setOpenId(""); 84 | const open = setOpenId; 85 | 86 | return ( 87 | 90 | {children} 91 | 92 | ); 93 | } 94 | 95 | function Toggle({ id }) { 96 | const { openId, close, open, setPosition } = useContext(MenusContext); 97 | 98 | function handleClick(e) { 99 | e.stopPropagation(); 100 | 101 | const rect = e.target.closest("button").getBoundingClientRect(); 102 | setPosition({ 103 | x: window.innerWidth - rect.width - rect.x, 104 | y: rect.y + rect.height + 8, 105 | }); 106 | 107 | openId === "" || openId !== id ? open(id) : close(); 108 | } 109 | 110 | return ( 111 | 112 | 113 | 114 | ); 115 | } 116 | 117 | function List({ id, children }) { 118 | const { openId, position, close } = useContext(MenusContext); 119 | const ref = useOutsideClick(close, false); 120 | 121 | if (openId !== id) return null; 122 | 123 | return createPortal( 124 | 125 | {children} 126 | , 127 | document.body 128 | ); 129 | } 130 | 131 | function Button({ children, icon, onClick }) { 132 | const { close } = useContext(MenusContext); 133 | 134 | function handleClick() { 135 | onClick?.(); 136 | close(); 137 | } 138 | 139 | return ( 140 |
  • 141 | 142 | {icon} 143 | {children} 144 | 145 |
  • 146 | ); 147 | } 148 | 149 | Menus.Menu = Menu; 150 | Menus.Toggle = Toggle; 151 | Menus.List = List; 152 | Menus.Button = Button; 153 | 154 | export default Menus; 155 | -------------------------------------------------------------------------------- /src/features/check-in-out/CheckinBooking.jsx: -------------------------------------------------------------------------------- 1 | import styled from "styled-components"; 2 | import BookingDataBox from "../../features/bookings/BookingDataBox"; 3 | 4 | import Row from "../../ui/Row"; 5 | import Heading from "../../ui/Heading"; 6 | import ButtonGroup from "../../ui/ButtonGroup"; 7 | import Button from "../../ui/Button"; 8 | import ButtonText from "../../ui/ButtonText"; 9 | import Spinner from "../../ui/Spinner"; 10 | 11 | import { useMoveBack } from "../../hooks/useMoveBack"; 12 | import { useBooking } from "../bookings/useBooking"; 13 | import { useEffect, useState } from "react"; 14 | import Checkbox from "../../ui/Checkbox"; 15 | import { formatCurrency } from "../../utils/helpers"; 16 | import { useCheckin } from "./useCheckin"; 17 | import { useSettings } from "../settings/useSettings"; 18 | 19 | const Box = styled.div` 20 | /* Box */ 21 | background-color: var(--color-grey-0); 22 | border: 1px solid var(--color-grey-100); 23 | border-radius: var(--border-radius-md); 24 | padding: 2.4rem 4rem; 25 | `; 26 | 27 | function CheckinBooking() { 28 | const [confirmPaid, setConfirmPaid] = useState(false); 29 | const [addBreakfast, setAddBreakfast] = useState(false); 30 | const { booking, isLoading } = useBooking(); 31 | const { settings, isLoading: isLoadingSettings } = useSettings(); 32 | 33 | useEffect(() => setConfirmPaid(booking?.isPaid ?? false), [booking]); 34 | 35 | const moveBack = useMoveBack(); 36 | const { checkin, isCheckingIn } = useCheckin(); 37 | 38 | if (isLoading || isLoadingSettings) return ; 39 | 40 | const { 41 | id: bookingId, 42 | guests, 43 | totalPrice, 44 | numGuests, 45 | hasBreakfast, 46 | numNights, 47 | } = booking; 48 | 49 | const optionalBreakfastPrice = 50 | settings.breakfastPrice * numNights * numGuests; 51 | 52 | function handleCheckin() { 53 | if (!confirmPaid) return; 54 | 55 | if (addBreakfast) { 56 | checkin({ 57 | bookingId, 58 | breakfast: { 59 | hasBreakfast: true, 60 | extrasPrice: optionalBreakfastPrice, 61 | totalPrice: totalPrice + optionalBreakfastPrice, 62 | }, 63 | }); 64 | } else { 65 | checkin({ bookingId, breakfast: {} }); 66 | } 67 | } 68 | 69 | return ( 70 | <> 71 | 72 | Check in booking #{bookingId} 73 | ← Back 74 | 75 | 76 | 77 | 78 | {!hasBreakfast && ( 79 | 80 | { 83 | setAddBreakfast((add) => !add); 84 | setConfirmPaid(false); 85 | }} 86 | id="breakfast" 87 | > 88 | Want to add breakfast for {formatCurrency(optionalBreakfastPrice)}? 89 | 90 | 91 | )} 92 | 93 | 94 | setConfirmPaid((confirm) => !confirm)} 97 | disabled={confirmPaid || isCheckingIn} 98 | id="confirm" 99 | > 100 | I confirm that {guests.fullName} has paid the total amount of{" "} 101 | {!addBreakfast 102 | ? formatCurrency(totalPrice) 103 | : `${formatCurrency( 104 | totalPrice + optionalBreakfastPrice 105 | )} (${formatCurrency(totalPrice)} + ${formatCurrency( 106 | optionalBreakfastPrice 107 | )})`} 108 | 109 | 110 | 111 | 112 | 115 | 118 | 119 | 120 | ); 121 | } 122 | 123 | export default CheckinBooking; 124 | -------------------------------------------------------------------------------- /src/services/apiBookings.js: -------------------------------------------------------------------------------- 1 | import { getToday } from "../utils/helpers"; 2 | import supabase from "./supabase"; 3 | import { PAGE_SIZE } from "../utils/constants"; 4 | 5 | export async function getBookings({ filter, sortBy, page }) { 6 | let query = supabase 7 | .from("bookings") 8 | .select( 9 | "id, created_at, startDate, endDate, numNights, numGuests, status, totalPrice, cabins(name), guests(fullName, email)", 10 | { count: "exact" } 11 | ); 12 | 13 | // FILTER 14 | if (filter) query = query[filter.method || "eq"](filter.field, filter.value); 15 | 16 | // SORT 17 | if (sortBy) 18 | query = query.order(sortBy.field, { 19 | ascending: sortBy.direction === "asc", 20 | }); 21 | 22 | if (page) { 23 | const from = (page - 1) * PAGE_SIZE; 24 | const to = from + PAGE_SIZE - 1; 25 | query = query.range(from, to); 26 | } 27 | 28 | const { data, error, count } = await query; 29 | 30 | if (error) { 31 | console.error(error); 32 | throw new Error("Bookings could not be loaded"); 33 | } 34 | 35 | return { data, count }; 36 | } 37 | 38 | export async function getBooking(id) { 39 | const { data, error } = await supabase 40 | .from("bookings") 41 | .select("*, cabins(*), guests(*)") 42 | .eq("id", id) 43 | .single(); 44 | 45 | if (error) { 46 | console.error(error); 47 | throw new Error("Booking not found"); 48 | } 49 | 50 | return data; 51 | } 52 | 53 | // Returns all BOOKINGS that are were created after the given date. Useful to get bookings created in the last 30 days, for example. 54 | // date: ISOString 55 | export async function getBookingsAfterDate(date) { 56 | const { data, error } = await supabase 57 | .from("bookings") 58 | .select("created_at, totalPrice, extrasPrice") 59 | .gte("created_at", date) 60 | .lte("created_at", getToday({ end: true })); 61 | 62 | if (error) { 63 | console.error(error); 64 | throw new Error("Bookings could not get loaded"); 65 | } 66 | 67 | return data; 68 | } 69 | 70 | // Returns all STAYS that are were created after the given date 71 | export async function getStaysAfterDate(date) { 72 | const { data, error } = await supabase 73 | .from("bookings") 74 | .select("*, guests(fullName)") 75 | .gte("startDate", date) 76 | .lte("startDate", getToday()); 77 | 78 | if (error) { 79 | console.error(error); 80 | throw new Error("Bookings could not get loaded"); 81 | } 82 | 83 | return data; 84 | } 85 | 86 | // Activity means that there is a check in or a check out today 87 | export async function getStaysTodayActivity() { 88 | const { data, error } = await supabase 89 | .from("bookings") 90 | .select("*, guests(fullName, nationality, countryFlag)") 91 | .or( 92 | `and(status.eq.unconfirmed,startDate.eq.${getToday()}),and(status.eq.checked-in,endDate.eq.${getToday()})` 93 | ) 94 | .order("created_at"); 95 | 96 | // Equivalent to this. But by querying this, we only download the data we actually need, otherwise we would need ALL bookings ever created 97 | // (stay.status === 'unconfirmed' && isToday(new Date(stay.startDate))) || 98 | // (stay.status === 'checked-in' && isToday(new Date(stay.endDate))) 99 | 100 | if (error) { 101 | console.error(error); 102 | throw new Error("Bookings could not get loaded"); 103 | } 104 | return data; 105 | } 106 | 107 | export async function updateBooking(id, obj) { 108 | const { data, error } = await supabase 109 | .from("bookings") 110 | .update(obj) 111 | .eq("id", id) 112 | .select() 113 | .single(); 114 | 115 | if (error) { 116 | console.error(error); 117 | throw new Error("Booking could not be updated"); 118 | } 119 | return data; 120 | } 121 | 122 | export async function deleteBooking(id) { 123 | // REMEMBER RLS POLICIES 124 | const { data, error } = await supabase.from("bookings").delete().eq("id", id); 125 | 126 | if (error) { 127 | console.error(error); 128 | throw new Error("Booking could not be deleted"); 129 | } 130 | return data; 131 | } 132 | -------------------------------------------------------------------------------- /src/features/cabins/CreateCabinForm-v1.jsx: -------------------------------------------------------------------------------- 1 | import { useMutation, useQueryClient } from "@tanstack/react-query"; 2 | import { toast } from "react-hot-toast"; 3 | 4 | import Input from "../../ui/Input"; 5 | import Form from "../../ui/Form"; 6 | import Button from "../../ui/Button"; 7 | import FileInput from "../../ui/FileInput"; 8 | import Textarea from "../../ui/Textarea"; 9 | import FormRow from "../../ui/FormRow"; 10 | 11 | import { useForm } from "react-hook-form"; 12 | import { createCabin } from "../../services/apiCabins"; 13 | 14 | function CreateCabinForm() { 15 | const { register, handleSubmit, reset, getValues, formState } = useForm(); 16 | const { errors } = formState; 17 | 18 | const queryClient = useQueryClient(); 19 | 20 | const { mutate, isLoading: isCreating } = useMutation({ 21 | mutationFn: createCabin, 22 | onSuccess: () => { 23 | toast.success("New cabin successfully created"); 24 | queryClient.invalidateQueries({ queryKey: ["cabins"] }); 25 | reset(); 26 | }, 27 | onError: (err) => toast.error(err.message), 28 | }); 29 | 30 | function onSubmit(data) { 31 | mutate({ ...data, image: data.image[0] }); 32 | } 33 | 34 | function onError(errors) { 35 | // console.log(errors); 36 | } 37 | 38 | return ( 39 |
    40 | 41 | 49 | 50 | 51 | 52 | 64 | 65 | 66 | 67 | 79 | 80 | 81 | 82 | 90 | value <= getValues().regularPrice || 91 | "Discount should be less than regular price", 92 | })} 93 | /> 94 | 95 | 96 | 101 |