├── .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 |
20 | );
21 | }
22 |
23 | export default SortBy;
24 |
--------------------------------------------------------------------------------
/src/features/authentication/useLogout.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { logout as logoutApi } from "../../services/apiAuth";
3 | import { useNavigate } from "react-router-dom";
4 |
5 | export function useLogout() {
6 | const navigate = useNavigate();
7 | const queryClient = useQueryClient();
8 |
9 | const { mutate: logout, isLoading } = useMutation({
10 | mutationFn: logoutApi,
11 | onSuccess: () => {
12 | queryClient.removeQueries();
13 | navigate("/login", { replace: true });
14 | },
15 | });
16 |
17 | return { logout, isLoading };
18 | }
19 |
--------------------------------------------------------------------------------
/src/pages/Cabins.jsx:
--------------------------------------------------------------------------------
1 | import CabinTable from "../features/cabins/CabinTable";
2 | import Heading from "../ui/Heading";
3 | import Row from "../ui/Row";
4 | import AddCabin from "../features/cabins/AddCabin";
5 | import CabinTableOperations from "../features/cabins/CabinTableOperations";
6 |
7 | function Cabins() {
8 | return (
9 | <>
10 |
11 | All cabins
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | >
20 | );
21 | }
22 |
23 | export default Cabins;
24 |
--------------------------------------------------------------------------------
/src/ui/Row.jsx:
--------------------------------------------------------------------------------
1 | import styled, { css } from "styled-components";
2 |
3 | const Row = styled.div`
4 | display: flex;
5 | flex-wrap: wrap;
6 |
7 | ${(props) =>
8 | props.type === "horizontal" &&
9 | css`
10 | justify-content: space-between;
11 | align-items: center;
12 | @media screen and (max-width: 768px) {
13 | flex-direction: row;
14 | }
15 | `}
16 |
17 | ${(props) =>
18 | props.type === "vertical" &&
19 | css`
20 | flex-direction: column;
21 | gap: 1.6rem;
22 | `}
23 | `;
24 |
25 | Row.defaultProps = {
26 | type: "vertical",
27 | };
28 |
29 | export default Row;
30 |
--------------------------------------------------------------------------------
/src/hooks/useOutsideClick.js:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | export function useOutsideClick(handler, listenCapturing = true) {
4 | const ref = useRef();
5 |
6 | useEffect(
7 | function () {
8 | function handleClick(e) {
9 | if (ref.current && !ref.current.contains(e.target)) {
10 | handler();
11 | }
12 | }
13 |
14 | document.addEventListener("click", handleClick, listenCapturing);
15 |
16 | return () =>
17 | document.removeEventListener("click", handleClick, listenCapturing);
18 | },
19 | [handler, listenCapturing]
20 | );
21 |
22 | return ref;
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/ButtonIcon.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const ButtonIcon = styled.button`
4 | background: none;
5 | border: none;
6 | padding: 0.6rem;
7 | border-radius: var(--border-radius-sm);
8 | transition: all 0.2s;
9 |
10 | &:hover {
11 | background-color: var(--color-grey-100);
12 | }
13 |
14 | & svg {
15 | width: 2.2rem;
16 | height: 2.2rem;
17 | color: var(--color-brand-600);
18 | @media screen and (max-width: 768px) {
19 | width: 1.9rem;
20 | height: 1.9rem;
21 | }
22 | }
23 |
24 | @media screen and (max-width: 768px) {
25 | padding: 0.6rem 0.3rem;
26 | }
27 | `;
28 |
29 | export default ButtonIcon;
30 |
--------------------------------------------------------------------------------
/src/pages/Account.jsx:
--------------------------------------------------------------------------------
1 | import UpdatePasswordForm from "../features/authentication/UpdatePasswordForm";
2 | import UpdateUserDataForm from "../features/authentication/UpdateUserDataForm";
3 | import Heading from "../ui/Heading";
4 | import Row from "../ui/Row";
5 |
6 | function Account() {
7 | return (
8 | <>
9 | Update your account
10 |
11 |
12 | Update user data
13 |
14 |
15 |
16 |
17 | Update password
18 |
19 |
20 | >
21 | );
22 | }
23 |
24 | export default Account;
25 |
--------------------------------------------------------------------------------
/src/features/cabins/useCreateCabin.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { toast } from "react-hot-toast";
3 | import { createEditCabin } from "../../services/apiCabins";
4 |
5 | export function useCreateCabin() {
6 | const queryClient = useQueryClient();
7 |
8 | const { mutate: createCabin, isLoading: isCreating } = useMutation({
9 | mutationFn: createEditCabin,
10 | onSuccess: () => {
11 | toast.success("New cabin successfully created");
12 | queryClient.invalidateQueries({ queryKey: ["cabins"] });
13 | },
14 | onError: (err) => toast.error(err.message),
15 | });
16 |
17 | return { isCreating, createCabin };
18 | }
19 |
--------------------------------------------------------------------------------
/src/features/authentication/useUpdateUser.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { toast } from "react-hot-toast";
3 | import { updateCurrentUser } from "../../services/apiAuth";
4 |
5 | export function useUpdateUser() {
6 | const queryClient = useQueryClient();
7 |
8 | const { mutate: updateUser, isLoading: isUpdating } = useMutation({
9 | mutationFn: updateCurrentUser,
10 | onSuccess: ({ user }) => {
11 | toast.success("User account successfully updated");
12 | queryClient.setQueryData(["user"], user);
13 | },
14 | onError: (err) => toast.error(err.message),
15 | });
16 |
17 | return { updateUser, isUpdating };
18 | }
19 |
--------------------------------------------------------------------------------
/src/features/cabins/useEditCabin.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { createEditCabin } from "../../services/apiCabins";
3 | import { toast } from "react-hot-toast";
4 |
5 | export function useEditCabin() {
6 | const queryClient = useQueryClient();
7 |
8 | const { mutate: editCabin, isLoading: isEditing } = useMutation({
9 | mutationFn: ({ newCabinData, id }) => createEditCabin(newCabinData, id),
10 | onSuccess: () => {
11 | toast.success("Cabin successfully edited");
12 | queryClient.invalidateQueries({ queryKey: ["cabins"] });
13 | },
14 | onError: (err) => toast.error(err.message),
15 | });
16 |
17 | return { isEditing, editCabin };
18 | }
19 |
--------------------------------------------------------------------------------
/src/features/settings/useUpdateSetting.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { toast } from "react-hot-toast";
3 | import { updateSetting as updateSettingApi } from "../../services/apiSettings";
4 |
5 | export function useUpdateSetting() {
6 | const queryClient = useQueryClient();
7 |
8 | const { mutate: updateSetting, isLoading: isUpdating } = useMutation({
9 | mutationFn: updateSettingApi,
10 | onSuccess: () => {
11 | toast.success("Setting successfully edited");
12 | queryClient.invalidateQueries({ queryKey: ["settings"] });
13 | },
14 | onError: (err) => toast.error(err.message),
15 | });
16 |
17 | return { isUpdating, updateSetting };
18 | }
19 |
--------------------------------------------------------------------------------
/src/features/cabins/useDeleteCabin.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { toast } from "react-hot-toast";
3 | import { deleteCabin as deleteCabinApi } from "../../services/apiCabins";
4 |
5 | export function useDeleteCabin() {
6 | const queryClient = useQueryClient();
7 |
8 | const { isLoading: isDeleting, mutate: deleteCabin } = useMutation({
9 | mutationFn: deleteCabinApi,
10 | onSuccess: () => {
11 | toast.success("Cabin successfully deleted");
12 |
13 | queryClient.invalidateQueries({
14 | queryKey: ["cabins"],
15 | });
16 | },
17 | onError: (err) => toast.error(err.message),
18 | });
19 |
20 | return { isDeleting, deleteCabin };
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/Spinner.jsx:
--------------------------------------------------------------------------------
1 | import styled, { keyframes } from "styled-components";
2 |
3 | const rotate = keyframes`
4 | to {
5 | transform: rotate(1turn)
6 | }
7 | `;
8 |
9 | const Spinner = styled.div`
10 | margin: 4.8rem auto;
11 |
12 | width: 6.4rem;
13 | aspect-ratio: 1;
14 | border-radius: 50%;
15 | background: radial-gradient(farthest-side, var(--color-brand-600) 94%, #0000)
16 | top/10px 10px no-repeat,
17 | conic-gradient(#0000 30%, var(--color-brand-600));
18 | -webkit-mask: radial-gradient(farthest-side, #0000 calc(100% - 10px), #000 0);
19 | animation: ${rotate} 1.5s infinite linear;
20 |
21 | @media screen and (max-width: 768px) {
22 | width: 4.4rem;
23 | }
24 | `;
25 |
26 | export default Spinner;
27 |
--------------------------------------------------------------------------------
/src/features/dashboard/useRecentBookings.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { subDays } from "date-fns";
3 | import { useSearchParams } from "react-router-dom";
4 | import { getBookingsAfterDate } from "../../services/apiBookings";
5 |
6 | export function useRecentBookings() {
7 | const [searchParams] = useSearchParams();
8 |
9 | const numDays = !searchParams.get("last")
10 | ? 7
11 | : Number(searchParams.get("last"));
12 | const queryDate = subDays(new Date(), numDays).toISOString();
13 |
14 | const { isLoading, data: bookings } = useQuery({
15 | queryFn: () => getBookingsAfterDate(queryDate),
16 | queryKey: ["bookings", `last-${numDays}`],
17 | });
18 |
19 | return { isLoading, bookings };
20 | }
21 |
--------------------------------------------------------------------------------
/src/features/bookings/useDeleteBooking.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { toast } from "react-hot-toast";
3 | import { deleteBooking as deleteBookingApi } from "../../services/apiBookings";
4 |
5 | export function useDeleteBooking() {
6 | const queryClient = useQueryClient();
7 |
8 | const { isLoading: isDeleting, mutate: deleteBooking } = useMutation({
9 | mutationFn: deleteBookingApi,
10 | onSuccess: () => {
11 | toast.success("Booking successfully deleted");
12 |
13 | queryClient.invalidateQueries({
14 | queryKey: ["bookings"],
15 | });
16 | },
17 | onError: (err) => toast.error(err.message),
18 | });
19 |
20 | return { isDeleting, deleteBooking };
21 | }
22 |
--------------------------------------------------------------------------------
/src/ui/Header.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import HeaderMenu from "./HeaderMenu";
3 | import UserAvatar from "../features/authentication/UserAvatar";
4 |
5 | const StyledHeader = styled.header`
6 | background-color: var(--color-grey-0);
7 | padding: 1.2rem 4.8rem;
8 | border-bottom: 1px solid var(--color-grey-100);
9 |
10 | display: flex;
11 | gap: 2.4rem;
12 | align-items: center;
13 | justify-content: flex-end;
14 | @media screen and (max-width: 890px) {
15 | padding: 0.4rem 0 1rem 1rem;
16 | gap: 0.3rem;
17 | }
18 | `;
19 |
20 | function Header() {
21 | return (
22 |
23 |
24 |
25 |
26 | );
27 | }
28 |
29 | export default Header;
30 |
--------------------------------------------------------------------------------
/src/features/cabins/useCabinDelete.js:
--------------------------------------------------------------------------------
1 | import { useQueryClient } from "@tanstack/react-query";
2 | import { useMutation } from "@tanstack/react-query";
3 | import { toast } from "react-hot-toast";
4 | import { deleteCabin as deleteCabinApi } from "../../services/apiCabins";
5 |
6 | export const useCabinDelete = () => {
7 | const queryClient = useQueryClient();
8 |
9 | const { isLoading: isDeleting, mutate: deleteCabin } = useMutation({
10 | mutationFn: deleteCabinApi,
11 | onSuccess: () => {
12 | toast.success("Cabin Succesfully deleted");
13 | queryClient.invalidateQueries({
14 | queryKey: ["cabins"],
15 | });
16 | },
17 | onError: (err) => toast.error(err.message),
18 | });
19 | return { isDeleting, deleteCabin };
20 | };
21 |
--------------------------------------------------------------------------------
/src/ui/Logo.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { useDarkMode } from "../context/DarkModeContext";
3 |
4 | const StyledLogo = styled.div`
5 | text-align: center;
6 | @media screen and (max-width: 768px) {
7 | /* text-align: start; */
8 | }
9 | `;
10 |
11 | const Img = styled.img`
12 | max-width: 100%;
13 | height: auto;
14 |
15 | height: 9.6rem;
16 |
17 | @media screen and (max-width: 768px) {
18 | height: 5rem;
19 | }
20 | `;
21 |
22 | function Logo() {
23 | const { isDarkMode } = useDarkMode();
24 |
25 | const src = isDarkMode ? "/logo-dark.png" : "/logo-light.png";
26 |
27 | return (
28 |
29 |
30 |
31 | );
32 | }
33 |
34 | export default Logo;
35 |
--------------------------------------------------------------------------------
/src/pages/Login.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import LoginForm from "../features/authentication/LoginForm";
3 | import Logo from "../ui/Logo";
4 | import Heading from "../ui/Heading";
5 |
6 | const LoginLayout = styled.main`
7 | min-height: 100vh;
8 | display: grid;
9 | grid-template-columns: 48rem;
10 | align-content: center;
11 | justify-content: center;
12 | gap: 3.2rem;
13 | background-color: var(--color-grey-50);
14 |
15 | @media screen and (max-width: 768px) {
16 | overflow: hidden;
17 | }
18 | `;
19 |
20 | function Login() {
21 | return (
22 |
23 |
24 | Log in to your account
25 |
26 |
27 | );
28 | }
29 |
30 | export default Login;
31 |
--------------------------------------------------------------------------------
/src/features/check-in-out/useCheckout.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { updateBooking } from "../../services/apiBookings";
3 | import { toast } from "react-hot-toast";
4 |
5 | export function useCheckout() {
6 | const queryClient = useQueryClient();
7 |
8 | const { mutate: checkout, isLoading: isCheckingOut } = useMutation({
9 | mutationFn: (bookingId) =>
10 | updateBooking(bookingId, {
11 | status: "checked-out",
12 | }),
13 |
14 | onSuccess: (data) => {
15 | toast.success(`Booking #${data.id} successfully checked out`);
16 | queryClient.invalidateQueries({ active: true });
17 | },
18 |
19 | onError: () => toast.error("There was an error while checking out"),
20 | });
21 |
22 | return { checkout, isCheckingOut };
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/DataItem.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const StyledDataItem = styled.div`
4 | display: flex;
5 | align-items: center;
6 | gap: 1.6rem;
7 | padding: 0.8rem 0;
8 | flex-wrap: wrap;
9 | @media screen and (max-width: 768px) {
10 | gap: 1.2rem;
11 | }
12 | `;
13 |
14 | const Label = styled.span`
15 | display: flex;
16 | align-items: center;
17 | gap: 0.8rem;
18 | font-weight: 500;
19 |
20 | & svg {
21 | width: 2rem;
22 | height: 2rem;
23 | color: var(--color-brand-600);
24 | }
25 | `;
26 |
27 | function DataItem({ icon, label, children }) {
28 | return (
29 |
30 |
34 | {children}
35 |
36 | );
37 | }
38 |
39 | export default DataItem;
40 |
--------------------------------------------------------------------------------
/src/services/apiSettings.js:
--------------------------------------------------------------------------------
1 | import supabase from "./supabase";
2 |
3 | export async function getSettings() {
4 | const { data, error } = await supabase.from("settings").select("*").single();
5 |
6 | if (error) {
7 | console.error(error);
8 | throw new Error("Settings could not be loaded");
9 | }
10 | return data;
11 | }
12 |
13 | // We expect a newSetting object that looks like {setting: newValue}
14 | export async function updateSetting(newSetting) {
15 | const { data, error } = await supabase
16 | .from("settings")
17 | .update(newSetting)
18 | // There is only ONE row of settings, and it has the ID=1, and so this is the updated one
19 | .eq("id", 1)
20 | .single();
21 |
22 | if (error) {
23 | console.error(error);
24 | throw new Error("Settings could not be updated");
25 | }
26 | return data;
27 | }
28 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
14 |
18 |
19 | The Wild Oasis
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/src/features/authentication/useLogin.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from '@tanstack/react-query';
2 | import { login as loginApi } from '../../services/apiAuth';
3 | import { useNavigate } from 'react-router-dom';
4 | import { toast } from 'react-hot-toast';
5 |
6 | export function useLogin() {
7 | const queryClient = useQueryClient();
8 | const navigate = useNavigate();
9 |
10 | const { mutate: login, isLoading } = useMutation({
11 | mutationFn: ({ email, password }) => loginApi({ email, password }),
12 | onSuccess: (user) => {
13 | queryClient.setQueryData(['user'], user.user);
14 | navigate('/dashboard', { replace: true });
15 | },
16 | onError: (err) => {
17 | console.log('ERROR', err);
18 | toast.error('Provided email or password are incorrect');
19 | },
20 | });
21 |
22 | return { login, isLoading };
23 | }
24 |
--------------------------------------------------------------------------------
/src/ui/FileInput.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const FileInput = styled.input.attrs({ type: "file" })`
4 | font-size: 1.4rem;
5 | border-radius: var(--border-radius-sm);
6 | @media screen and (max-width: 768px) {
7 | font-size: 1rem;
8 | }
9 |
10 | &::file-selector-button {
11 | font: inherit;
12 | font-weight: 500;
13 | padding: 0.8rem 1.2rem;
14 | margin-right: 1.2rem;
15 | border-radius: var(--border-radius-sm);
16 | border: none;
17 | color: var(--color-brand-50);
18 | background-color: var(--color-brand-600);
19 | cursor: pointer;
20 | transition: color 0.2s, background-color 0.2s;
21 | @media screen and (max-width: 768px) {
22 | padding: 0.6rem 0.9rem;
23 | }
24 |
25 | &:hover {
26 | background-color: var(--color-brand-700);
27 | }
28 | }
29 | `;
30 |
31 | export default FileInput;
32 |
--------------------------------------------------------------------------------
/src/features/dashboard/useRecentStays.js:
--------------------------------------------------------------------------------
1 | import { useQuery } from "@tanstack/react-query";
2 | import { subDays } from "date-fns";
3 | import { useSearchParams } from "react-router-dom";
4 | import { getStaysAfterDate } from "../../services/apiBookings";
5 |
6 | export function useRecentStays() {
7 | const [searchParams] = useSearchParams();
8 |
9 | const numDays = !searchParams.get("last")
10 | ? 7
11 | : Number(searchParams.get("last"));
12 | const queryDate = subDays(new Date(), numDays).toISOString();
13 |
14 | const { isLoading, data: stays } = useQuery({
15 | queryFn: () => getStaysAfterDate(queryDate),
16 | queryKey: ["stays", `last-${numDays}`],
17 | });
18 |
19 | const confirmedStays = stays?.filter(
20 | (stay) => stay.status === "checked-in" || stay.status === "checked-out"
21 | );
22 |
23 | return { isLoading, stays, confirmedStays, numDays };
24 | }
25 |
--------------------------------------------------------------------------------
/src/ui/Select.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const StyledSelect = styled.select`
4 | font-size: 1.4rem;
5 | padding: 0.8rem 1.2rem;
6 | border: 1px solid
7 | ${(props) =>
8 | props.type === "white"
9 | ? "var(--color-grey-100)"
10 | : "var(--color-grey-300)"};
11 | border-radius: var(--border-radius-sm);
12 | background-color: var(--color-grey-0);
13 | font-weight: 500;
14 | box-shadow: var(--shadow-sm);
15 | @media screen and (max-width: 768px) {
16 | font-size: 1rem;
17 | }
18 | `;
19 |
20 | function Select({ options, value, onChange, ...props }) {
21 | return (
22 |
23 | {options.map((option) => (
24 |
27 | ))}
28 |
29 | );
30 | }
31 |
32 | export default Select;
33 |
--------------------------------------------------------------------------------
/src/ui/HeaderMenu.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import Logout from "../features/authentication/Logout";
3 | import ButtonIcon from "./ButtonIcon";
4 | import { HiOutlineUser } from "react-icons/hi2";
5 | import { useNavigate } from "react-router-dom";
6 | import DarkModeToggle from "./DarkModeToggle";
7 |
8 | const StyledHeaderMenu = styled.ul`
9 | display: flex;
10 | gap: 0.4rem;
11 | @media screen and (max-width: 850px) {
12 | gap: 0rem;
13 | }
14 | `;
15 |
16 | function HeaderMenu() {
17 | const navigate = useNavigate();
18 |
19 | return (
20 |
21 |
22 | navigate("/account")}>
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 | );
34 | }
35 |
36 | export default HeaderMenu;
37 |
--------------------------------------------------------------------------------
/src/ui/ProtecctedRoute.jsx:
--------------------------------------------------------------------------------
1 | import { useNavigate } from "react-router-dom";
2 | import { useUser } from "../features/authentication/useUser";
3 | import { styled } from "styled-components";
4 | import { useEffect } from "react";
5 | import Spinner from "./Spinner";
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 | const ProtecctedRoute = ({ children }) => {
16 | const navigate = useNavigate();
17 | const { isLoading, isAuthenticated } = useUser();
18 |
19 | useEffect(
20 | function () {
21 | if (!isAuthenticated && !isLoading) navigate("/login");
22 | },
23 | [isAuthenticated, isLoading, navigate]
24 | );
25 | if (isLoading)
26 | return (
27 |
28 | ;
29 |
30 | );
31 |
32 | if (isAuthenticated) return children;
33 | };
34 |
35 | export default ProtecctedRoute;
36 |
--------------------------------------------------------------------------------
/src/features/check-in-out/useCheckin.js:
--------------------------------------------------------------------------------
1 | import { useMutation, useQueryClient } from "@tanstack/react-query";
2 | import { updateBooking } from "../../services/apiBookings";
3 | import { toast } from "react-hot-toast";
4 | import { useNavigate } from "react-router-dom";
5 |
6 | export function useCheckin() {
7 | const queryClient = useQueryClient();
8 | const navigate = useNavigate();
9 |
10 | const { mutate: checkin, isLoading: isCheckingIn } = useMutation({
11 | mutationFn: ({ bookingId, breakfast }) =>
12 | updateBooking(bookingId, {
13 | status: "checked-in",
14 | isPaid: true,
15 | ...breakfast,
16 | }),
17 |
18 | onSuccess: (data) => {
19 | toast.success(`Booking #${data.id} successfully checked in`);
20 | queryClient.invalidateQueries({ active: true });
21 | navigate("/");
22 | },
23 |
24 | onError: () => toast.error("There was an error while checking in"),
25 | });
26 |
27 | return { checkin, isCheckingIn };
28 | }
29 |
--------------------------------------------------------------------------------
/src/ui/FormRowVertical.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const StyledFormRow = styled.div`
4 | display: flex;
5 | flex-direction: column;
6 | gap: 0.8rem;
7 | padding: 1.2rem 0;
8 | @media screen and (max-width: 768px) {
9 | gap: 0.3rem;
10 | padding: 0.7rem;
11 | }
12 | `;
13 |
14 | const Label = styled.label`
15 | font-weight: 500;
16 | @media screen and (max-width: 768px) {
17 | font-weight: 300;
18 | font-size: 1rem;
19 | }
20 | `;
21 |
22 | const Error = styled.span`
23 | font-size: 1.4rem;
24 | color: var(--color-red-700);
25 |
26 | @media screen and (max-width: 768px) {
27 | font-size: 1.2rem;
28 | }
29 | `;
30 |
31 | function FormRowVertical({ label, error, children }) {
32 | return (
33 |
34 | {label && }
35 | {children}
36 | {error && {error}}
37 |
38 | );
39 | }
40 |
41 | export default FormRowVertical;
42 |
--------------------------------------------------------------------------------
/src/ui/Checkbox.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 |
3 | const StyledCheckbox = styled.div`
4 | display: flex;
5 | gap: 1.6rem;
6 |
7 | & input[type="checkbox"] {
8 | height: 2.4rem;
9 | width: 2.4rem;
10 | outline-offset: 2px;
11 | transform-origin: 0;
12 | accent-color: var(--color-brand-600);
13 | }
14 |
15 | & input[type="checkbox"]:disabled {
16 | accent-color: var(--color-brand-600);
17 | }
18 |
19 | & label {
20 | flex: 1;
21 |
22 | display: flex;
23 | align-items: center;
24 | gap: 0.8rem;
25 | }
26 | `;
27 |
28 | function Checkbox({ checked, onChange, disabled = false, id, children }) {
29 | return (
30 |
31 |
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 |
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 |
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 |
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 |
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 |
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 | }
69 | onClick={() => checkout(bookingId)}
70 | disabled={isCheckingOut}
71 | >
72 | Check out
73 |
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 |
130 | );
131 | }
132 |
133 | export default CreateCabinForm;
134 |
--------------------------------------------------------------------------------
/src/features/guests/CreateGuestForm.jsx:
--------------------------------------------------------------------------------
1 | import { useForm } from 'react-hook-form';
2 |
3 | import { useCountries } from 'hooks/useCountries';
4 | import { useCreateGuest } from 'features/guests/useCreateGuest';
5 | import Spinner from 'ui/Spinner';
6 | import Form from 'ui/Form';
7 | import FormRow from 'ui/FormRow';
8 | import Input from 'ui/Input';
9 | import Select from 'ui/Select';
10 | import Button from 'ui/Button';
11 | import styled from 'styled-components';
12 |
13 | const FormSelect = styled(Select)`
14 | width: 100%;
15 | `;
16 |
17 | // With NEW modal
18 | // function CreateGuest({ onSuccessNewGuest, setIsOpenForm }) {
19 | function CreateGuestForm({ onSuccessNewGuest, closeModal }) {
20 | const { isLoading: isLoadingCountries, countries } = useCountries();
21 | const { isLoading: isCreating, mutate: createGuest } = useCreateGuest();
22 |
23 | const { register, handleSubmit, formState } = useForm();
24 | const { errors } = formState;
25 |
26 | if (isLoadingCountries) return ;
27 |
28 | const countryOptions = countries.map((country) => {
29 | return {
30 | value: country.name,
31 | label: country.name,
32 | };
33 | });
34 | console.log(countryOptions);
35 |
36 | const onSubmit = function (data) {
37 | const countryFlag = countries.find(
38 | (country) => country.name === data.nationality
39 | ).flag;
40 |
41 | createGuest(
42 | { ...data, countryFlag },
43 | {
44 | // In the mutate function, we can ALSO use the onSuccess handler, just like in useMutation. Both will get called. This one also gets access to the returned value of the mutation (new guest in this case)
45 | // This is how we can get access to the newly created object. Here we set it into state, because we want to display it in the UI
46 | onSuccess: (data) => {
47 | // We might want to reuse this form in another place, and then onSuccessNewGuest will not exist
48 | onSuccessNewGuest?.(data);
49 |
50 | // If this component is used OUTSIDE the Modal Context, this will return undefined, so we need to test for this. Instead of if
51 | closeModal?.();
52 | },
53 | }
54 | );
55 | };
56 |
57 | return (
58 |
117 | );
118 | }
119 |
120 | export default CreateGuestForm;
121 |
--------------------------------------------------------------------------------
/src/styles/GlobalStyles.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components";
2 |
3 | const GlobalStyles = createGlobalStyle`
4 | :root {
5 | &, &.light-mode {
6 | /* Grey */
7 | --color-grey-0: #fff;
8 | --color-grey-50: #f9fafb;
9 | --color-grey-100: #f3f4f6;
10 | --color-grey-200: #e5e7eb;
11 | --color-grey-300: #d1d5db;
12 | --color-grey-400: #9ca3af;
13 | --color-grey-500: #6b7280;
14 | --color-grey-600: #4b5563;
15 | --color-grey-700: #374151;
16 | --color-grey-800: #1f2937;
17 | --color-grey-900: #111827;
18 |
19 | --color-blue-100: #e0f2fe;
20 | --color-blue-700: #0369a1;
21 | --color-green-100: #dcfce7;
22 | --color-green-700: #15803d;
23 | --color-yellow-100: #fef9c3;
24 | --color-yellow-700: #a16207;
25 | --color-silver-100: #e5e7eb;
26 | --color-silver-700: #374151;
27 | --color-indigo-100: #e0e7ff;
28 | --color-indigo-700: #4338ca;
29 |
30 | --color-red-100: #fee2e2;
31 | --color-red-700: #b91c1c;
32 | --color-red-800: #991b1b;
33 |
34 | --backdrop-color: rgba(255, 255, 255, 0.1);
35 |
36 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
37 | --shadow-md: 0px 0.6rem 2.4rem rgba(0, 0, 0, 0.06);
38 | --shadow-lg: 0 2.4rem 3.2rem rgba(0, 0, 0, 0.12);
39 |
40 |
41 | --image-grayscale: 0;
42 | --image-opacity: 100%;
43 | }
44 |
45 | &.dark-mode {
46 | --color-grey-0: #18212f;
47 | --color-grey-50: #111827;
48 | --color-grey-100: #1f2937;
49 | --color-grey-200: #374151;
50 | --color-grey-300: #4b5563;
51 | --color-grey-400: #6b7280;
52 | --color-grey-500: #9ca3af;
53 | --color-grey-600: #d1d5db;
54 | --color-grey-700: #e5e7eb;
55 | --color-grey-800: #f3f4f6;
56 | --color-grey-900: #f9fafb;
57 |
58 | --color-blue-100: #075985;
59 | --color-blue-700: #e0f2fe;
60 | --color-green-100: #166534;
61 | --color-green-700: #dcfce7;
62 | --color-yellow-100: #854d0e;
63 | --color-yellow-700: #fef9c3;
64 | --color-silver-100: #374151;
65 | --color-silver-700: #f3f4f6;
66 | --color-indigo-100: #3730a3;
67 | --color-indigo-700: #e0e7ff;
68 |
69 | --color-red-100: #fee2e2;
70 | --color-red-700: #b91c1c;
71 | --color-red-800: #991b1b;
72 |
73 | --backdrop-color: rgba(0, 0, 0, 0.3);
74 |
75 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
76 | --shadow-md: 0px 0.6rem 2.4rem rgba(0, 0, 0, 0.3);
77 | --shadow-lg: 0 2.4rem 3.2rem rgba(0, 0, 0, 0.4);
78 |
79 | --image-grayscale: 10%;
80 | --image-opacity: 90%;
81 | }
82 |
83 | /* Indigo */
84 | --color-brand-50: #eef2ff;
85 | --color-brand-100: #e0e7ff;
86 | --color-brand-200: #c7d2fe;
87 | --color-brand-500: #6366f1;
88 | --color-brand-600: #4f46e5;
89 | --color-brand-700: #4338ca;
90 | --color-brand-800: #3730a3;
91 | --color-brand-900: #312e81;
92 |
93 | --border-radius-tiny: 3px;
94 | --border-radius-sm: 5px;
95 | --border-radius-md: 7px;
96 | --border-radius-lg: 9px;
97 |
98 |
99 | }
100 |
101 | *,
102 | *::before,
103 | *::after {
104 | box-sizing: border-box;
105 | padding: 0;
106 | margin: 0;
107 |
108 | /* Creating animations for dark mode */
109 | transition: background-color 0.3s, border 0.3s;
110 | }
111 |
112 | html {
113 | font-size: 62.5%;
114 | }
115 |
116 | body {
117 | font-family: "Poppins", sans-serif;
118 | color: var(--color-grey-700);
119 |
120 | transition: color 0.3s, background-color 0.3s;
121 | min-height: 100vh;
122 | line-height: 1.5;
123 | font-size: 1.6rem;
124 | }
125 |
126 | input,
127 | button,
128 | textarea,
129 | select {
130 | font: inherit;
131 | color: inherit;
132 | }
133 |
134 | button {
135 | cursor: pointer;
136 | }
137 |
138 | *:disabled {
139 | cursor: not-allowed;
140 | }
141 |
142 | select:disabled,
143 | input:disabled {
144 | background-color: var(--color-grey-200);
145 | color: var(--color-grey-500);
146 | }
147 |
148 | input:focus,
149 | button:focus,
150 | textarea:focus,
151 | select:focus {
152 | outline: 2px solid var(--color-brand-600);
153 | outline-offset: -1px;
154 | }
155 |
156 | /* Parent selector, finally 😃 */
157 | button:has(svg) {
158 | line-height: 0;
159 | }
160 |
161 | a {
162 | color: inherit;
163 | text-decoration: none;
164 | }
165 |
166 | ul {
167 | list-style: none;
168 | }
169 |
170 | p,
171 | h1,
172 | h2,
173 | h3,
174 | h4,
175 | h5,
176 | h6 {
177 | overflow-wrap: break-word;
178 | hyphens: auto;
179 | }
180 |
181 | img {
182 | max-width: 100%;
183 |
184 | /* For dark mode */
185 | filter: grayscale(var(--image-grayscale)) opacity(var(--image-opacity));
186 | }
187 |
188 | `;
189 |
190 | export default GlobalStyles;
191 |
--------------------------------------------------------------------------------
/src/features/bookings/BookingRow.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { format, isToday } from "date-fns";
3 | import {
4 | HiArrowDownOnSquare,
5 | HiArrowUpOnSquare,
6 | HiEye,
7 | HiTrash,
8 | } from "react-icons/hi2";
9 | import { useNavigate } from "react-router-dom";
10 |
11 | import Tag from "../../ui/Tag";
12 | import Table from "../../ui/Table";
13 | import Modal from "../../ui/Modal";
14 | import Menus from "../../ui/Menus";
15 | import ConfirmDelete from "../../ui/ConfirmDelete";
16 |
17 | import { formatCurrency } from "../../utils/helpers";
18 | import { formatDistanceFromNow } from "../../utils/helpers";
19 | import { useCheckout } from "../check-in-out/useCheckout";
20 | import { useDeleteBooking } from "./useDeleteBooking";
21 |
22 | const Cabin = styled.div`
23 | font-size: 1.6rem;
24 | font-weight: 600;
25 | color: var(--color-grey-600);
26 | font-family: "Sono";
27 | @media screen and (max-width: 768px) {
28 | font-size: 0.9rem;
29 | }
30 | `;
31 |
32 | const Stacked = styled.div`
33 | display: flex;
34 | flex-direction: column;
35 | gap: 0.2rem;
36 | @media screen and (max-width: 768px) {
37 | font-size: 0.7rem;
38 | width: 3.3rem;
39 | margin: 0.2rem 0.4rem;
40 | }
41 |
42 | & span:first-child {
43 | font-weight: 500;
44 | }
45 |
46 | & span:last-child {
47 | color: var(--color-grey-500);
48 | font-size: 1.2rem;
49 | @media screen and (max-width: 768px) {
50 | font-size: 0.9rem;
51 | }
52 | }
53 | `;
54 |
55 | const Amount = styled.div`
56 | font-family: "Sono";
57 | font-weight: 500;
58 | `;
59 |
60 | const StyleDate = styled.span`
61 | @media screen and (max-width: 1124px) {
62 | display: none;
63 | }
64 | `;
65 |
66 | function BookingRow({
67 | booking: {
68 | id: bookingId,
69 | created_at,
70 | startDate,
71 | endDate,
72 | numNights,
73 | numGuests,
74 | totalPrice,
75 | status,
76 | guests: { fullName: guestName, email },
77 | cabins: { name: cabinName },
78 | },
79 | }) {
80 | const navigate = useNavigate();
81 | const { checkout, isCheckingOut } = useCheckout();
82 | const { deleteBooking, isDeleting } = useDeleteBooking();
83 |
84 | const statusToTagName = {
85 | unconfirmed: "blue",
86 | "checked-in": "green",
87 | "checked-out": "silver",
88 | };
89 |
90 | return (
91 |
92 |
93 | {cabinName}
94 |
95 |
96 |
97 | {guestName}
98 | {email}
99 |
100 |
101 |
102 |
103 | {isToday(new Date(startDate))
104 | ? "Today"
105 | : formatDistanceFromNow(startDate)}{" "}
106 | → {numNights} night stay
107 |
108 | {format(new Date(startDate), "MMM dd yyyy")} —{" "}
109 | {format(new Date(endDate), "MMM dd yyyy")}
110 |
111 |
112 |
113 | {(status || "unconfirmed").replace("-", " ")}
114 |
115 |
116 | {formatCurrency(totalPrice)}
117 |
118 |
119 |
120 |
121 |
122 | }
124 | onClick={() => navigate(`/bookings/${bookingId}`)}
125 | >
126 | See details
127 |
128 |
129 | {status === "unconfirmed" && (
130 | }
132 | onClick={() => navigate(`/checkin/${bookingId}`)}
133 | >
134 | Check in
135 |
136 | )}
137 |
138 | {status === "checked-in" && (
139 | }
141 | onClick={() => checkout(bookingId)}
142 | disabled={isCheckingOut}
143 | >
144 | Check out
145 |
146 | )}
147 |
148 |
149 | }>Delete booking
150 |
151 |
152 |
153 |
154 |
155 | deleteBooking(bookingId)}
159 | />
160 |
161 |
162 |
163 | );
164 | }
165 |
166 | export default BookingRow;
167 |
--------------------------------------------------------------------------------
/src/features/cabins/CreateCabinForm.jsx:
--------------------------------------------------------------------------------
1 | import { useForm } from "react-hook-form";
2 |
3 | import Input from "../../ui/Input";
4 | import Form from "../../ui/Form";
5 | import Button from "../../ui/Button";
6 | import FileInput from "../../ui/FileInput";
7 | import Textarea from "../../ui/Textarea";
8 | import FormRow from "../../ui/FormRow";
9 |
10 | import { useCreateCabin } from "./useCreateCabin";
11 | import { useEditCabin } from "./useEditCabin";
12 |
13 | function CreateCabinForm({ cabinToEdit = {}, onCloseModal }) {
14 | const { isCreating, createCabin } = useCreateCabin();
15 | const { isEditing, editCabin } = useEditCabin();
16 | const isWorking = isCreating || isEditing;
17 |
18 | const { id: editId, ...editValues } = cabinToEdit;
19 | const isEditSession = Boolean(editId);
20 |
21 | const { register, handleSubmit, reset, getValues, formState } = useForm({
22 | defaultValues: isEditSession ? editValues : {},
23 | });
24 | const { errors } = formState;
25 |
26 | function onSubmit(data) {
27 | const image = typeof data.image === "string" ? data.image : data.image[0];
28 |
29 | if (isEditSession)
30 | editCabin(
31 | { newCabinData: { ...data, image }, id: editId },
32 | {
33 | onSuccess: (data) => {
34 | reset();
35 | onCloseModal?.();
36 | },
37 | }
38 | );
39 | else
40 | createCabin(
41 | { ...data, image: image },
42 | {
43 | onSuccess: (data) => {
44 | reset();
45 | onCloseModal?.();
46 | },
47 | }
48 | );
49 | }
50 |
51 | function onError(errors) {
52 | // console.log(errors);
53 | }
54 |
55 | return (
56 |
155 | );
156 | }
157 |
158 | export default CreateCabinForm;
159 |
--------------------------------------------------------------------------------
/src/data/Uploader.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { isFuture, isPast, isToday } from "date-fns";
3 | import supabase from "../services/supabase";
4 | import Button from "../ui/Button";
5 | import { subtractDates } from "../utils/helpers";
6 |
7 | import { bookings } from "./data-bookings";
8 | import { cabins } from "./data-cabins";
9 | import { guests } from "./data-guests";
10 | import Heading from "../ui/Heading";
11 |
12 | // const originalSettings = {
13 | // minBookingLength: 3,
14 | // maxBookingLength: 30,
15 | // maxGuestsPerBooking: 10,
16 | // breakfastPrice: 15,
17 | // };
18 |
19 | async function deleteGuests() {
20 | const { error } = await supabase.from("guests").delete().gt("id", 0);
21 | if (error) console.log(error.message);
22 | }
23 |
24 | async function deleteCabins() {
25 | const { error } = await supabase.from("cabins").delete().gt("id", 0);
26 | if (error) console.log(error.message);
27 | }
28 |
29 | async function deleteBookings() {
30 | const { error } = await supabase.from("bookings").delete().gt("id", 0);
31 | if (error) console.log(error.message);
32 | }
33 |
34 | async function createGuests() {
35 | const { error } = await supabase.from("guests").insert(guests);
36 | if (error) console.log(error.message);
37 | }
38 |
39 | async function createCabins() {
40 | const { error } = await supabase.from("cabins").insert(cabins);
41 | if (error) console.log(error.message);
42 | }
43 |
44 | async function createBookings() {
45 | // Bookings need a guestId and a cabinId. We can't tell Supabase IDs for each object, it will calculate them on its own. So it might be different for different people, especially after multiple uploads. Therefore, we need to first get all guestIds and cabinIds, and then replace the original IDs in the booking data with the actual ones from the DB
46 | const { data: guestsIds } = await supabase
47 | .from("guests")
48 | .select("id")
49 | .order("id");
50 | const allGuestIds = guestsIds.map((cabin) => cabin.id);
51 | const { data: cabinsIds } = await supabase
52 | .from("cabins")
53 | .select("id")
54 | .order("id");
55 | const allCabinIds = cabinsIds.map((cabin) => cabin.id);
56 |
57 | const finalBookings = bookings.map((booking) => {
58 | // Here relying on the order of cabins, as they don't have and ID yet
59 | const cabin = cabins[booking.cabinId - 1];
60 | const numNights = subtractDates(booking.endDate, booking.startDate);
61 | const cabinPrice = numNights * (cabin.regularPrice - cabin.discount);
62 | const extrasPrice = booking.hasBreakfast
63 | ? numNights * 15 * booking.numGuests
64 | : 0; // hardcoded breakfast price
65 | const totalPrice = cabinPrice + extrasPrice;
66 |
67 | let status;
68 | if (
69 | isPast(new Date(booking.endDate)) &&
70 | !isToday(new Date(booking.endDate))
71 | )
72 | status = "checked-out";
73 | if (
74 | isFuture(new Date(booking.startDate)) ||
75 | isToday(new Date(booking.startDate))
76 | )
77 | status = "unconfirmed";
78 | if (
79 | (isFuture(new Date(booking.endDate)) ||
80 | isToday(new Date(booking.endDate))) &&
81 | isPast(new Date(booking.startDate)) &&
82 | !isToday(new Date(booking.startDate))
83 | )
84 | status = "checked-in";
85 |
86 | return {
87 | ...booking,
88 | numNights,
89 | cabinPrice,
90 | extrasPrice,
91 | totalPrice,
92 | guestId: allGuestIds.at(booking.guestId - 1),
93 | cabinId: allCabinIds.at(booking.cabinId - 1),
94 | status,
95 | };
96 | });
97 |
98 | const { error } = await supabase.from("bookings").insert(finalBookings);
99 | if (error) console.log(error.message);
100 | }
101 |
102 | function Uploader() {
103 | const [isLoading, setIsLoading] = useState(false);
104 |
105 | async function uploadAll() {
106 | setIsLoading(true);
107 | // Bookings need to be deleted FIRST
108 | await deleteBookings();
109 | await deleteGuests();
110 | await deleteCabins();
111 |
112 | // Bookings need to be created LAST
113 | await createGuests();
114 | await createCabins();
115 | await createBookings();
116 |
117 | setIsLoading(false);
118 | }
119 |
120 | async function uploadBookings() {
121 | setIsLoading(true);
122 | await deleteBookings();
123 | await createBookings();
124 | setIsLoading(false);
125 | }
126 |
127 | return (
128 |
140 | SAMPLE DATA
141 |
142 |
145 |
146 |
149 |
150 | );
151 | }
152 |
153 | export default Uploader;
154 |
--------------------------------------------------------------------------------
/src/features/dashboard/DurationChart.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import Heading from "../../ui/Heading";
3 | import {
4 | Cell,
5 | Legend,
6 | Pie,
7 | PieChart,
8 | ResponsiveContainer,
9 | Tooltip,
10 | } from "recharts";
11 | import { useDarkMode } from "../../context/DarkModeContext";
12 |
13 | const ChartBox = styled.div`
14 | /* Box */
15 | background-color: var(--color-grey-0);
16 | border: 1px solid var(--color-grey-100);
17 | border-radius: var(--border-radius-md);
18 |
19 | padding: 2.4rem 3.2rem;
20 | grid-column: 3 / span 2;
21 | width: 37%;
22 |
23 | @media screen and (max-width: 768px) {
24 | width: 100%;
25 | padding: 2rem 0rem;
26 | font-size: 1.4rem;
27 | }
28 | @media screen and (min-width: 769px) and (max-width: 1100px) {
29 | width: 100%;
30 | }
31 |
32 | & > *:first-child {
33 | margin-bottom: 1.6rem;
34 | @media screen and (max-width: 768px) {
35 | text-align: center;
36 | margin-bottom: 0.8rem;
37 | }
38 | }
39 |
40 | & .recharts-pie-label-text {
41 | font-weight: 600;
42 | }
43 | `;
44 |
45 | const startDataLight = [
46 | {
47 | duration: "1 night",
48 | value: 0,
49 | color: "#ef4444",
50 | },
51 | {
52 | duration: "2 nights",
53 | value: 0,
54 | color: "#f97316",
55 | },
56 | {
57 | duration: "3 nights",
58 | value: 0,
59 | color: "#eab308",
60 | },
61 | {
62 | duration: "4-5 nights",
63 | value: 0,
64 | color: "#84cc16",
65 | },
66 | {
67 | duration: "6-7 nights",
68 | value: 0,
69 | color: "#22c55e",
70 | },
71 | {
72 | duration: "8-14 nights",
73 | value: 0,
74 | color: "#14b8a6",
75 | },
76 | {
77 | duration: "15-21 nights",
78 | value: 0,
79 | color: "#3b82f6",
80 | },
81 | {
82 | duration: "21+ nights",
83 | value: 0,
84 | color: "#a855f7",
85 | },
86 | ];
87 |
88 | const startDataDark = [
89 | {
90 | duration: "1 night",
91 | value: 0,
92 | color: "#b91c1c",
93 | },
94 | {
95 | duration: "2 nights",
96 | value: 0,
97 | color: "#c2410c",
98 | },
99 | {
100 | duration: "3 nights",
101 | value: 0,
102 | color: "#a16207",
103 | },
104 | {
105 | duration: "4-5 nights",
106 | value: 0,
107 | color: "#4d7c0f",
108 | },
109 | {
110 | duration: "6-7 nights",
111 | value: 0,
112 | color: "#15803d",
113 | },
114 | {
115 | duration: "8-14 nights",
116 | value: 0,
117 | color: "#0f766e",
118 | },
119 | {
120 | duration: "15-21 nights",
121 | value: 0,
122 | color: "#1d4ed8",
123 | },
124 | {
125 | duration: "21+ nights",
126 | value: 0,
127 | color: "#7e22ce",
128 | },
129 | ];
130 |
131 | function prepareData(startData, stays) {
132 | // A bit ugly code, but sometimes this is what it takes when working with real data 😅
133 |
134 | function incArrayValue(arr, field) {
135 | return arr.map((obj) =>
136 | obj.duration === field ? { ...obj, value: obj.value + 1 } : obj
137 | );
138 | }
139 |
140 | const data = stays
141 | .reduce((arr, cur) => {
142 | const num = cur.numNights;
143 | if (num === 1) return incArrayValue(arr, "1 night");
144 | if (num === 2) return incArrayValue(arr, "2 nights");
145 | if (num === 3) return incArrayValue(arr, "3 nights");
146 | if ([4, 5].includes(num)) return incArrayValue(arr, "4-5 nights");
147 | if ([6, 7].includes(num)) return incArrayValue(arr, "6-7 nights");
148 | if (num >= 8 && num <= 14) return incArrayValue(arr, "8-14 nights");
149 | if (num >= 15 && num <= 21) return incArrayValue(arr, "15-21 nights");
150 | if (num >= 21) return incArrayValue(arr, "21+ nights");
151 | return arr;
152 | }, startData)
153 | .filter((obj) => obj.value > 0);
154 |
155 | return data;
156 | }
157 |
158 | function DurationChart({ confirmedStays }) {
159 | const { isDarkMode } = useDarkMode();
160 | const startData = isDarkMode ? startDataDark : startDataLight;
161 | const data = prepareData(startData, confirmedStays);
162 |
163 | return (
164 |
165 | Stay duration summary
166 |
167 |
168 |
169 |
177 | {data.map((entry, index) => (
178 | |
179 | ))}
180 |
181 |
182 |
189 |
190 |
191 |
192 | );
193 | }
194 |
195 | export default DurationChart;
196 |
--------------------------------------------------------------------------------
/src/styles/GlobalStyle.js:
--------------------------------------------------------------------------------
1 | import { createGlobalStyle } from "styled-components";
2 |
3 | const GlobalStyles = createGlobalStyle`
4 | :root {
5 | /* Indigo */
6 |
7 |
8 | &,&.light-mode{
9 |
10 |
11 | /* Grey */
12 | --color-grey-0: #fff;
13 | --color-grey-50: #f9fafb;
14 | --color-grey-100: #f3f4f6;
15 | --color-grey-200: #e5e7eb;
16 | --color-grey-300: #d1d5db;
17 | --color-grey-400: #9ca3af;
18 | --color-grey-500: #6b7280;
19 | --color-grey-600: #4b5563;
20 | --color-grey-700: #374151;
21 | --color-grey-800: #1f2937;
22 | --color-grey-900: #111827;
23 |
24 | --color-blue-100: #e0f2fe;
25 | --color-blue-700: #0369a1;
26 | --color-green-100: #dcfce7;
27 | --color-green-700: #15803d;
28 | --color-yellow-100: #fef9c3;
29 | --color-yellow-700: #a16207;
30 | --color-silver-100: #e5e7eb;
31 | --color-silver-700: #374151;
32 | --color-indigo-100: #e0e7ff;
33 | --color-indigo-700: #4338ca;
34 |
35 | --color-red-100: #fee2e2;
36 | --color-red-700: #b91c1c;
37 | --color-red-800: #991b1b;
38 |
39 | --backdrop-color: rgba(255, 255, 255, 0.1);
40 |
41 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.04);
42 | --shadow-md: 0px 0.6rem 2.4rem rgba(0, 0, 0, 0.06);
43 | --shadow-lg: 0 2.4rem 3.2rem rgba(0, 0, 0, 0.12);
44 |
45 | --border-radius-tiny: 3px;
46 | --border-radius-sm: 5px;
47 | --border-radius-md: 7px;
48 | --border-radius-lg: 9px;
49 | --image-grayscale: 0;
50 | --image-opacity: 100%;}
51 |
52 | &.dark-mode{
53 | --color-grey-0: #18212f;
54 | --color-grey-50: #111827;
55 | --color-grey-100: #1f2937;
56 | --color-grey-200: #374151;
57 | --color-grey-300: #4b5563;
58 | --color-grey-400: #6b7280;
59 | --color-grey-500: #9ca3af;
60 | --color-grey-600: #d1d5db;
61 | --color-grey-700: #e5e7eb;
62 | --color-grey-800: #f3f4f6;
63 | --color-grey-900: #f9fafb;
64 |
65 | --color-blue-100: #075985;
66 | --color-blue-700: #e0f2fe;
67 | --color-green-100: #166534;
68 | --color-green-700: #dcfce7;
69 | --color-yellow-100: #854d0e;
70 | --color-yellow-700: #fef9c3;
71 | --color-silver-100: #374151;
72 | --color-silver-700: #f3f4f6;
73 | --color-indigo-100: #3730a3;
74 | --color-indigo-700: #e0e7ff;
75 |
76 | --color-red-100: #fee2e2;
77 | --color-red-700: #b91c1c;
78 | --color-red-800: #991b1b;
79 |
80 | --backdrop-color: rgba(0, 0, 0, 0.3);
81 |
82 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
83 | --shadow-md: 0px 0.6rem 2.4rem rgba(0, 0, 0, 0.3);
84 | --shadow-lg: 0 2.4rem 3.2rem rgba(0, 0, 0, 0.4);
85 |
86 | --image-grayscale: 10%;
87 | --image-opacity: 90%;
88 | ;
89 | }
90 |
91 | --color-brand-50: #eef2ff;
92 | --color-brand-100: #e0e7ff;
93 | --color-brand-200: #c7d2fe;
94 | --color-brand-500: #6366f1;
95 | --color-brand-600: #4f46e5;
96 | --color-brand-700: #4338ca;
97 | --color-brand-800: #3730a3;
98 | --color-brand-900: #312e81;
99 |
100 |
101 |
102 | }
103 |
104 | *,
105 | *::before,
106 | *::after {
107 | box-sizing: border-box;
108 | padding: 0;
109 | margin: 0;
110 |
111 | /* Creating animations for dark mode */
112 | transition: background-color 0.3s, border 0.3s;
113 | }
114 |
115 | html {
116 | font-size: 62.5%;
117 | }
118 |
119 | body {
120 | font-family: "Poppins", sans-serif;
121 | color: var(--color-grey-700);
122 |
123 | transition: color 0.3s, background-color 0.3s;
124 | min-height: 100vh;
125 | line-height: 1.5;
126 | font-size: 1.6rem;
127 | }
128 |
129 | input,
130 | button,
131 | textarea,
132 | select {
133 | font: inherit;
134 | color: inherit;
135 | }
136 |
137 | button {
138 | cursor: pointer;
139 | }
140 |
141 | *:disabled {
142 | cursor: not-allowed;
143 | }
144 |
145 | select:disabled,
146 | input:disabled {
147 | background-color: var(--color-grey-200);
148 | color: var(--color-grey-500);
149 | }
150 |
151 | input:focus,
152 | button:focus,
153 | textarea:focus,
154 | select:focus {
155 | outline: 2px solid var(--color-brand-600);
156 | outline-offset: -1px;
157 | }
158 |
159 | /* Parent selector, finally 😃 */
160 | button:has(svg) {
161 | line-height: 0;
162 | }
163 |
164 | a {
165 | color: inherit;
166 | text-decoration: none;
167 | }
168 |
169 | ul {
170 | list-style: none;
171 | }
172 |
173 | p,
174 | h1,
175 | h2,
176 | h3,
177 | h4,
178 | h5,
179 | h6 {
180 | overflow-wrap: break-word;
181 | hyphens: auto;
182 | }
183 |
184 | img {
185 | max-width: 100%;
186 |
187 | /* For dark mode */
188 | filter: grayscale(var(--image-grayscale)) opacity(var(--image-opacity));
189 | }
190 |
191 | /*
192 | FOR DARK MODE
193 |
194 | --color-grey-0: #18212f;
195 | --color-grey-50: #111827;
196 | --color-grey-100: #1f2937;
197 | --color-grey-200: #374151;
198 | --color-grey-300: #4b5563;
199 | --color-grey-400: #6b7280;
200 | --color-grey-500: #9ca3af;
201 | --color-grey-600: #d1d5db;
202 | --color-grey-700: #e5e7eb;
203 | --color-grey-800: #f3f4f6;
204 | --color-grey-900: #f9fafb;
205 |
206 | --color-blue-100: #075985;
207 | --color-blue-700: #e0f2fe;
208 | --color-green-100: #166534;
209 | --color-green-700: #dcfce7;
210 | --color-yellow-100: #854d0e;
211 | --color-yellow-700: #fef9c3;
212 | --color-silver-100: #374151;
213 | --color-silver-700: #f3f4f6;
214 | --color-indigo-100: #3730a3;
215 | --color-indigo-700: #e0e7ff;
216 |
217 | --color-red-100: #fee2e2;
218 | --color-red-700: #b91c1c;
219 | --color-red-800: #991b1b;
220 |
221 | --backdrop-color: rgba(0, 0, 0, 0.3);
222 |
223 | --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.4);
224 | --shadow-md: 0px 0.6rem 2.4rem rgba(0, 0, 0, 0.3);
225 | --shadow-lg: 0 2.4rem 3.2rem rgba(0, 0, 0, 0.4);
226 |
227 | --image-grayscale: 10%;
228 | --image-opacity: 90%;
229 | */`;
230 |
231 | export default GlobalStyles;
232 |
--------------------------------------------------------------------------------
/src/data/data-cabins.js:
--------------------------------------------------------------------------------
1 | import { supabaseUrl } from "../services/supabase";
2 |
3 | const imageUrl = `${supabaseUrl}/storage/v1/object/public/cabin-images/`;
4 |
5 | export const cabins = [
6 | {
7 | name: "001",
8 | maxCapacity: 2,
9 | regularPrice: 250,
10 | discount: 0,
11 | image: imageUrl + "cabin-001.jpg",
12 | description:
13 | "Discover the ultimate luxury getaway for couples in the cozy wooden cabin 001. Nestled in a picturesque forest, this stunning cabin offers a secluded and intimate retreat. Inside, enjoy modern high-quality wood interiors, a comfortable seating area, a fireplace and a fully-equipped kitchen. The plush king-size bed, dressed in fine linens guarantees a peaceful nights sleep. Relax in the spa-like shower and unwind on the private deck with hot tub.",
14 | },
15 | {
16 | name: "002",
17 | maxCapacity: 2,
18 | regularPrice: 350,
19 | discount: 25,
20 | image: imageUrl + "cabin-002.jpg",
21 | description:
22 | "Escape to the serenity of nature and indulge in luxury in our cozy cabin 002. Perfect for couples, this cabin offers a secluded and intimate retreat in the heart of a picturesque forest. Inside, you will find warm and inviting interiors crafted from high-quality wood, a comfortable living area, a fireplace and a fully-equipped kitchen. The luxurious bedroom features a plush king-size bed and spa-like shower. Relax on the private deck with hot tub and take in the beauty of nature.",
23 | },
24 | {
25 | name: "003",
26 | maxCapacity: 4,
27 | regularPrice: 300,
28 | discount: 0,
29 | image: imageUrl + "cabin-003.jpg",
30 | description:
31 | "Experience luxury family living in our medium-sized wooden cabin 003. Perfect for families of up to 4 people, this cabin offers a comfortable and inviting space with all modern amenities. Inside, you will find warm and inviting interiors crafted from high-quality wood, a comfortable living area, a fireplace, and a fully-equipped kitchen. The bedrooms feature plush beds and spa-like bathrooms. The cabin has a private deck with a hot tub and outdoor seating area, perfect for taking in the natural surroundings.",
32 | },
33 | {
34 | name: "004",
35 | maxCapacity: 4,
36 | regularPrice: 500,
37 | discount: 50,
38 | image: imageUrl + "cabin-004.jpg",
39 | description:
40 | "Indulge in the ultimate luxury family vacation in this medium-sized cabin 004. Designed for families of up to 4, this cabin offers a sumptuous retreat for the discerning traveler. Inside, the cabin boasts of opulent interiors crafted from the finest quality wood, a comfortable living area, a fireplace, and a fully-equipped gourmet kitchen. The bedrooms are adorned with plush beds and spa-inspired en-suite bathrooms. Step outside to your private deck and soak in the natural surroundings while relaxing in your own hot tub.",
41 | },
42 | {
43 | name: "005",
44 | maxCapacity: 6,
45 | regularPrice: 350,
46 | discount: 0,
47 | image: imageUrl + "cabin-005.jpg",
48 | description:
49 | "Enjoy a comfortable and cozy getaway with your group or family in our spacious cabin 005. Designed to accommodate up to 6 people, this cabin offers a secluded retreat in the heart of nature. Inside, the cabin features warm and inviting interiors crafted from quality wood, a living area with fireplace, and a fully-equipped kitchen. The bedrooms are comfortable and equipped with en-suite bathrooms. Step outside to your private deck and take in the natural surroundings while relaxing in your own hot tub.",
50 | },
51 | {
52 | name: "006",
53 | maxCapacity: 6,
54 | regularPrice: 800,
55 | discount: 100,
56 | image: imageUrl + "cabin-006.jpg",
57 | description:
58 | "Experience the epitome of luxury with your group or family in our spacious wooden cabin 006. Designed to comfortably accommodate up to 6 people, this cabin offers a lavish retreat in the heart of nature. Inside, the cabin features opulent interiors crafted from premium wood, a grand living area with fireplace, and a fully-equipped gourmet kitchen. The bedrooms are adorned with plush beds and spa-like en-suite bathrooms. Step outside to your private deck and soak in the natural surroundings while relaxing in your own hot tub.",
59 | },
60 | {
61 | name: "007",
62 | maxCapacity: 8,
63 | regularPrice: 600,
64 | discount: 100,
65 | image: imageUrl + "cabin-007.jpg",
66 | description:
67 | "Accommodate your large group or multiple families in the spacious and grand wooden cabin 007. Designed to comfortably fit up to 8 people, this cabin offers a secluded retreat in the heart of beautiful forests and mountains. Inside, the cabin features warm and inviting interiors crafted from quality wood, multiple living areas with fireplace, and a fully-equipped kitchen. The bedrooms are comfortable and equipped with en-suite bathrooms. The cabin has a private deck with a hot tub and outdoor seating area, perfect for taking in the natural surroundings.",
68 | },
69 | {
70 | name: "008",
71 | maxCapacity: 10,
72 | regularPrice: 1400,
73 | discount: 0,
74 | image: imageUrl + "cabin-008.jpg",
75 | description:
76 | "Experience the epitome of luxury and grandeur with your large group or multiple families in our grand cabin 008. This cabin offers a lavish retreat that caters to all your needs and desires. The cabin features an opulent design and boasts of high-end finishes, intricate details and the finest quality wood throughout. Inside, the cabin features multiple grand living areas with fireplaces, a formal dining area, and a gourmet kitchen that is a chef's dream. The bedrooms are designed for ultimate comfort and luxury, with plush beds and en-suite spa-inspired bathrooms. Step outside and immerse yourself in the beauty of nature from your private deck, featuring a luxurious hot tub and ample seating areas for ultimate relaxation and enjoyment.",
77 | },
78 | ];
79 |
--------------------------------------------------------------------------------
/src/features/bookings/BookingDataBox.jsx:
--------------------------------------------------------------------------------
1 | import styled from "styled-components";
2 | import { format, isToday } from "date-fns";
3 | import {
4 | HiOutlineChatBubbleBottomCenterText,
5 | HiOutlineCheckCircle,
6 | HiOutlineCurrencyDollar,
7 | HiOutlineHomeModern,
8 | } from "react-icons/hi2";
9 |
10 | import DataItem from "../../ui/DataItem";
11 | import { Flag } from "../../ui/Flag";
12 |
13 | import { formatDistanceFromNow, formatCurrency } from "../../utils/helpers";
14 |
15 | const StyledBookingDataBox = styled.section`
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 | overflow: hidden;
22 | `;
23 |
24 | const Header = styled.header`
25 | background-color: var(--color-brand-500);
26 | padding: 2rem 4rem;
27 | color: #e0e7ff;
28 | font-size: 1.8rem;
29 | font-weight: 500;
30 | display: flex;
31 | align-items: center;
32 | justify-content: space-between;
33 | @media screen and (max-width: 768px) {
34 | padding: 0.2rem 0.3rem;
35 | font-size: 0.9rem;
36 | }
37 |
38 | svg {
39 | height: 3.2rem;
40 | width: 3.2rem;
41 | @media screen and (max-width: 768px) {
42 | height: 2.6rem;
43 | width: 2.6rem;
44 | }
45 | }
46 |
47 | & div:first-child {
48 | display: flex;
49 | align-items: center;
50 | gap: 1.6rem;
51 | font-weight: 600;
52 | font-size: 1.8rem;
53 | @media screen and (max-width: 768px) {
54 | gap: 0.7rem;
55 | font-size: 1rem;
56 | }
57 | }
58 |
59 | & span {
60 | font-family: "Sono";
61 | font-size: 2rem;
62 | margin-left: 4px;
63 | @media screen and (max-width: 768px) {
64 | font-size: 0.9rem;
65 | }
66 | }
67 | `;
68 |
69 | const Section = styled.section`
70 | padding: 3.2rem 4rem 1.2rem;
71 | @media screen and (max-width: 768px) {
72 | padding: 2rem 2rem 1.2rem;
73 | }
74 | `;
75 |
76 | const Guest = styled.div`
77 | display: flex;
78 | align-items: center;
79 | gap: 1.2rem;
80 | flex-wrap: wrap;
81 | margin-bottom: 1.6rem;
82 | color: var(--color-grey-500);
83 |
84 | @media screen and (max-width: 768px) {
85 | gap: 0.8rem;
86 | }
87 |
88 | & p:first-of-type {
89 | font-weight: 500;
90 | color: var(--color-grey-700);
91 | }
92 | `;
93 |
94 | const Price = styled.div`
95 | display: flex;
96 | align-items: center;
97 | justify-content: space-between;
98 | padding: 1.6rem 3.2rem;
99 | border-radius: var(--border-radius-sm);
100 | margin-top: 2.4rem;
101 |
102 | @media screen and (max-width: 768px) {
103 | padding: 0.8rem 1.8rem;
104 | font-size: 1rem;
105 | }
106 |
107 | background-color: ${(props) =>
108 | props.isPaid ? "var(--color-green-100)" : "var(--color-yellow-100)"};
109 | color: ${(props) =>
110 | props.isPaid ? "var(--color-green-700)" : "var(--color-yellow-700)"};
111 |
112 | & p:last-child {
113 | text-transform: uppercase;
114 | font-size: 1.4rem;
115 | font-weight: 600;
116 | }
117 |
118 | svg {
119 | height: 2.4rem;
120 | width: 2.4rem;
121 | color: currentColor !important;
122 | }
123 | `;
124 |
125 | const Footer = styled.footer`
126 | padding: 1.6rem 4rem;
127 | font-size: 1.2rem;
128 | color: var(--color-grey-500);
129 | text-align: right;
130 | `;
131 |
132 | // A purely presentational component
133 | function BookingDataBox({ booking }) {
134 | const {
135 | created_at,
136 | startDate,
137 | endDate,
138 | numNights,
139 | numGuests,
140 | cabinPrice,
141 | extrasPrice,
142 | totalPrice,
143 | hasBreakfast,
144 | observations,
145 | isPaid,
146 | guests: { fullName: guestName, email, country, countryFlag, nationalID },
147 | cabins: { name: cabinName },
148 | } = booking;
149 |
150 | return (
151 |
152 |
153 |
154 |
155 |
156 | {numNights} nights in Cabin {cabinName}
157 |
158 |
159 |
160 |
161 | {format(new Date(startDate), "EEE, MMM dd yyyy")} (
162 | {isToday(new Date(startDate))
163 | ? "Today"
164 | : formatDistanceFromNow(startDate)}
165 | ) — {format(new Date(endDate), "EEE, MMM dd yyyy")}
166 |
167 |
168 |
169 |
170 |
171 | {countryFlag && }
172 |
173 | {guestName} {numGuests > 1 ? `+ ${numGuests - 1} guests` : ""}
174 |
175 | •
176 | {email}
177 | •
178 | National ID {nationalID}
179 |
180 |
181 | {observations && (
182 | }
184 | label="Observations"
185 | >
186 | {observations}
187 |
188 | )}
189 |
190 | } label="Breakfast included?">
191 | {hasBreakfast ? "Yes" : "No"}
192 |
193 |
194 |
195 | } label={`Total price`}>
196 | {formatCurrency(totalPrice)}
197 |
198 | {hasBreakfast &&
199 | ` (${formatCurrency(cabinPrice)} cabin + ${formatCurrency(
200 | extrasPrice
201 | )} breakfast)`}
202 |
203 |
204 | {isPaid ? "Paid" : "Will pay at property"}
205 |
206 |
207 |
208 |
211 |
212 | );
213 | }
214 |
215 | export default BookingDataBox;
216 |
--------------------------------------------------------------------------------
/src/data/data-guests.js:
--------------------------------------------------------------------------------
1 | // Search for: 'jo', 'fa', 'mar', 'emm', 'ah'
2 |
3 | export const guests = [
4 | {
5 | // id: 1000,
6 | fullName: "Jonas Schmedtmann",
7 | email: "hello@jonas.io",
8 | nationality: "Portugal",
9 | nationalID: "3525436345",
10 | countryFlag: "https://flagcdn.com/pt.svg",
11 | },
12 | {
13 | fullName: "Jonathan Smith",
14 | email: "johnsmith@test.eu",
15 | nationality: "Great Britain",
16 | nationalID: "4534593454",
17 | countryFlag: "https://flagcdn.com/gb.svg",
18 | },
19 | {
20 | fullName: "Jonatan Johansson",
21 | email: "jonatan@example.com",
22 | nationality: "Finland",
23 | nationalID: "9374074454",
24 | countryFlag: "https://flagcdn.com/fi.svg",
25 | },
26 | {
27 | fullName: "Jonas Mueller",
28 | email: "jonas@example.eu",
29 | nationality: "Germany",
30 | nationalID: "1233212288",
31 | countryFlag: "https://flagcdn.com/de.svg",
32 | },
33 | {
34 | fullName: "Jonas Anderson",
35 | email: "anderson@example.com",
36 | nationality: "Bolivia (Plurinational State of)",
37 | nationalID: "0988520146",
38 | countryFlag: "https://flagcdn.com/bo.svg",
39 | },
40 | {
41 | fullName: "Jonathan Williams",
42 | email: "jowi@gmail.com",
43 | nationality: "United States of America",
44 | nationalID: "633678543",
45 | countryFlag: "https://flagcdn.com/us.svg",
46 | },
47 |
48 | // GPT
49 | {
50 | fullName: "Emma Watson",
51 | email: "emma@gmail.com",
52 | nationality: "United Kingdom",
53 | nationalID: "1234578901",
54 | countryFlag: "https://flagcdn.com/gb.svg",
55 | },
56 | {
57 | fullName: "Mohammed Ali",
58 | email: "mohammedali@yahoo.com",
59 | nationality: "Egypt",
60 | nationalID: "987543210",
61 | countryFlag: "https://flagcdn.com/eg.svg",
62 | },
63 | {
64 | fullName: "Maria Rodriguez",
65 | email: "maria@gmail.com",
66 | nationality: "Spain",
67 | nationalID: "1098765321",
68 | countryFlag: "https://flagcdn.com/es.svg",
69 | },
70 | {
71 | fullName: "Li Mei",
72 | email: "li.mei@hotmail.com",
73 | nationality: "China",
74 | nationalID: "102934756",
75 | countryFlag: "https://flagcdn.com/cn.svg",
76 | },
77 | {
78 | fullName: "Khadija Ahmed",
79 | email: "khadija@gmail.com",
80 | nationality: "Sudan",
81 | nationalID: "1023457890",
82 | countryFlag: "https://flagcdn.com/sd.svg",
83 | },
84 | {
85 | fullName: "Gabriel Silva",
86 | email: "gabriel@gmail.com",
87 | nationality: "Brazil",
88 | nationalID: "109283465",
89 | countryFlag: "https://flagcdn.com/br.svg",
90 | },
91 | {
92 | fullName: "Maria Gomez",
93 | email: "maria@example.com",
94 | nationality: "Mexico",
95 | nationalID: "108765421",
96 | countryFlag: "https://flagcdn.com/mx.svg",
97 | },
98 | {
99 | fullName: "Ahmed Hassan",
100 | email: "ahmed@gmail.com",
101 | nationality: "Egypt",
102 | nationalID: "1077777777",
103 | countryFlag: "https://flagcdn.com/eg.svg",
104 | },
105 | {
106 | fullName: "John Doe",
107 | email: "johndoe@gmail.com",
108 | nationality: "United States",
109 | nationalID: "3245908744",
110 | countryFlag: "https://flagcdn.com/us.svg",
111 | },
112 | {
113 | fullName: "Fatima Ahmed",
114 | email: "fatima@example.com",
115 | nationality: "Pakistan",
116 | nationalID: "1089999363",
117 | countryFlag: "https://flagcdn.com/pk.svg",
118 | },
119 | {
120 | fullName: "David Smith",
121 | email: "david@gmail.com",
122 | nationality: "Australia",
123 | nationalID: "44450960283",
124 | countryFlag: "https://flagcdn.com/au.svg",
125 | },
126 | {
127 | fullName: "Marie Dupont",
128 | email: "marie@gmail.com",
129 | nationality: "France",
130 | nationalID: "06934233728",
131 | countryFlag: "https://flagcdn.com/fr.svg",
132 | },
133 | {
134 | fullName: "Ramesh Patel",
135 | email: "ramesh@gmail.com",
136 | nationality: "India",
137 | nationalID: "9875412303",
138 | countryFlag: "https://flagcdn.com/in.svg",
139 | },
140 | {
141 | fullName: "Fatimah Al-Sayed",
142 | email: "fatimah@gmail.com",
143 | nationality: "Kuwait",
144 | nationalID: "0123456789",
145 | countryFlag: "https://flagcdn.com/kw.svg",
146 | },
147 | {
148 | fullName: "Nina Williams",
149 | email: "nina@hotmail.com",
150 | nationality: "South Africa",
151 | nationalID: "2345678901",
152 | countryFlag: "https://flagcdn.com/za.svg",
153 | },
154 | {
155 | fullName: "Taro Tanaka",
156 | email: "taro@gmail.com",
157 | nationality: "Japan",
158 | nationalID: "3456789012",
159 | countryFlag: "https://flagcdn.com/jp.svg",
160 | },
161 | {
162 | fullName: "Abdul Rahman",
163 | email: "abdul@gmail.com",
164 | nationality: "Saudi Arabia",
165 | nationalID: "4567890123",
166 | countryFlag: "https://flagcdn.com/sa.svg",
167 | },
168 | {
169 | fullName: "Julie Nguyen",
170 | email: "julie@gmail.com",
171 | nationality: "Vietnam",
172 | nationalID: "5678901234",
173 | countryFlag: "https://flagcdn.com/vn.svg",
174 | },
175 | {
176 | fullName: "Sara Lee",
177 | email: "sara@gmail.com",
178 | nationality: "South Korea",
179 | nationalID: "6789012345",
180 | countryFlag: "https://flagcdn.com/kr.svg",
181 | },
182 | {
183 | fullName: "Carlos Gomez",
184 | email: "carlos@yahoo.com",
185 | nationality: "Colombia",
186 | nationalID: "7890123456",
187 | countryFlag: "https://flagcdn.com/co.svg",
188 | },
189 | {
190 | fullName: "Emma Brown",
191 | email: "emma@gmail.com",
192 | nationality: "Canada",
193 | nationalID: "8901234567",
194 | countryFlag: "https://flagcdn.com/ca.svg",
195 | },
196 | {
197 | fullName: "Juan Hernandez",
198 | email: "juan@yahoo.com",
199 | nationality: "Argentina",
200 | nationalID: "4343433333",
201 | countryFlag: "https://flagcdn.com/ar.svg",
202 | },
203 | {
204 | fullName: "Ibrahim Ahmed",
205 | email: "ibrahim@yahoo.com",
206 | nationality: "Nigeria",
207 | nationalID: "2345678009",
208 | countryFlag: "https://flagcdn.com/ng.svg",
209 | },
210 | {
211 | fullName: "Mei Chen",
212 | email: "mei@gmail.com",
213 | nationality: "Taiwan",
214 | nationalID: "3456117890",
215 | countryFlag: "https://flagcdn.com/tw.svg",
216 | },
217 | ];
218 |
--------------------------------------------------------------------------------
/src/data/data-bookings.js:
--------------------------------------------------------------------------------
1 | import { add } from 'date-fns';
2 |
3 | function fromToday(numDays, withTime = false) {
4 | const date = add(new Date(), { days: numDays });
5 | if (!withTime) date.setUTCHours(0, 0, 0, 0);
6 | return date.toISOString().slice(0, -1);
7 | }
8 |
9 | export const bookings = [
10 | // CABIN 001
11 | {
12 | created_at: fromToday(-20, true),
13 | startDate: fromToday(0),
14 | endDate: fromToday(7),
15 | cabinId: 1,
16 | guestId: 2,
17 | hasBreakfast: true,
18 | observations:
19 | 'I have a gluten allergy and would like to request a gluten-free breakfast.',
20 | isPaid: false,
21 | numGuests: 1,
22 | },
23 | {
24 | created_at: fromToday(-33, true),
25 | startDate: fromToday(-23),
26 | endDate: fromToday(-13),
27 | cabinId: 1,
28 | guestId: 3,
29 | hasBreakfast: true,
30 | observations: '',
31 | isPaid: true,
32 | numGuests: 2,
33 | },
34 | {
35 | created_at: fromToday(-27, true),
36 | startDate: fromToday(12),
37 | endDate: fromToday(18),
38 | cabinId: 1,
39 | guestId: 4,
40 | hasBreakfast: false,
41 | observations: '',
42 | isPaid: false,
43 | numGuests: 2,
44 | },
45 |
46 | // CABIN 002
47 | {
48 | created_at: fromToday(-45, true),
49 | startDate: fromToday(-45),
50 | endDate: fromToday(-29),
51 | cabinId: 2,
52 | guestId: 5,
53 | hasBreakfast: false,
54 | observations: '',
55 | isPaid: true,
56 | numGuests: 2,
57 | },
58 | {
59 | created_at: fromToday(-2, true),
60 | startDate: fromToday(15),
61 | endDate: fromToday(18),
62 | cabinId: 2,
63 | guestId: 6,
64 | hasBreakfast: true,
65 | observations: '',
66 | isPaid: true,
67 | numGuests: 2,
68 | },
69 | {
70 | created_at: fromToday(-5, true),
71 | startDate: fromToday(33),
72 | endDate: fromToday(48),
73 | cabinId: 2,
74 | guestId: 7,
75 | hasBreakfast: true,
76 | observations: '',
77 | isPaid: false,
78 | numGuests: 2,
79 | },
80 |
81 | // CABIN 003
82 | {
83 | created_at: fromToday(-65, true),
84 | startDate: fromToday(-25),
85 | endDate: fromToday(-20),
86 | cabinId: 3,
87 | guestId: 8,
88 | hasBreakfast: true,
89 | observations: '',
90 | isPaid: true,
91 | numGuests: 4,
92 | },
93 | {
94 | created_at: fromToday(-2, true),
95 | startDate: fromToday(-2),
96 | endDate: fromToday(0),
97 | cabinId: 3,
98 | guestId: 9,
99 | hasBreakfast: false,
100 | observations: 'We will be bringing our small dog with us',
101 | isPaid: true,
102 | numGuests: 3,
103 | },
104 | {
105 | created_at: fromToday(-14, true),
106 | startDate: fromToday(-14),
107 | endDate: fromToday(-11),
108 | cabinId: 3,
109 | guestId: 10,
110 | hasBreakfast: true,
111 | observations: '',
112 | isPaid: true,
113 | numGuests: 4,
114 | },
115 |
116 | // CABIN 004
117 | {
118 | created_at: fromToday(-30, true),
119 | startDate: fromToday(-4),
120 | endDate: fromToday(8),
121 | cabinId: 4,
122 | guestId: 11,
123 | hasBreakfast: true,
124 | observations: '',
125 | isPaid: true,
126 | numGuests: 4,
127 | },
128 | {
129 | created_at: fromToday(-1, true),
130 | startDate: fromToday(12),
131 | endDate: fromToday(17),
132 | cabinId: 4,
133 | guestId: 12,
134 | hasBreakfast: true,
135 | observations: '',
136 | isPaid: false,
137 | numGuests: 4,
138 | },
139 | {
140 | created_at: fromToday(-3, true),
141 | startDate: fromToday(18),
142 | endDate: fromToday(19),
143 | cabinId: 4,
144 | guestId: 13,
145 | hasBreakfast: false,
146 | observations: '',
147 | isPaid: true,
148 | numGuests: 1,
149 | },
150 |
151 | // CABIN 005
152 | {
153 | created_at: fromToday(0, true),
154 | startDate: fromToday(14),
155 | endDate: fromToday(21),
156 | cabinId: 5,
157 | guestId: 14,
158 | hasBreakfast: true,
159 | observations: '',
160 | isPaid: false,
161 | numGuests: 5,
162 | },
163 | {
164 | created_at: fromToday(-6, true),
165 | startDate: fromToday(-6),
166 | endDate: fromToday(-4),
167 | cabinId: 5,
168 | guestId: 15,
169 | hasBreakfast: true,
170 | observations: '',
171 | isPaid: true,
172 | numGuests: 4,
173 | },
174 | {
175 | created_at: fromToday(-4, true),
176 | startDate: fromToday(-4),
177 | endDate: fromToday(-1),
178 | cabinId: 5,
179 | guestId: 16,
180 | hasBreakfast: false,
181 | observations: '',
182 | isPaid: true,
183 | numGuests: 6,
184 | },
185 |
186 | // CABIN 006
187 | {
188 | created_at: fromToday(-3, true),
189 | startDate: fromToday(0),
190 | endDate: fromToday(11),
191 | cabinId: 6,
192 | guestId: 17,
193 | hasBreakfast: false,
194 | observations:
195 | "We will be checking in late, around midnight. Hope that's okay :)",
196 | isPaid: true,
197 | numGuests: 6,
198 | },
199 | {
200 | created_at: fromToday(-16, true),
201 | startDate: fromToday(-16),
202 | endDate: fromToday(-9),
203 | cabinId: 6,
204 | guestId: 18,
205 | hasBreakfast: true,
206 | observations: 'I will need a rollaway bed for one of the guests',
207 | isPaid: true,
208 | numGuests: 4,
209 | },
210 | {
211 | created_at: fromToday(-18, true),
212 | startDate: fromToday(-4),
213 | endDate: fromToday(-1),
214 | cabinId: 6,
215 | guestId: 19,
216 | hasBreakfast: true,
217 | observations: '',
218 | isPaid: true,
219 | numGuests: 6,
220 | },
221 |
222 | // CABIN 007
223 | {
224 | created_at: fromToday(-2, true),
225 | startDate: fromToday(17),
226 | endDate: fromToday(23),
227 | cabinId: 7,
228 | guestId: 20,
229 | hasBreakfast: false,
230 | observations: '',
231 | isPaid: false,
232 | numGuests: 8,
233 | },
234 | {
235 | created_at: fromToday(-7, true),
236 | startDate: fromToday(40),
237 | endDate: fromToday(50),
238 | cabinId: 7,
239 | guestId: 21,
240 | hasBreakfast: true,
241 | observations: '',
242 | isPaid: true,
243 | numGuests: 7,
244 | },
245 | {
246 | created_at: fromToday(-55, true),
247 | startDate: fromToday(32),
248 | endDate: fromToday(37),
249 | cabinId: 7,
250 | guestId: 22,
251 | hasBreakfast: true,
252 | observations: '',
253 | isPaid: true,
254 | numGuests: 6,
255 | },
256 |
257 | // CABIN 008
258 | {
259 | created_at: fromToday(-8, true),
260 | startDate: fromToday(-5),
261 | endDate: fromToday(0),
262 | cabinId: 8,
263 | guestId: 1,
264 | hasBreakfast: true,
265 | observations:
266 | 'My wife has a gluten allergy so I would like to request a gluten-free breakfast if possible',
267 | isPaid: true,
268 | numGuests: 9,
269 | },
270 | {
271 | created_at: fromToday(0, true),
272 | startDate: fromToday(0),
273 | endDate: fromToday(5),
274 | cabinId: 8,
275 | guestId: 23,
276 | hasBreakfast: true,
277 | observations:
278 | 'I am celebrating my anniversary, can you arrange for any special amenities or decorations?',
279 | isPaid: true,
280 | numGuests: 10,
281 | },
282 | {
283 | created_at: fromToday(-10, true),
284 | startDate: fromToday(10),
285 | endDate: fromToday(13),
286 | cabinId: 8,
287 | guestId: 24,
288 | hasBreakfast: false,
289 | observations: '',
290 | isPaid: true,
291 | numGuests: 7,
292 | },
293 | ];
294 |
--------------------------------------------------------------------------------