initial commit
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||
import CircleCheckIcon from "@/components/frontend/icons/CircleCheckIcon";
|
||||
import DateTimeIcon from "@/components/frontend/icons/DateTimeIcon";
|
||||
import GreenCheckIcon from "@/components/frontend/icons/GreenCheckIcon";
|
||||
import PersonIcon from "@/components/frontend/icons/PersonIcon";
|
||||
import StarIcon from "@/components/frontend/icons/StarIcon";
|
||||
import Icon from "@/components/Icons";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { useBookingContext } from "./bookingContext";
|
||||
import { daysMapping, monthsMapping } from "@/utils/date-time-utils";
|
||||
import FavoriteButton from "@/components/frontend/FavoriteButton";
|
||||
import PropertySpaceMapImage from "@/components/frontend/PropertySpaceMapImage";
|
||||
import { usePropertySpace } from "@/hooks/api";
|
||||
|
||||
let sdk = new MkdSDK();
|
||||
|
||||
const BookingConfirmationPage = () => {
|
||||
const { bookingData } = useBookingContext();
|
||||
const [booking, setBooking] = useState({});
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [render, forceRender] = useState(false);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const { propertySpace } = usePropertySpace(booking.property_space_id, render);
|
||||
|
||||
async function fetchBooking(booking_id) {
|
||||
const where = [`ergo_booking.id = ${booking_id} AND ergo_booking.deleted_at IS NULL`];
|
||||
try {
|
||||
const result = await callCustomAPI("booking/details", "post", { where }, "");
|
||||
setBooking(result.list ?? {});
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchBooking(bookingData.id ?? id);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto px-6 pt-24 normal-case 2xl:px-16">
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(-1)}
|
||||
className="mr-2 mb-2 inline-flex items-center py-2.5 pr-5 text-center text-sm font-semibold"
|
||||
>
|
||||
<Icon
|
||||
type="arrow"
|
||||
variant="narrow-left"
|
||||
className="h-4 w-4 stroke-[#667085]"
|
||||
/>{" "}
|
||||
<span className="ml-2">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-[22px] flex flex-col justify-between md:flex-row">
|
||||
<div className="mb-6 flex flex-wrap items-center justify-center md:mb-0">
|
||||
<GreenCheckIcon />
|
||||
<h1 className="mr-3 text-xl font-semibold text-[#101828] md:text-3xl">Booking request successful</h1>
|
||||
</div>
|
||||
<Link
|
||||
to="/account/my-bookings"
|
||||
className="rounded-md border border-[#EAECF0] bg-[#F9FAFB] px-4 py-2 text-center text-[#101828]"
|
||||
>
|
||||
Go to my bookings
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start md:gap-[24px]">
|
||||
<div className="w-full md:w-[55%]">
|
||||
<div className="mb-[32px] rounded-lg border border-[#EAECF0] bg-[#F9FAFB] px-2 py-[16px] text-sm md:px-[20px] md:text-base">
|
||||
<div className="flex items-start gap-[12px]">
|
||||
<svg
|
||||
width="60"
|
||||
height="20"
|
||||
viewBox="0 0 20 20"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M9.99984 13.3333V9.99996M9.99984 6.66663H10.0082M18.3332 9.99996C18.3332 14.6023 14.6022 18.3333 9.99984 18.3333C5.39746 18.3333 1.6665 14.6023 1.6665 9.99996C1.6665 5.39759 5.39746 1.66663 9.99984 1.66663C14.6022 1.66663 18.3332 5.39759 18.3332 9.99996Z"
|
||||
stroke="#475467"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-[#101828]">What's next</h3>
|
||||
<p className="text-[#667085]">
|
||||
Your booking has been sent to the host and will be reviewed within 2 hours. If you don’t hear form the host withing 2h you can cancel your booking or reach out to them via{" "}
|
||||
<span className="font-semibold underline">Messages</span>.<br />
|
||||
<span className="font-semibold">Note: Payment is only after the host accepts your booking</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<li className="mb-6 text-xl font-semibold">Booking Time</li>
|
||||
<div className="mb-[32px] flex items-center justify-around rounded-lg border border-[#EAECF0] px-[20px] py-[16px]">
|
||||
<div className="w-[80px] rounded-lg border text-center">
|
||||
<p className="rounded-t-lg bg-black py-2 uppercase text-white">{monthsMapping[new Date(booking.booking_start_time).getMonth()] ?? "N/A"}</p>
|
||||
<strong className="text-3xl">{new Date(booking.booking_start_time).getDate() || "N/A"}</strong>
|
||||
<p className="uppercase text-[#667085]">{daysMapping[new Date(booking.booking_start_time).getDay()] ?? "N/A"}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-[10px] md:flex-row md:gap-[67px]">
|
||||
<div className="flex gap-[10px]">
|
||||
<DateTimeIcon />
|
||||
<p className="text-lg">From</p>
|
||||
<strong>{bookingData.from}</strong>
|
||||
</div>
|
||||
<div className="flex gap-[10px]">
|
||||
<DateTimeIcon />
|
||||
<p className="text-lg">Until</p>
|
||||
<strong>{bookingData.to}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<li className="mb-6 text-xl font-semibold">Space</li>
|
||||
<div className="mb-[32px] flex max-w-full flex-col rounded-lg border border-[#EAECF0] bg-[#F9FAFB] lg:min-h-[167px] lg:w-[unset] lg:flex-row lg:gap-[32px]">
|
||||
<div
|
||||
className="mb-[8px] flex h-[180px] w-full flex-col rounded-lg bg-cover bg-center bg-no-repeat px-[8px] pb-[13px] lg:w-[262px]"
|
||||
style={{ backgroundImage: `url(${booking.image_url ?? bookingData.url ?? "/default-property.jpg"})` }}
|
||||
>
|
||||
<FavoriteButton
|
||||
space_id={bookingData.id}
|
||||
user_property_spaces_id={propertySpace.user_property_spaces_id}
|
||||
reRender={forceRender}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-grow items-end justify-between py-6 pl-4 pr-4 lg:items-start lg:pl-0 lg:pr-8">
|
||||
<div className="">
|
||||
<h2 className="mb-[6px] text-[18px] font-semibold">{booking.property_name ?? bookingData.name}</h2>
|
||||
<p className="tracking-wider text-[#475467]">{propertySpace.city}</p>
|
||||
<p className="tracking-wider text-[#475467]">{propertySpace.country}</p>
|
||||
<div className="mt-[6px] flex lg:mt-[21px]">
|
||||
<p className="mr-[31px]">
|
||||
from: <span className="font-bold">${booking.hourly_rate}</span>/<span className="">day</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<PersonIcon />
|
||||
<span>{propertySpace.max_capacity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-center">
|
||||
<p className="flex items-center gap-2 lg:mb-[9px]">
|
||||
<StarIcon />
|
||||
<strong className="font-semibold">{(Number(propertySpace.average_space_rating) || 0).toFixed(1)}</strong>
|
||||
</p>
|
||||
<button
|
||||
className="whitespace-nowrap text-sm underline"
|
||||
target="_blank"
|
||||
onClick={() => setShowMap(true)}
|
||||
>
|
||||
(view on map)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<li className="mb-6 text-xl font-semibold">Add-ons:</li>
|
||||
|
||||
{/* <ul className="addons-grid mb-32">
|
||||
{(booking.add_ons ?? []).map((addon, idx) => (
|
||||
<li
|
||||
className="flex"
|
||||
key={idx}
|
||||
>
|
||||
<span className="w-[200px]">
|
||||
{" "}
|
||||
<div className="flex gap-4">
|
||||
<CircleCheckIcon /> {addon.name}
|
||||
</div>{" "}
|
||||
</span>{" "}
|
||||
</li>
|
||||
))}
|
||||
</ul> */}
|
||||
<ul className="w-full sm:flex flex-wrap gap-8">
|
||||
{(booking.add_ons ?? []).map((addon, idx) => (
|
||||
<li
|
||||
className="flex w-fit sm:w-full items-center gap-2 mb-4 sm:mb-0"
|
||||
key={idx}
|
||||
>
|
||||
<span className="w-fit">
|
||||
{" "}
|
||||
<div className="flex gap-4">
|
||||
<CircleCheckIcon /> {addon.name}
|
||||
</div>{" "}
|
||||
</span>{" "}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="max-w-full flex-grow">
|
||||
<div className="mb-[16px] rounded-lg border border-[#EAECF0] bg-[#F9FAFB] px-[20px] py-[24px] text-sm md:px-[32px] md:text-base">
|
||||
<h4 className="text-lg mb-[8px] font-semibold md:text-2xl">Charges</h4>
|
||||
<p className="mb-[16px] text-xs text-[#667085] md:text-sm">(You will not be charged until the host accepts your booking)</p>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Rate</p>
|
||||
<p className="font-semibold text-[#344054]"> ${booking.hourly_rate}</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Price</p>
|
||||
<p className="font-semibold text-[#344054]"> ${(booking.hourly_rate ?? 0) * ((booking.duration ?? 0) / 3600)}</p>
|
||||
</div>
|
||||
{(booking.add_ons ?? []).map((addon) => (
|
||||
<div
|
||||
className="mb-[12px] flex justify-between"
|
||||
key={addon.id}
|
||||
>
|
||||
<p>{addon.name}</p>
|
||||
<p className="font-semibold text-[#344054]"> ${addon.cost}</p>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="mb-[16px] flex justify-between">
|
||||
<p>Tax</p>
|
||||
<p className="font-semibold text-[#344054]"> ${booking.tax}</p>
|
||||
</div>
|
||||
<div className="-mx-3 mb-[12px] flex justify-between rounded-md bg-black p-3 font-bold text-white">
|
||||
<p>Total</p>
|
||||
<p> ${booking.total}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link
|
||||
to={"/help/cancellation-policy"}
|
||||
target="_blank"
|
||||
className="block text-center font-semibold text-[#667085] underline"
|
||||
>
|
||||
Cancellation policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<PropertySpaceMapImage
|
||||
modalImage={`https://maps.googleapis.com/maps/api/staticmap?center=${propertySpace.address_line_1 || ""}, ${propertySpace.address_line_2 || ""}, ${propertySpace.city || ""}, ${propertySpace.country || ""
|
||||
}&zoom=15&size=600x400&maptype=roadmap&markers=color:red|${propertySpace.address_line_1 || ""}, ${propertySpace.address_line_2 || ""}
|
||||
&key=${import.meta.env.VITE_GOOGLE_API_KEY}`}
|
||||
modalOpen={showMap}
|
||||
closeModal={() => setShowMap(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingConfirmationPage;
|
||||
@@ -0,0 +1,449 @@
|
||||
import { useStripe } from "@stripe/react-stripe-js";
|
||||
import moment from "moment";
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Navigate, useNavigate, useParams } from "react-router";
|
||||
import AddIcon from "@/components/frontend/icons/AddIcon";
|
||||
import DateTimeIcon from "@/components/frontend/icons/DateTimeIcon";
|
||||
import Icon from "@/components/Icons";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { useBookingContext } from "./bookingContext";
|
||||
import { formatDate, getDuration } from "@/utils/date-time-utils";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import { FavoriteButton, LoadingButton, AddOnCounter } from "@/components/frontend";
|
||||
import { usePropertyAddons, useTaxAndCommission, useCards } from "@/hooks/api";
|
||||
import { Link } from "react-router-dom";
|
||||
import { parseJsonSafely, sleep } from "@/utils/utils";
|
||||
import MultipleBookingErrorModal from "./MultipleBookingErrorModal";
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import SelectExistingCardsModal from "@/pages/Customer/Bookings/SelectExistingCardsModal";
|
||||
|
||||
|
||||
|
||||
const cardIcons = {
|
||||
MasterCard: "/mastercard.jpg",
|
||||
Visa: "/visa.jpg",
|
||||
"American Express": "/american-express.png",
|
||||
Discover: "/discover.png",
|
||||
};
|
||||
const sdk = new MkdSDK();
|
||||
const ctrl = new AbortController();
|
||||
const BOOKING_ERRORS = {
|
||||
ERR_MULTIPLE_BOOKING: "You already have a pending booking for this slot!",
|
||||
};
|
||||
|
||||
const BookingPreviewPage = () => {
|
||||
localStorage.removeItem("paying");
|
||||
const { bookingData, dispatch } = useBookingContext();
|
||||
const { dispatch: authDispatch } = useContext(AuthContext);
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [paymentOptions, setPaymentOptions] = useState(false);
|
||||
const bookingDetails = bookingData?.from !== "" ? bookingData : JSON.parse(localStorage.getItem("booking_details"));
|
||||
|
||||
const { register, watch } = useForm();
|
||||
const selectedAddons = watch();
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { cards } = useCards({ loader: false });
|
||||
const [errMultipleBooking, setErrMultipleBooking] = useState(false);
|
||||
const [paying, setPaying] = useState(false);
|
||||
const [existingCardsModal, setExistingCardsModal] = useState(localStorage.getItem("paying") ? true : false);
|
||||
const [paymentMethod, setPaymentMethod] = useState();
|
||||
const [confirmPayment, setConfirmPayment] = useState(false);
|
||||
const [clientSecret, setClientSecret] = useState(undefined);
|
||||
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_REACT_STRIPE_PUBLIC_KEY);
|
||||
if (id != bookingDetails.id) {
|
||||
return <Navigate to={`/property/${id}`} />;
|
||||
}
|
||||
|
||||
// const handleBooking = async () => {
|
||||
// setLoading(true);
|
||||
// const dateFormat = moment(bookingDetails.selectedDate).format("MM/DD/YY");
|
||||
// const user_id = localStorage.getItem("user");
|
||||
|
||||
// try {
|
||||
// const result = await sdk.callRawAPI(
|
||||
// "/v2/api/custom/ergo/booking/POST",
|
||||
// {
|
||||
// booked_unit: 1,
|
||||
// booking_start_time: new Date(dateFormat + " " + bookingDetails.from).toISOString(),
|
||||
// booking_end_time: new Date(dateFormat + " " + bookingDetails.to).toISOString(),
|
||||
// commission_rate: Number(commission),
|
||||
// customer_id: Number(user_id),
|
||||
// duration: getDuration(bookingDetails.from, bookingDetails.to) * 3600,
|
||||
// host_id: bookingDetails.host_id,
|
||||
// payment_method: "0",
|
||||
// payment_status: 0,
|
||||
// property_space_id: Number(id),
|
||||
// status: 0,
|
||||
// num_guests: bookingDetails.num_guests - 1,
|
||||
// tax_rate: Number(tax),
|
||||
// },
|
||||
// "POST",
|
||||
// ctrl.signal,
|
||||
// );
|
||||
// // create booking addons
|
||||
|
||||
// for (const [k, v] of Object.entries(selectedAddons)) {
|
||||
// const property_add_on_id = document.querySelector(`input[name='${k}']`)?.getAttribute("id").replace("cb", "");
|
||||
// if (!property_add_on_id || !v) continue;
|
||||
// sdk.setTable("booking_addons");
|
||||
// await sdk.callRestAPI({ booking_id: result.message, property_add_on_id: Number(property_add_on_id) }, "POST");
|
||||
// }
|
||||
// sendEmailAlert(bookingDetails.host_id, bookingDetails.name, bookingDetails.id);
|
||||
// dispatch({ type: "SET_BOOKING_ID", payload: result.message });
|
||||
// // navigate(`/property/${id}/booking-confirmation`);
|
||||
// navigate(`/account/my-bookings/${result.message}`)
|
||||
|
||||
// } catch (err) {
|
||||
// tokenExpireError(authDispatch, err.message);
|
||||
// if (err.name == "AbortError") {
|
||||
// setLoading(false);
|
||||
// return;
|
||||
// }
|
||||
// await handleBookingErrors(err);
|
||||
// }
|
||||
// setLoading(false);
|
||||
// };
|
||||
|
||||
// async function createPaymentIntent() {
|
||||
// try {
|
||||
// setPaymentMethod(result)
|
||||
// } catch (err) {
|
||||
// tokenExpireError(dispatch, err.message);
|
||||
// globalDispatch({ type: "SHOW_ERROR", payload: { heading: "Failed to create payment intent", message: err.message } });
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
const stripe = useStripe()
|
||||
const [paymentRequest, setPaymentRequest] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (stripe) {
|
||||
const pr = stripe.paymentRequest({
|
||||
country: 'US',
|
||||
currency: 'usd',
|
||||
total: {
|
||||
label: 'Booking total',
|
||||
amount: Number(total_additional_guest_rate + total_rate + addon_cost),
|
||||
},
|
||||
requestPayerName: true,
|
||||
requestPayerEmail: true,
|
||||
});
|
||||
|
||||
pr.canMakePayment().then(result => {
|
||||
if (result) {
|
||||
console.log("result", result)
|
||||
setPaymentRequest(pr);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [stripe])
|
||||
|
||||
if (paymentRequest) {
|
||||
paymentRequest.on('paymentmethod', async (ev) => {
|
||||
const { paymentIntent, error: confirmError } = await stripe.confirmCardPayment(
|
||||
clientSecret,
|
||||
{ payment_method: ev.paymentMethod.id },
|
||||
{ handleActions: false }
|
||||
);
|
||||
|
||||
if (confirmError) {
|
||||
ev.complete('fail');
|
||||
}
|
||||
else {
|
||||
ev.complete('success')
|
||||
if (paymentIntent.status === "requires_action") {
|
||||
const { error } = await stripe.confirmCardPayment(clientSecret);
|
||||
if (error) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Payment failed",
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await fetchBooking(id)
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Payment success",
|
||||
message: "Your payment was successful",
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await fetchBooking(id);
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Payment success",
|
||||
message: "Your payment was successful",
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const makePayment = () => {
|
||||
if (cards.length > 0) {
|
||||
setExistingCardsModal(true)
|
||||
} else {
|
||||
showToast(globalDispatch, "Please add cards in your billing page", 5000, "ERROR")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBookingErrors(err) {
|
||||
switch (err.message) {
|
||||
case BOOKING_ERRORS.ERR_MULTIPLE_BOOKING:
|
||||
setErrMultipleBooking(true);
|
||||
break;
|
||||
default:
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Booking Failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEmailAlert(to, property_name, booking_id) {
|
||||
try {
|
||||
// get receiver preferences
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/get-user", { id: to }, "POST");
|
||||
|
||||
if (parseJsonSafely(result.settings, {}).email_on_space_booked == true) {
|
||||
let customer_name = globalState.user.first_name + " " + globalState.user.last_name;
|
||||
// get email template
|
||||
const tmpl = await sdk.getEmailTemplate("space-booked-alert");
|
||||
const body = tmpl.html?.replace(new RegExp("{{{customer_name}}}", "g"), customer_name)
|
||||
.replace(new RegExp("{{{property_name}}}", "g"), property_name)
|
||||
.replace(new RegExp("{{{booking_id}}}", "g"), booking_id);
|
||||
|
||||
// send email
|
||||
await sdk.sendEmail(result.email, tmpl.subject, body);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const { tax, commission } = useTaxAndCommission();
|
||||
const addons = usePropertyAddons(bookingDetails.property_id);
|
||||
|
||||
const total_rate = bookingDetails.rate * getDuration(bookingDetails.from, bookingDetails.to);
|
||||
const total_additional_guest_rate = bookingDetails.additional_guest_rate * getDuration(bookingDetails.from, bookingDetails.to) * (bookingDetails.num_guests - 1);
|
||||
const addon_cost = Number(
|
||||
Object.entries(selectedAddons)
|
||||
.map(([k, v]) => v)
|
||||
.reduce((acc, price) => {
|
||||
return Number(acc) + (Number(price) ?? 0);
|
||||
}, 0),
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto min-h-screen bg-white px-6 pt-[100px] pb-12 normal-case md:pt-[90px] 2xl:px-16">
|
||||
<button
|
||||
className="mr-2 mb-2 inline-flex items-center py-2.5 pr-5 text-center font-semibold"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<Icon
|
||||
type="arrow"
|
||||
variant="narrow-left"
|
||||
className="h-4 w-4 stroke-[#667085]"
|
||||
/>{" "}
|
||||
<span className="ml-2">Back</span>
|
||||
</button>
|
||||
<div className="flex flex-col items-start justify-between md:flex-row">
|
||||
<div className="w-full md:w-[43%]">
|
||||
<h2 className="mb-[20px] text-3xl font-semibold">Review and payment</h2>
|
||||
<div className="mb-[40px] flex flex-col gap-[24px] md:flex-row">
|
||||
<div
|
||||
className="h-[150px] rounded-lg bg-cover bg-center pr-2 md:w-[204px]"
|
||||
style={{ backgroundImage: `url(${bookingDetails.url ?? "/default-property.jpg"})` }}
|
||||
>
|
||||
<FavoriteButton
|
||||
space_id={bookingDetails.id}
|
||||
user_property_spaces_id={bookingDetails.user_property_spaces_id}
|
||||
reRender={null}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<h3 className="mb-[6px] text-[18px] font-semibold">{bookingDetails.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<div className="flex gap-[10px]">
|
||||
<DateTimeIcon />
|
||||
<h4 className="text-lg font-semibold">Date & time</h4>
|
||||
</div>
|
||||
<button
|
||||
className="text-sm font-semibold text-[#475467] underline"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Date</p>
|
||||
<p className="font-semibold text-[#344054]"> {formatDate(bookingDetails.selectedDate)}</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Time</p>
|
||||
<p className="font-semibold text-[#344054]">
|
||||
{bookingDetails.from} - {bookingDetails.to}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Duration</p>
|
||||
<p className="font-semibold text-[#344054]">{getDuration(bookingDetails.from, bookingDetails.to) + " hours"}</p>
|
||||
</div>
|
||||
<div className="mt-[40px] mb-[16px] flex gap-[10px]">
|
||||
<AddIcon />
|
||||
<h4 className="text-lg font-semibold">Add Ons</h4>
|
||||
</div>
|
||||
{addons.map((addon) => (
|
||||
<AddOnCounter
|
||||
key={addon.id}
|
||||
data={addon}
|
||||
register={register}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full md:w-[40%]">
|
||||
<div className="flex flex-col rounded-md border border-[#33D4B7] p-[20px] md:border-2 md:p-[32px]">
|
||||
<h4 className="text-lg mb-4 font-semibold md:text-2xl">Booking summary</h4>
|
||||
|
||||
<hr className="mb-8" />
|
||||
<div className="tiny-scroll mb-4 max-h-[200px] overflow-y-auto pb-4 pr-4 md:max-h-[250px]">
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Rate</p>
|
||||
<p className="font-semibold text-[#344054]">${bookingDetails.rate.toFixed(2)}/h</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Price</p>
|
||||
<p className="font-semibold text-[#344054]"> ${total_rate.toFixed(2)}</p>
|
||||
</div>
|
||||
{bookingDetails.additional_guest_rate && bookingDetails.num_guests - 1 ? (
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Extra guests</p>
|
||||
<p className="font-semibold text-[#344054]"> ${total_additional_guest_rate.toFixed(2)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.entries(selectedAddons).map(([addon_name, price], idx) => {
|
||||
if (!price) return null;
|
||||
return (
|
||||
<div
|
||||
className="mb-[12px] flex justify-between"
|
||||
key={idx}
|
||||
>
|
||||
<p>{addon_name}</p>
|
||||
<p className="font-semibold text-[#344054]"> ${Number(price).toFixed(2)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Tax</p>
|
||||
<p className="font-semibold text-[#344054]"> ${((((total_additional_guest_rate + total_rate) * (bookingDetails?.tax ?? tax)) / 100)).toFixed(2)}</p>
|
||||
</div>
|
||||
{/* <div className="mb-[12px] flex justify-between">
|
||||
<p>Commission</p>
|
||||
<p className="font-semibold text-[#344054]"> ${(((total_additional_guest_rate + total_rate + addon_cost) * commission) / 100).toFixed(2)}</p>
|
||||
</div> */}
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Total</p>
|
||||
<p className="font-semibold text-[#344054]"> ${(total_additional_guest_rate + total_rate + addon_cost + (((total_additional_guest_rate + total_rate) * (bookingDetails?.tax ?? tax)) / 100)).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* {!(tax == null || commission == null) && (
|
||||
<ReCAPTCHA
|
||||
className="recaptcha-v2 mb-2"
|
||||
sitekey={import.meta.env.VITE_RECAPTCHA_SITE_KEY}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
className={`login-btn-gradient mb-[12px] gap-2 rounded-md border border-[#33D4B7] px-2 text-center tracking-wide text-white outline-none focus:outline-none disabled:border-0 ${loading ? "loading py-2" : "py-3"
|
||||
}`}
|
||||
onClick={() => makePayment()}
|
||||
|
||||
disabled={(tax == null ?? bookingDetails?.tax) || commission == null}
|
||||
>
|
||||
Make Payment
|
||||
</LoadingButton>
|
||||
{
|
||||
!clientSecret && paymentOptions && (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
{/* {() && (
|
||||
<div>
|
||||
<div className="flex justify-center gap-4">
|
||||
{cards.length > 0 && (
|
||||
<button
|
||||
className="rounded-lg border py-2 px-6 ring-2 ring-transparent duration-100 hover:border-transparent hover:ring-primary"
|
||||
onClick={() => setExistingCardsModal(true)}
|
||||
>
|
||||
Use existing cards
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="rounded-lg border py-2 px-6 ring-2 ring-transparent duration-100 hover:border-transparent hover:ring-primary"
|
||||
onClick={() => setNewCardPaymentModal(true)}
|
||||
>
|
||||
Pay with new card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
<p className="text-center text-sm">(funds will be put on hold, pending when host accepts/rejects your booking)</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/help/cancellation-policy"
|
||||
target={"_blank"}
|
||||
className="mt-[12px] block w-full text-center text-sm text-[#667085] underline"
|
||||
>
|
||||
Cancellation Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectExistingCardsModal
|
||||
modalOpen={existingCardsModal}
|
||||
setConfirmPayment={setConfirmPayment}
|
||||
closeModal={() => setExistingCardsModal(false)}
|
||||
cards={cards}
|
||||
bookingData={bookingDetails}
|
||||
selectedAddons={selectedAddons}
|
||||
paying={paying}
|
||||
setloadingBtn={setLoading}
|
||||
setPaying={setPaying}
|
||||
/>
|
||||
|
||||
<MultipleBookingErrorModal
|
||||
modalOpen={errMultipleBooking}
|
||||
closeModal={() => setErrMultipleBooking(false)}
|
||||
spaceId={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingPreviewPage;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { BOOKING_STATUS } from "@/utils/constants";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import React, { Fragment, useEffect, useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function MultipleBookingErrorModal({ modalOpen, closeModal, spaceId }) {
|
||||
const [bookingId, setBookingId] = useState("");
|
||||
|
||||
async function fetchBooking() {
|
||||
const sdk = new MkdSDK();
|
||||
sdk.setTable("booking");
|
||||
try {
|
||||
const result = await sdk.callRestAPI(
|
||||
{ page: 1, limit: 1, payload: { property_space_id: spaceId, customer_id: +localStorage.getItem("user"), status: BOOKING_STATUS.PENDING }, sortId: "id", direction: "DESC" },
|
||||
"PAGINATE",
|
||||
);
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setBookingId(result.list[0].id);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("err", err);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchBooking();
|
||||
}, [spaceId]);
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={modalOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
onClose={closeModal}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-2xl font-medium leading-6 text-gray-900"
|
||||
>
|
||||
This is a duplicate request
|
||||
</Dialog.Title>
|
||||
<div className="my-4">
|
||||
<p className="text-lg text-gray-500">Once your host approves this booking, you will be able to enjoy your reservation! </p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border px-4 py-2 text-sm font-medium focus:outline-none"
|
||||
onClick={closeModal}
|
||||
>
|
||||
OK got it
|
||||
</button>
|
||||
<Link
|
||||
to={"/account/my-bookings/" + bookingId}
|
||||
className={`login-btn-gradient inline-flex justify-center rounded-md py-2 px-4 text-sm font-medium text-white`}
|
||||
>
|
||||
View Status
|
||||
</Link>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { BookingContextProvider } from "./bookingContext";
|
||||
|
||||
const PageWrapper = () => {
|
||||
return (
|
||||
<BookingContextProvider>
|
||||
<div className="bg-white">
|
||||
<Outlet />
|
||||
</div>
|
||||
</BookingContextProvider>
|
||||
);
|
||||
};
|
||||
export default PageWrapper;
|
||||
@@ -0,0 +1,550 @@
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, Navigate, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import FaqAccordion from "@/components/frontend/FaqAccordion";
|
||||
import ReviewCard from "@/components/frontend/ReviewCard";
|
||||
|
||||
import StarIcon from "@/components/frontend/icons/StarIcon";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import DateTimePicker from "@/components/frontend/DateTimePicker";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useBookingContext } from "./bookingContext";
|
||||
import CustomSelect from "@/components/frontend/CustomSelect";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import FavoriteButton from "@/components/frontend/FavoriteButton";
|
||||
import Counter from "@/components/frontend/Counter";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import { usePropertyAddons, usePropertySpace, usePropertySpaceImages, usePublicUserData, usePropertySpaceAmenities, usePropertySpaceFaqs, usePropertySpaceReviews } from "@/hooks/api";
|
||||
import PropertyImageSlider from "@/components/frontend/PropertyImageSlider";
|
||||
import PropertySpaceMapImage from "@/components/frontend/PropertySpaceMapImage";
|
||||
import AllReviewsModal from "@/components/frontend/AllReviewsModal";
|
||||
import { AuthContext } from "@/authContext";
|
||||
import CircleCheckIcon from "@/components/frontend/icons/CircleCheckIcon";
|
||||
|
||||
let sdk = new MkdSDK();
|
||||
|
||||
const PropertyPage = () => {
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
const { state: authState, dispatch: authDispatch } = useContext(AuthContext);
|
||||
const { state: spaceData } = useLocation();
|
||||
const { bookingData, dispatch } = useBookingContext();
|
||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||
const [reviewsPopup, setReviewsPopup] = useState(false);
|
||||
const [fetching, setFetching] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
const { id } = useParams();
|
||||
const bookingDetails = bookingData?.from === "" ? bookingData : JSON.parse(localStorage.getItem("booking_details"));
|
||||
const [reviewDirection, setReviewDirection] = useState("DESC");
|
||||
const [bookedSlots, setBookedSlots] = useState([]);
|
||||
const [scheduleTemplate, setScheduleTemplate] = useState({});
|
||||
const [render, forceRender] = useState(false);
|
||||
const { handleSubmit, register, setValue } = useForm({
|
||||
defaultValues: bookingDetails,
|
||||
});
|
||||
|
||||
const [showCalendar, setShowCalendar] = useState(false);
|
||||
|
||||
const { propertySpace, notFound } = usePropertySpace(id, render);
|
||||
const hostData = usePublicUserData(propertySpace.host_id);
|
||||
const spaceImages = usePropertySpaceImages(propertySpace.id, true, setFetching);
|
||||
const spaceAddons = usePropertyAddons(propertySpace.property_id);
|
||||
const spaceAmenities = usePropertySpaceAmenities(propertySpace.id);
|
||||
const faqs = usePropertySpaceFaqs(propertySpace.id);
|
||||
const reviews = usePropertySpaceReviews(propertySpace.id);
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const { pathname } = useLocation();
|
||||
|
||||
if (!fetching && spaceImages.length === 0) {
|
||||
navigate("*")
|
||||
}
|
||||
|
||||
async function fetchBookedSlots(id) {
|
||||
try {
|
||||
const result = await callCustomAPI("customer/schedule", "post", { property_spaces_id: id }, "", null, "v3");
|
||||
if (Array.isArray(result.list)) {
|
||||
setBookedSlots(result.list);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScheduleTemplate(id) {
|
||||
try {
|
||||
const result = await callCustomAPI(
|
||||
"property_spaces_schedule_template",
|
||||
"post",
|
||||
{
|
||||
page: 1,
|
||||
limit: 1,
|
||||
where: [`property_spaces_id = ${id}`],
|
||||
},
|
||||
"PAGINATE",
|
||||
);
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setScheduleTemplate({ custom_slots: result.list[0].custom_slots });
|
||||
|
||||
}
|
||||
if (result.list[0]?.schedule_template_id) {
|
||||
const templateResult = await callCustomAPI(
|
||||
"schedule_template",
|
||||
"post",
|
||||
{
|
||||
page: 1,
|
||||
limit: 1,
|
||||
where: [`id = ${result.list[0].schedule_template_id}`],
|
||||
},
|
||||
"PAGINATE",
|
||||
);
|
||||
if (Array.isArray(templateResult.list) && (templateResult.list[0] ?? {})) {
|
||||
setScheduleTemplate((prev) => {
|
||||
let updated = { ...prev, ...templateResult.list[0] };
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function switchToCustomer() {
|
||||
authDispatch({ type: "SWITCH_TO_CUSTOMER" });
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Success",
|
||||
message: `You are now signed in as a customer`,
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (!authState.isAuthenticated) {
|
||||
navigate(`/login?redirect_uri=${pathname}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (authState.user == propertySpace.host_id) {
|
||||
globalDispatch({ type: "SHOW_ERROR", payload: { heading: "error", message: "Owners can't book their own spaces" } })
|
||||
return
|
||||
}
|
||||
|
||||
if (globalState.user.verificationStatus != 1) {
|
||||
globalDispatch({ type: "OPEN_NOT_VERIFIED_MODAL" });
|
||||
return;
|
||||
}
|
||||
dispatch({ type: "SET_BOOKING_DETAILS", payload: { ...data, ...propertySpace } });
|
||||
navigate("booking-preview");
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isNaN(id)) return;
|
||||
fetchBookedSlots(id);
|
||||
fetchScheduleTemplate(id);
|
||||
|
||||
// if (spaceImages.length === 0) {
|
||||
// navigate("*")
|
||||
// }
|
||||
}, []);
|
||||
|
||||
const sortByPostDate = (a, b) => {
|
||||
if (reviewDirection == "DESC") {
|
||||
return new Date(b.post_date) - new Date(a.post_date);
|
||||
}
|
||||
return new Date(a.post_date) - new Date(b.post_date);
|
||||
};
|
||||
|
||||
if (notFound || isNaN(id)) return <Navigate to="/not-found" />;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="container mx-auto min-h-screen pt-[140px] text-sm normal-case md:text-base 2xl:px-16"
|
||||
onClick={() => {
|
||||
setShowCalendar(false);
|
||||
}}
|
||||
>
|
||||
<div className="mb-[18px] flex flex-col items-start justify-between px-[17px] md:flex-row md:items-center md:px-0">
|
||||
<div className="flex flex-col items-start gap-4 normal-case md:flex-row md:items-center">
|
||||
<h2 className="text-3xl font-semibold">{propertySpace.name ?? spaceData?.name}</h2>
|
||||
<button
|
||||
className="whitespace-nowrap text-sm underline"
|
||||
target="_blank"
|
||||
onClick={() => setShowMap(true)}
|
||||
>
|
||||
(view on map)
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-[19px] flex w-full justify-center gap-4 md:mt-0 md:w-[unset]">
|
||||
<p className="flex flex-grow items-center justify-center gap-2 rounded-sm border bg-[#F3F9F7] px-[14px] py-[10px]">
|
||||
<StarIcon />
|
||||
<strong className="font-semibold">
|
||||
{(Number(propertySpace.average_space_rating ?? spaceData?.average_space_rating) || 0).toFixed(1)}
|
||||
<span className="font-normal">({propertySpace.space_rating_count ?? spaceData?.space_rating_count})</span>
|
||||
</strong>
|
||||
</p>
|
||||
<div className="flex flex-grow items-center justify-center gap-2 rounded-sm border bg-[#F3F9F7] px-[14px] py-[10px]">
|
||||
<FavoriteButton
|
||||
space_id={propertySpace.id}
|
||||
user_property_spaces_id={propertySpace.user_property_spaces_id}
|
||||
reRender={forceRender}
|
||||
withLoader={true}
|
||||
className="-mb-1"
|
||||
buttonClassName=""
|
||||
stroke="#344054"
|
||||
favColor={"black"}
|
||||
/>
|
||||
<span>Save</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="snap-scroll relative mb-[66px] flex h-[381px] gap-[32px] px-[14px] md:px-0">
|
||||
{spaceImages[0]?.photo_url &&
|
||||
<img
|
||||
src={spaceImages[0]?.photo_url}
|
||||
className="h-full rounded-lg object-cover xl:min-w-[616px]"
|
||||
/>
|
||||
}
|
||||
{spaceImages[1]?.photo_url &&
|
||||
<img
|
||||
src={spaceImages[1]?.photo_url}
|
||||
className="h-full w-[292px] rounded-lg object-cover"
|
||||
/>
|
||||
}
|
||||
<div className={`${((!spaceImages[3]?.photo_url)) ? "flex min-w-[550px] flex-col" : "block"} "gap-4 overflow-hidden md:gap-[32px]"`}>
|
||||
{spaceImages[2]?.photo_url &&
|
||||
<img
|
||||
src={spaceImages[2]?.photo_url}
|
||||
className={`${spaceImages[3]?.photo_url && "h-1/2"} "rounded-lg object-cover md:w-full"`}
|
||||
/>
|
||||
}
|
||||
{spaceImages[3]?.photo_url &&
|
||||
<img
|
||||
src={spaceImages[3]?.photo_url}
|
||||
className="h-1/2 rounded-lg object-cover md:w-full"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
{spaceImages[4]?.photo_url &&
|
||||
<img
|
||||
src={spaceImages[4]?.photo_url}
|
||||
className="h-full w-[292px] rounded-lg object-cover"
|
||||
/>
|
||||
}
|
||||
<button
|
||||
className="sticky right-6 mb-[8px] min-w-[170px] self-end border bg-[#00000080] px-3 py-1 text-center text-sm text-white"
|
||||
onClick={() => setGalleryOpen(true)}
|
||||
>
|
||||
View all photos ({spaceImages.length})
|
||||
</button>
|
||||
</div>
|
||||
<section className="relative flex flex-col items-start xl:flex-row xl:gap-12 lg:w-[90%] w-full mx-auto">
|
||||
<div className="w-full md:px-0 xl:w-3/5 px-10">
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">Description</h3>
|
||||
<p className="">{propertySpace.description ?? spaceData?.description}</p>
|
||||
<hr className="my-[32px] md:my-[47px]" />
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">Amenities</h3>
|
||||
<ul className="addons-grid list-disk-important">
|
||||
{spaceAmenities.map((am, idx) => (
|
||||
<li
|
||||
className="flex w-fit items-center gap-2 mb-4 sm:mb-0"
|
||||
key={idx}
|
||||
>
|
||||
<CircleCheckIcon />
|
||||
{am.amenity_name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<hr className="my-[32px] md:my-[47px]" />
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">Add ons</h3>
|
||||
<ul className="addons-grid list-disk-important">
|
||||
{spaceAddons.map((addon) => (
|
||||
<li
|
||||
className="flex w-fit sm:w-full items-center gap-2 mb-4 sm:mb-0"
|
||||
key={addon.id}
|
||||
>
|
||||
<span className="w-fit">
|
||||
{" "}
|
||||
<div className="flex gap-4">
|
||||
<CircleCheckIcon /> {addon.add_on_name}
|
||||
</div>{" "}
|
||||
</span>{" "}
|
||||
<strong className="font-semibold">${addon.cost}/h</strong>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<hr className="my-[32px] md:my-[47px]" />
|
||||
<div className="mb-[28px] flex flex-wrap items-center justify-between">
|
||||
<h3 className="mb-2 text-xl font-semibold md:mb-0 md:text-2xl">About the host</h3>
|
||||
{(authState.role == "customer" && propertySpace?.id) && (
|
||||
<Link
|
||||
to={`/account/messages?other_user_id=${propertySpace.host_id}&space=${propertySpace.id}`}
|
||||
className="my-text-gradient hidden w-[178px] whitespace-nowrap rounded-md border border-[#33D4B7] px-6 py-2 text-center font-semibold md:inline"
|
||||
id="contact-host"
|
||||
>
|
||||
Contact the host
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-4 md:justify-start md:gap-[24px]">
|
||||
<div className="w-max-content">
|
||||
<img
|
||||
src={hostData.photo ?? "/default.png"}
|
||||
className="h-[72px] w-[72px] rounded-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3 w-[90%]">
|
||||
<p className="hidden text-xl font-bold md:block">{propertySpace.first_name}</p>
|
||||
<p className="hidden md:block">{propertySpace.about ?? spaceData?.about}</p>
|
||||
</div>
|
||||
{(authState.role == "customer" && propertySpace?.id) && (
|
||||
<Link
|
||||
to={`/account/messages?other_user_id=${propertySpace.host_id}&space=${propertySpace.id}`}
|
||||
className="my-text-gradient inline whitespace-nowrap rounded-md border border-[#33D4B7] px-4 py-1 text-center text-sm font-semibold md:hidden"
|
||||
id="contact-host"
|
||||
>
|
||||
Contact the host
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-4 block md:hidden">{propertySpace.about ?? spaceData?.about}</p>
|
||||
|
||||
<hr className="my-[32px] md:my-[47px]" />
|
||||
<div className="mb-[18px] flex items-center justify-between">
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">Reviews</h3>
|
||||
<CustomSelect
|
||||
options={[
|
||||
{ label: "By Date: Newest First", value: "DESC" },
|
||||
{ label: "By Date: Oldest First", value: "ASC" },
|
||||
]}
|
||||
onChange={setReviewDirection}
|
||||
accessor="label"
|
||||
valueAccessor="value"
|
||||
className="min-w-[200px]"
|
||||
listOptionClassName={"pl-4"}
|
||||
/>
|
||||
</div>
|
||||
<section>
|
||||
{reviews.length == 0 && <p>No reviews yet</p>}
|
||||
{reviews
|
||||
.sort(sortByPostDate)
|
||||
.slice(0, 10)
|
||||
.map((rw) => (
|
||||
<ReviewCard
|
||||
key={rw.id}
|
||||
data={rw}
|
||||
/>
|
||||
))}
|
||||
<div className="text-center">
|
||||
{reviews.length > 10 ? (
|
||||
<button
|
||||
className="font-semibold underline"
|
||||
onClick={() => setReviewsPopup(true)}
|
||||
>
|
||||
View more ({reviews.length})
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<hr className="my-[32px] md:my-[47px]" />
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">FAQs</h3>
|
||||
{faqs.map((faq) => (
|
||||
<FaqAccordion
|
||||
key={faq.id}
|
||||
data={faq}
|
||||
/>
|
||||
))}
|
||||
<hr className="my-[32px] md:my-[47px]" />
|
||||
<h3 className="mb-4 text-2xl font-semibold">Property rules</h3>
|
||||
<p className="mb-32">{propertySpace.rule ?? spaceData?.rule}</p>
|
||||
</div>
|
||||
<div className="sticky bottom-0 hidden w-full flex-grow bg-white xl:top-16 xl:bottom-[unset] xl:block xl:w-[unset] ml-24">
|
||||
<div className="sticky-price-summary mx-auto max-w-2xl p-6 md:border-2">
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">Price and availability</h3>
|
||||
<div className="mb-[13px] flex justify-between">
|
||||
<span className="text-lg">Max capacity</span>
|
||||
<span>
|
||||
{" "}
|
||||
<strong className="font-semibold">{propertySpace.max_capacity ?? spaceData?.max_capacity}</strong> people
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[13px] flex justify-between">
|
||||
<span className="text-lg">Pricing from</span>
|
||||
<span>
|
||||
from: <strong className="font-semibold">${propertySpace.rate ?? spaceData?.rate}</strong>/h
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="mb-[13px] flex items-center justify-between">
|
||||
<span className="text-lg">Number of guests</span>
|
||||
<Counter
|
||||
register={register}
|
||||
name="num_guests"
|
||||
setValue={setValue}
|
||||
initialValue={bookingDetails.num_guests || 1}
|
||||
maxCount={propertySpace.max_capacity ?? spaceData?.max_capacity}
|
||||
minCount={1}
|
||||
/>
|
||||
</div>
|
||||
<hr className="mb-[24px] hidden md:block" />
|
||||
<div className="z-50 mb-3">
|
||||
<DateTimePicker
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
fieldNames={["selectedDate", "from", "to"]}
|
||||
showCalendar={showCalendar}
|
||||
setShowCalendar={setShowCalendar}
|
||||
fromDefault={bookingDetails.from}
|
||||
toDefault={bookingDetails.to}
|
||||
bookedSlots={bookedSlots.map((slot) => ({ fromTime: new Date(slot.start_time), toTime: new Date(slot.end_time) }))}
|
||||
scheduleTemplate={scheduleTemplate}
|
||||
defaultDate={bookingDetails.selectedDate || undefined}
|
||||
/>
|
||||
</div>
|
||||
{authState.role != "customer" && authState.isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToCustomer}
|
||||
className="login-btn-gradient gap-2 rounded-sm py-3 px-2 text-center tracking-wide text-white outline-none focus:outline-none"
|
||||
>
|
||||
Join as customer to book
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
id="proceed-to-preview"
|
||||
className="login-btn-gradient gap-2 rounded-sm py-3 px-2 text-center tracking-wide text-white outline-none focus:outline-none"
|
||||
disabled={(() => {
|
||||
const el = document.getElementById("booking-time");
|
||||
return !(el && !el.innerText.includes("Select"));
|
||||
})()}
|
||||
>
|
||||
{window.innerWidth > 500 ? "Continue" : "Continue to checkout"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto -mt-16 block w-full max-w-xl p-6 xl:hidden">
|
||||
<h3 className="mb-[8px] text-2xl font-semibold">Price and availability</h3>
|
||||
<div className="mb-[13px] flex justify-between">
|
||||
<span className="text-lg">Max capacity</span>
|
||||
<span>
|
||||
{" "}
|
||||
<strong className="font-semibold">{propertySpace.max_capacity ?? spaceData?.max_capacity}</strong> people
|
||||
</span>
|
||||
</div>
|
||||
<div className="mb-[13px] flex justify-between">
|
||||
<span className="text-lg">Pricing from</span>
|
||||
<span>
|
||||
from: <strong className="font-semibold">${propertySpace.rate ?? spaceData?.rate}</strong>/h
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="flex flex-col"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<div className="mb-[13px] flex items-center justify-between">
|
||||
<span className="text-lg">Number of guests</span>
|
||||
<Counter
|
||||
register={register}
|
||||
name="num_guests"
|
||||
setValue={setValue}
|
||||
initialValue={bookingDetails.num_guests || 1}
|
||||
maxCount={propertySpace.max_capacity ?? spaceData?.max_capacity}
|
||||
minCount={1}
|
||||
/>
|
||||
</div>
|
||||
<hr className="mb-[24px] hidden md:block" />
|
||||
<div className="z-50 mb-3">
|
||||
<DateTimePicker
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
fieldNames={["selectedDate", "from", "to"]}
|
||||
showCalendar={showCalendar}
|
||||
setShowCalendar={setShowCalendar}
|
||||
fromDefault={bookingDetails.from}
|
||||
toDefault={bookingDetails.to}
|
||||
bookedSlots={bookedSlots.map((slot) => ({ fromTime: new Date(slot.start_time), toTime: new Date(slot.end_time) }))}
|
||||
scheduleTemplate={scheduleTemplate}
|
||||
/>
|
||||
</div>
|
||||
{authState.role != "customer" && authState.isAuthenticated ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToCustomer}
|
||||
className="login-btn-gradient gap-2 rounded-sm py-3 px-2 text-center tracking-wide text-white outline-none focus:outline-none"
|
||||
>
|
||||
Join as customer to book
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="submit"
|
||||
id="proceed-to-preview"
|
||||
className="login-btn-gradient gap-2 rounded-sm py-3 px-2 text-center tracking-wide text-white outline-none focus:outline-none"
|
||||
disabled={(() => {
|
||||
const els = document.querySelectorAll("#booking-time");
|
||||
return Array.from(els).every((el) => el.innerText.includes("Select"));
|
||||
})()}
|
||||
>
|
||||
{window.innerWidth > 500 && "Continue"}
|
||||
{window.innerWidth < 500 && "Continue to checkout"}
|
||||
</button>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<PropertyImageSlider
|
||||
spaceImages={spaceImages}
|
||||
modalOpen={galleryOpen}
|
||||
closeModal={() => setGalleryOpen(false)}
|
||||
/>
|
||||
<AllReviewsModal
|
||||
modalOpen={reviewsPopup}
|
||||
closeModal={() => setReviewsPopup(false)}
|
||||
reviews={reviews}
|
||||
onDirectionChange={setReviewDirection}
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId="proceed-to-preview"
|
||||
place="bottom"
|
||||
content={"Proceed to book"}
|
||||
noArrow
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId="contact-host"
|
||||
place="bottom"
|
||||
content={"Chat with Host"}
|
||||
noArrow
|
||||
/>
|
||||
<PropertySpaceMapImage
|
||||
modalImage={`https://maps.googleapis.com/maps/api/staticmap?center=${propertySpace.address_line_1 ?? ""},${propertySpace.address_line_2 ?? ""},${propertySpace.city ?? ""},${propertySpace.country ?? ""
|
||||
}&zoom=15&size=600x400&maptype=roadmap&markers=color:red|${propertySpace.address_line_1 ?? ""},${propertySpace.address_line_2 ?? ""}
|
||||
&key=${import.meta.env.VITE_GOOGLE_API_KEY}`}
|
||||
modalOpen={showMap}
|
||||
closeModal={() => setShowMap(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PropertyPage;
|
||||
@@ -0,0 +1,39 @@
|
||||
import React, { createContext, useContext, useReducer } from "react";
|
||||
|
||||
const initialBookingData = {
|
||||
from: "",
|
||||
to: "",
|
||||
selectedDate: "",
|
||||
num_guests: 0,
|
||||
};
|
||||
|
||||
|
||||
// localStorage.setItem("booking_details", JSON.stringify(initialBookingData));
|
||||
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "SET_BOOKING_DETAILS":
|
||||
localStorage.setItem("booking_details", JSON.stringify(action.payload));
|
||||
localStorage.setItem("booking_id", action.payload.id);
|
||||
return { ...state, ...action.payload };
|
||||
case "SET_BOOKING_ID":
|
||||
return { ...state, id: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// create context here
|
||||
const bookingContext = createContext({});
|
||||
|
||||
// wrap this component around App.tsx to get access to userData in all components
|
||||
const BookingContextProvider = ({ children }) => {
|
||||
const [bookingData, dispatch] = useReducer(reducer, initialBookingData);
|
||||
|
||||
return <bookingContext.Provider value={{ bookingData, dispatch }}>{children}</bookingContext.Provider>;
|
||||
};
|
||||
|
||||
// use this custom hook to get the data in any component in component tree
|
||||
const useBookingContext = () => useContext(bookingContext);
|
||||
|
||||
export { useBookingContext, BookingContextProvider };
|
||||
@@ -0,0 +1,441 @@
|
||||
import { useStripe } from "@stripe/react-stripe-js";
|
||||
import moment from "moment";
|
||||
import React, { useState, useContext, useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Navigate, useNavigate, useParams } from "react-router";
|
||||
import AddIcon from "@/components/frontend/icons/AddIcon";
|
||||
import DateTimeIcon from "@/components/frontend/icons/DateTimeIcon";
|
||||
import Icon from "@/components/Icons";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { useBookingContext } from "./bookingContext";
|
||||
import { formatDate, getDuration } from "@/utils/date-time-utils";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import { FavoriteButton, LoadingButton, AddOnCounter } from "@/components/frontend";
|
||||
import { usePropertyAddons, useTaxAndCommission, useCards } from "@/hooks/api";
|
||||
import { Link } from "react-router-dom";
|
||||
import { parseJsonSafely, sleep } from "@/utils/utils";
|
||||
import MultipleBookingErrorModal from "./MultipleBookingErrorModal";
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import { loadStripe } from "@stripe/stripe-js";
|
||||
import SelectExistingCardsModal from "@/pages/Customer/Bookings/SelectExistingCardsModal";
|
||||
|
||||
|
||||
|
||||
const cardIcons = {
|
||||
MasterCard: "/mastercard.jpg",
|
||||
Visa: "/visa.jpg",
|
||||
"American Express": "/american-express.png",
|
||||
Discover: "/discover.png",
|
||||
};
|
||||
const sdk = new MkdSDK();
|
||||
const ctrl = new AbortController();
|
||||
const BOOKING_ERRORS = {
|
||||
ERR_MULTIPLE_BOOKING: "You already have a pending booking for this slot!",
|
||||
};
|
||||
|
||||
const BookingPreviewPage = () => {
|
||||
const { bookingData, dispatch } = useBookingContext();
|
||||
const { dispatch: authDispatch } = useContext(AuthContext);
|
||||
const { id } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const [paymentOptions, setPaymentOptions] = useState(false);
|
||||
|
||||
const { register, watch } = useForm();
|
||||
const selectedAddons = watch();
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { cards } = useCards({ loader: false });
|
||||
const [errMultipleBooking, setErrMultipleBooking] = useState(false);
|
||||
const [existingCardsModal, setExistingCardsModal] = useState(false);
|
||||
const [paymentMethod, setPaymentMethod] = useState();
|
||||
const [confirmPayment, setConfirmPayment] = useState(false);
|
||||
const [clientSecret, setClientSecret] = useState(undefined);
|
||||
|
||||
|
||||
const stripePromise = loadStripe(import.meta.env.VITE_REACT_STRIPE_PUBLIC_KEY);
|
||||
|
||||
|
||||
const handleBooking = async () => {
|
||||
setLoading(true);
|
||||
const dateFormat = moment(bookingData.selectedDate).format("MM/DD/YY");
|
||||
const user_id = localStorage.getItem("user");
|
||||
|
||||
try {
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/booking/POST",
|
||||
{
|
||||
booked_unit: 1,
|
||||
booking_start_time: new Date(dateFormat + " " + bookingData.from).toISOString(),
|
||||
booking_end_time: new Date(dateFormat + " " + bookingData.to).toISOString(),
|
||||
commission_rate: Number(commission),
|
||||
customer_id: Number(user_id),
|
||||
duration: getDuration(bookingData.from, bookingData.to) * 3600,
|
||||
host_id: bookingData.host_id,
|
||||
payment_method: "0",
|
||||
payment_status: 0,
|
||||
property_space_id: Number(id),
|
||||
status: 0,
|
||||
num_guests: bookingData.num_guests - 1,
|
||||
tax_rate: Number(tax),
|
||||
},
|
||||
"POST",
|
||||
ctrl.signal,
|
||||
);
|
||||
// create booking addons
|
||||
|
||||
for (const [k, v] of Object.entries(selectedAddons)) {
|
||||
const property_add_on_id = document.querySelector(`input[name='${k}']`)?.getAttribute("id").replace("cb", "");
|
||||
if (!property_add_on_id || !v) continue;
|
||||
sdk.setTable("booking_addons");
|
||||
await sdk.callRestAPI({ booking_id: result.message, property_add_on_id: Number(property_add_on_id) }, "POST");
|
||||
}
|
||||
sendEmailAlert(bookingData.host_id, bookingData.name, bookingData.id);
|
||||
dispatch({ type: "SET_BOOKING_ID", payload: result.message });
|
||||
// navigate(`/property/${id}/booking-confirmation`);
|
||||
navigate(`/account/my-bookings/${result.message}`)
|
||||
|
||||
} catch (err) {
|
||||
tokenExpireError(authDispatch, err.message);
|
||||
if (err.name == "AbortError") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
await handleBookingErrors(err);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
async function createPaymentIntent() {
|
||||
try {
|
||||
setPaymentMethod(result)
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({ type: "SHOW_ERROR", payload: { heading: "Failed to create payment intent", message: err.message } });
|
||||
}
|
||||
}
|
||||
|
||||
const stripe = useStripe()
|
||||
const [paymentRequest, setPaymentRequest] = useState(null)
|
||||
const { tax, commission } = useTaxAndCommission();
|
||||
const addons = usePropertyAddons(bookingData.property_id);
|
||||
|
||||
const total_rate = bookingData.rate * getDuration(bookingData.from, bookingData.to);
|
||||
const total_additional_guest_rate = bookingData.additional_guest_rate * getDuration(bookingData.from, bookingData.to) * (bookingData.num_guests - 1);
|
||||
const addon_cost = Number(
|
||||
Object.entries(selectedAddons)
|
||||
.map(([k, v]) => v)
|
||||
.reduce((acc, price) => {
|
||||
return Number(acc) + (Number(price) ?? 0);
|
||||
}, 0),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
if (stripe) {
|
||||
const pr = stripe.paymentRequest({
|
||||
country: 'US',
|
||||
currency: 'usd',
|
||||
total: {
|
||||
label: 'Booking total',
|
||||
amount: parseFloat(((total_additional_guest_rate + total_rate + addon_cost) + (((total_additional_guest_rate + total_rate + addon_cost) * (bookingData?.tax ?? tax)) / 100) + (((total_additional_guest_rate + total_rate + addon_cost) * commission) / 100)).toString().slice(0, -1)),
|
||||
},
|
||||
requestPayerName: true,
|
||||
requestPayerEmail: true,
|
||||
});
|
||||
pr.canMakePayment().then(result => {
|
||||
if (result) {
|
||||
console.log("result", result)
|
||||
setPaymentRequest(pr);
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
}, [stripe])
|
||||
|
||||
if (paymentRequest) {
|
||||
paymentRequest.on('paymentmethod', async (ev) => {
|
||||
const { paymentIntent, error: confirmError } = await stripe.confirmCardPayment(
|
||||
clientSecret,
|
||||
{ payment_method: ev.paymentMethod.id },
|
||||
{ handleActions: false }
|
||||
);
|
||||
|
||||
if (confirmError) {
|
||||
ev.complete('fail');
|
||||
}
|
||||
else {
|
||||
ev.complete('success')
|
||||
if (paymentIntent.status === "requires_action") {
|
||||
const { error } = await stripe.confirmCardPayment(clientSecret);
|
||||
if (error) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Payment failed",
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await fetchBooking(id)
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Payment success",
|
||||
message: "Your payment was successful",
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await fetchBooking(id);
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Payment success",
|
||||
message: "Your payment was successful",
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const makePayment = () => {
|
||||
if (cards.length > 0) {
|
||||
setExistingCardsModal(true)
|
||||
} else {
|
||||
showToast(globalDispatch, "Please add cards in your billing page", 5000, "ERROR")
|
||||
}
|
||||
}
|
||||
|
||||
async function handleBookingErrors(err) {
|
||||
switch (err.message) {
|
||||
case BOOKING_ERRORS.ERR_MULTIPLE_BOOKING:
|
||||
setErrMultipleBooking(true);
|
||||
break;
|
||||
default:
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Booking Failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendEmailAlert(to, property_name, booking_id) {
|
||||
try {
|
||||
// get receiver preferences
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/get-user", { id: to }, "POST");
|
||||
|
||||
if (parseJsonSafely(result.settings, {}).email_on_space_booked == true) {
|
||||
let customer_name = globalState.user.first_name + " " + globalState.user.last_name;
|
||||
// get email template
|
||||
const tmpl = await sdk.getEmailTemplate("space-booked-alert");
|
||||
const body = tmpl.html?.replace(new RegExp("{{{customer_name}}}", "g"), customer_name)
|
||||
.replace(new RegExp("{{{property_name}}}", "g"), property_name)
|
||||
.replace(new RegExp("{{{booking_id}}}", "g"), booking_id);
|
||||
|
||||
// send email
|
||||
await sdk.sendEmail(result.email, tmpl.subject, body);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
}
|
||||
}
|
||||
|
||||
if (id != bookingData.id) {
|
||||
return <Navigate to={`/property/${id}`} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto min-h-screen bg-white px-6 pt-[100px] normal-case md:pt-[90px] 2xl:px-16">
|
||||
<button
|
||||
className="mr-2 mb-2 inline-flex items-center py-2.5 pr-5 text-center font-semibold"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
<Icon
|
||||
type="arrow"
|
||||
variant="narrow-left"
|
||||
className="h-4 w-4 stroke-[#667085]"
|
||||
/>{" "}
|
||||
<span className="ml-2">Back</span>
|
||||
</button>
|
||||
<div className="flex flex-col items-start justify-between md:flex-row">
|
||||
<div className="w-full md:w-[43%]">
|
||||
<h2 className="mb-[20px] text-3xl font-semibold">Review and payment</h2>
|
||||
<div className="mb-[40px] flex flex-col gap-[24px] md:flex-row">
|
||||
<div
|
||||
className="h-[150px] rounded-lg bg-cover bg-center pr-2 md:w-[204px]"
|
||||
style={{ backgroundImage: `url(${bookingData.url ?? "/default-property.jpg"})` }}
|
||||
>
|
||||
<FavoriteButton
|
||||
space_id={bookingData.id}
|
||||
user_property_spaces_id={bookingData.user_property_spaces_id}
|
||||
reRender={null}
|
||||
/>
|
||||
</div>
|
||||
<div className="">
|
||||
<h3 className="mb-[6px] text-[18px] font-semibold">{bookingData.name}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<div className="flex gap-[10px]">
|
||||
<DateTimeIcon />
|
||||
<h4 className="text-lg font-semibold">Date & time</h4>
|
||||
</div>
|
||||
<button
|
||||
className="text-sm font-semibold text-[#475467] underline"
|
||||
onClick={() => navigate(-1)}
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Date</p>
|
||||
<p className="font-semibold text-[#344054]"> {formatDate(bookingData.selectedDate)}</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Time</p>
|
||||
<p className="font-semibold text-[#344054]">
|
||||
{bookingData.from} - {bookingData.to}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Duration</p>
|
||||
<p className="font-semibold text-[#344054]">{getDuration(bookingData.from, bookingData.to) + " hours"}</p>
|
||||
</div>
|
||||
<div className="mt-[40px] mb-[16px] flex gap-[10px]">
|
||||
<AddIcon />
|
||||
<h4 className="text-lg font-semibold">Add Ons</h4>
|
||||
</div>
|
||||
{addons.map((addon) => (
|
||||
<AddOnCounter
|
||||
key={addon.id}
|
||||
data={addon}
|
||||
register={register}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="w-full md:w-[40%]">
|
||||
<div className="flex flex-col rounded-md border border-[#33D4B7] p-[20px] md:border-2 md:p-[32px]">
|
||||
<h4 className="text-lg mb-4 font-semibold md:text-2xl">Booking summary</h4>
|
||||
|
||||
<hr className="mb-8" />
|
||||
<div className="tiny-scroll mb-4 max-h-[200px] overflow-y-auto pb-4 pr-4 md:max-h-[250px]">
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Rate</p>
|
||||
<p className="font-semibold text-[#344054]">${bookingData.rate.toFixed(2)}/h</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Price</p>
|
||||
<p className="font-semibold text-[#344054]"> ${total_rate.toFixed(2)}</p>
|
||||
</div>
|
||||
{bookingData.additional_guest_rate && bookingData.num_guests - 1 ? (
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Extra guests</p>
|
||||
<p className="font-semibold text-[#344054]"> ${total_additional_guest_rate.toFixed(2)}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{Object.entries(selectedAddons).map(([addon_name, price], idx) => {
|
||||
if (!price) return null;
|
||||
return (
|
||||
<div
|
||||
className="mb-[12px] flex justify-between"
|
||||
key={idx}
|
||||
>
|
||||
<p>{addon_name}</p>
|
||||
<p className="font-semibold text-[#344054]"> ${Number(price).toFixed(2)}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Tax</p>
|
||||
<p className="font-semibold text-[#344054]"> ${(((total_additional_guest_rate + total_rate + addon_cost) * (bookingData?.tax ?? tax)) / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Commission</p>
|
||||
<p className="font-semibold text-[#344054]"> ${(((total_additional_guest_rate + total_rate + addon_cost) * commission) / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
<div className="mb-[12px] flex justify-between">
|
||||
<p>Total</p>
|
||||
<p className="font-semibold text-[#344054]"> ${((total_additional_guest_rate + total_rate + addon_cost) + (((total_additional_guest_rate + total_rate + addon_cost) * (bookingData?.tax ?? tax)) / 100) + (((total_additional_guest_rate + total_rate + addon_cost) * commission) / 100)).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* {!(tax == null || commission == null) && (
|
||||
<ReCAPTCHA
|
||||
className="recaptcha-v2 mb-2"
|
||||
sitekey={import.meta.env.VITE_RECAPTCHA_SITE_KEY}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)} */}
|
||||
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
className={`login-btn-gradient mb-[12px] gap-2 rounded-md border border-[#33D4B7] px-2 text-center tracking-wide text-white outline-none focus:outline-none disabled:border-0 ${loading ? "loading py-2" : "py-3"
|
||||
}`}
|
||||
onClick={() => makePayment()}
|
||||
|
||||
disabled={(tax === null ?? bookingData?.tax) || commission === null}
|
||||
>
|
||||
Make Payment
|
||||
</LoadingButton>
|
||||
{
|
||||
!clientSecret && paymentOptions && (
|
||||
<p>Loading...</p>
|
||||
)
|
||||
}
|
||||
{/* {() && (
|
||||
<div>
|
||||
<div className="flex justify-center gap-4">
|
||||
{cards.length > 0 && (
|
||||
<button
|
||||
className="rounded-lg border py-2 px-6 ring-2 ring-transparent duration-100 hover:border-transparent hover:ring-primary"
|
||||
onClick={() => setExistingCardsModal(true)}
|
||||
>
|
||||
Use existing cards
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
className="rounded-lg border py-2 px-6 ring-2 ring-transparent duration-100 hover:border-transparent hover:ring-primary"
|
||||
onClick={() => setNewCardPaymentModal(true)}
|
||||
>
|
||||
Pay with new card
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)} */}
|
||||
<p className="text-center text-sm">(funds will be put on hold, pending when host accepts/rejects your booking)</p>
|
||||
</div>
|
||||
<Link
|
||||
to="/help/cancellation-policy"
|
||||
target={"_blank"}
|
||||
className="mt-[12px] block w-full text-center text-sm text-[#667085] underline"
|
||||
>
|
||||
Cancellation Policy
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SelectExistingCardsModal
|
||||
modalOpen={existingCardsModal}
|
||||
setConfirmPayment={setConfirmPayment}
|
||||
closeModal={() => setExistingCardsModal(false)}
|
||||
cards={cards}
|
||||
bookingData={bookingData}
|
||||
selectedAddons={selectedAddons}
|
||||
/>
|
||||
|
||||
<MultipleBookingErrorModal
|
||||
modalOpen={errMultipleBooking}
|
||||
closeModal={() => setErrMultipleBooking(false)}
|
||||
spaceId={id}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BookingPreviewPage;
|
||||
@@ -0,0 +1,48 @@
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import React, { useState } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function CancellationPolicyPage() {
|
||||
const [content, setContent] = useState("");
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
async function fetchCancellationPolicy() {
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
const sdk = new MkdSDK();
|
||||
sdk.setTable("cms");
|
||||
try {
|
||||
const result = await callCustomAPI("cms", "post", { payload: { content_key: "cancellation_policy" }, limit: 1000, page: 1 }, "PAGINATE");
|
||||
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setContent(result.list.find((stg) => stg.content_key == "cancellation_policy")?.content_value);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Cannot get Cancellation policy",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchCancellationPolicy();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-[120px] min-h-screen normal-case text-sm">
|
||||
<div className="container mx-auto 2xl:px-32 px-4">
|
||||
<article
|
||||
className="sun-editor-editable"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
></article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
export default function CheckDeleteEmailPage() {
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.clear();
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<h1 className="text-4xl">You have requested to delete your account. Please check your email to confirm this operation</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
export default function ConfirmDeletePage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
const [pageText, setPageText] = useState("Deleting your account");
|
||||
|
||||
async function deleteAccount() {
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
try {
|
||||
const result = await callCustomAPI("delete-account", "post", { token: searchParams.get("token") }, "");
|
||||
setPageText("Your account has been deleted");
|
||||
localStorage.clear();
|
||||
} catch (err) {
|
||||
globalDispatch({ type: "SHOW_ERROR", payload: { heading: "Operation failed", message: err.message } });
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
deleteAccount();
|
||||
}, []);
|
||||
|
||||
if (!searchParams.get("token")) return <Navigate to={"/"} />;
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<h1 className="text-2xl mb-32">{pageText}</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Link } from "react-router-dom";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import CustomSelect from "@/components/frontend/CustomSelect";
|
||||
|
||||
const ContactUsPage = () => {
|
||||
const { handleSubmit, register, reset, setValue } = useForm();
|
||||
let sdk = new MkdSDK();
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
console.log("submitting", data);
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
try {
|
||||
const tmpl = await sdk.getEmailTemplate("contact");
|
||||
const body = tmpl.html?.replace(new RegExp("{{{name}}}", "g"), data.name)?.replace(new RegExp("{{{email}}}", "g"), data.email)?.replace(new RegExp("{{{message}}}", "g"), data.message);
|
||||
|
||||
await sdk.sendEmail(data.email, tmpl.subject, body);
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Email Sent",
|
||||
message: "Email has been sent, we will get back to you shortly",
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
showToast(globalDispatch, err.message, 4000, "ERROR");
|
||||
}
|
||||
reset();
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="bg-black pb-[80px] md:pt-[120px] pt-[170px]">
|
||||
<h1 className="text-white md:text-7xl text-4xl font-semibold text-center">Contact Us</h1>
|
||||
</section>
|
||||
<section className="pt-[40px] container mx-auto 2xl:px-16 pb-[140px] normal-case">
|
||||
<p className="md:px-0 px-4 pb-8">We are here to help. Copy to be provided</p>
|
||||
<div className="flex md:flex-row flex-col justify-between px-16 items-end mb-24">
|
||||
<div className="mb-4">
|
||||
<small className="font-semibold text-2xl">FAQs - Frequently Asked Questions</small>
|
||||
<h3 className="text-xs">Read most common questions others have.</h3>
|
||||
</div>
|
||||
<Link
|
||||
to="/faq"
|
||||
className="px-6 py-2 md:w-[178px] w-full text-center rounded-md border border-[#33D4B7] my-text-gradient whitespace-nowrap"
|
||||
>
|
||||
Visit FAQs
|
||||
</Link>
|
||||
</div>
|
||||
<p className="mb-16 px-4 md:px-0">Feel free to reach out to our team. We usually reply within 24 hours.</p>
|
||||
<div className="flex items-center justify-center flex-wrap">
|
||||
<form
|
||||
className="flex flex-col gap-8 md:w-1/2 w-full p-8"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
placeholder="Name"
|
||||
className="resize-none border p-2 focus:outline-none"
|
||||
{...register("name")}
|
||||
/>
|
||||
<input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
placeholder="Email"
|
||||
className="resize-none border p-2 focus:outline-none"
|
||||
{...register("email")}
|
||||
/>
|
||||
<CustomSelect
|
||||
options={["Inquiry", "Complaint", "General"]}
|
||||
name="type"
|
||||
register={register}
|
||||
setValue={setValue}
|
||||
formMode
|
||||
className="min-w-[200px]"
|
||||
/>
|
||||
<textarea
|
||||
name=""
|
||||
id=""
|
||||
cols="30"
|
||||
rows="5"
|
||||
placeholder="Message"
|
||||
className="border p-2 focus:outline-none resize-none"
|
||||
{...register("message")}
|
||||
></textarea>
|
||||
<button
|
||||
type="submit"
|
||||
className="!bg-gradient-to-r from-[#33D4B7] to-[#0D9895] text-white w-40 self-end tracking-wide outline-none focus:outline-none rounded py-2"
|
||||
>
|
||||
Submit
|
||||
</button>
|
||||
</form>
|
||||
<div className="md:pl-32 md:w-1/2 w-full">
|
||||
<img
|
||||
src="/contact.png"
|
||||
alt=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ContactUsPage;
|
||||
@@ -0,0 +1,626 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import PropertySpaceCard from "@/components/frontend/PropertySpaceCard";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import NoteIcon from "@/components/frontend/icons/NoteIcon";
|
||||
import { isValidDate, parseSearchParams } from "@/utils/utils";
|
||||
import { useContext } from "react";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import HostCardSlider from "@/components/frontend/HostCardSlider";
|
||||
import CustomSelectV2 from "@/components/CustomSelectV2";
|
||||
import CustomLocationAutoCompleteV2 from "@/components/CustomLocationAutoCompleteV2";
|
||||
import DatePickerV3 from "@/components/DatePickerV3";
|
||||
import { DRAFT_STATUS, SPACE_STATUS, SPACE_VISIBILITY } from "@/utils/constants";
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import PropertySpaceFiltersModal from "@/components/PropertySpaceFiltersModal";
|
||||
import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
const prices = [
|
||||
{
|
||||
label: "All Prices",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
label: "$0 - $30",
|
||||
value: "$0 - $30",
|
||||
},
|
||||
{
|
||||
label: "$31 - $60",
|
||||
value: "$31 - $60",
|
||||
},
|
||||
{
|
||||
label: "$60 - $90",
|
||||
value: "$60 - $90",
|
||||
},
|
||||
{
|
||||
label: "$90 - $120",
|
||||
value: "$90 - $120",
|
||||
},
|
||||
{
|
||||
label: "$120 - $150",
|
||||
value: "$120 - $150",
|
||||
},
|
||||
{
|
||||
label: "$150 - $180",
|
||||
value: "$150 - $180",
|
||||
},
|
||||
];
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
const ExplorePage = () => {
|
||||
const FETCH_PER_SCROLL = 12;
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const section = searchParams.get("section") ?? "all";
|
||||
const [hosts, setHosts] = useState([]);
|
||||
const [popularSpaces, setPopularSpaces] = useState([]);
|
||||
const [newSpaces, setNewSpaces] = useState([]);
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [forceRender, setForceRender] = useState("");
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
const { dispatch } = useContext(AuthContext);
|
||||
const [ctrl] = useState(new AbortController());
|
||||
|
||||
const { handleSubmit, register, watch, reset, setValue, control, formState, resetField } = useForm({
|
||||
defaultValues: (() => {
|
||||
const params = parseSearchParams(searchParams);
|
||||
return {
|
||||
location: params.location ?? "",
|
||||
from: isValidDate(params.from ?? "") ? new Date(params.from) : new Date(),
|
||||
to: isValidDate(params.to ?? "") ? new Date(params.to) : new Date(),
|
||||
space_name: params.space_name ?? "",
|
||||
category: params.category ?? "",
|
||||
price_range: params.price_range ?? "",
|
||||
direction: "DESC",
|
||||
};
|
||||
})(),
|
||||
});
|
||||
|
||||
const { dirtyFields } = formState;
|
||||
|
||||
const direction = watch("direction");
|
||||
const fromDate = watch("from");
|
||||
|
||||
const [popularTotal, setPopularTotal] = useState(10000);
|
||||
const [newSpaceTotal, setNewSpaceTotal] = useState(10000);
|
||||
|
||||
async function fetchPopularSpaces(page) {
|
||||
setPopularSpaces([]);
|
||||
setPopularSpaces((prev) => {
|
||||
const amountToFetch = popularTotal - prev.length > FETCH_PER_SCROLL ? FETCH_PER_SCROLL : Math.abs(popularTotal - prev.length - FETCH_PER_SCROLL);
|
||||
return [...prev, ...Array(amountToFetch).fill({})];
|
||||
});
|
||||
const data = parseSearchParams(searchParams);
|
||||
const user_id = localStorage.getItem("user");
|
||||
const location = (data.location?.split(","))
|
||||
|
||||
var from_price, to_price;
|
||||
if (data.price_range) {
|
||||
var arr = data.price_range.split("-");
|
||||
if (arr.length > 1) {
|
||||
from_price = arr[0].trim().slice(1);
|
||||
to_price = arr[1].trim().slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
let where = [
|
||||
`ergo_property_spaces.space_status = ${SPACE_STATUS.APPROVED} AND ergo_property_spaces_images.is_approved = 1 AND schedule_template_id IS NOT NULL AND ergo_property_spaces.draft_status = ${DRAFT_STATUS.COMPLETED} AND ergo_property_spaces.deleted_at IS NULL`,
|
||||
];
|
||||
|
||||
if (data.category) {
|
||||
where.push(`ergo_spaces.category = '${data.category}'`);
|
||||
}
|
||||
|
||||
if (data.space_name) {
|
||||
where.push(`ergo_property.name LIKE '%${data.space_name}%'`);
|
||||
}
|
||||
|
||||
if (data.price_range) {
|
||||
where.push(`ergo_property_spaces.rate BETWEEN ${from_price} AND ${to_price}`);
|
||||
}
|
||||
|
||||
if (data.location) {
|
||||
where.push(
|
||||
`(ergo_property.address_line_1 LIKE '%${data.location}%' OR ergo_property.address_line_2 LIKE '%${data.location}%' OR ergo_property.city LIKE '%${location[0]}%' OR ergo_property.country LIKE '%${location.length === 1 ? location[0] : location.length === 2 ? location[1] : location[2]}%' OR ergo_property.zip LIKE '%${data.location}%' OR ergo_property.name LIKE '%${data.location}%')`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/popular/PAGINATE",
|
||||
{
|
||||
page: page ?? 1,
|
||||
limit: FETCH_PER_SCROLL,
|
||||
user_id: Number(user_id),
|
||||
where,
|
||||
booking_start_time: isValidDate(data.from || "") ? new Date(data.from).toISOString() : undefined,
|
||||
booking_end_time: isValidDate(data.to || "") ? new Date(data.to).toISOString() : undefined,
|
||||
sortId: direction == "NONE" ? undefined : "id",
|
||||
direction: direction == "NONE" ? undefined : direction,
|
||||
},
|
||||
"POST",
|
||||
ctrl.signal,
|
||||
);
|
||||
if (Array.isArray(result.list)) {
|
||||
setPopularSpaces((prev) => {
|
||||
return [...prev.filter((item) => Object.keys(item).length > 0), ...result.list].filter((v, i, a) => a.findIndex((v2) => v2.id === v.id) === i);
|
||||
});
|
||||
setPopularTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewSpaces(page) {
|
||||
setNewSpaces([]);
|
||||
setNewSpaces((prev) => {
|
||||
const amountToFetch = newSpaceTotal - prev.length > FETCH_PER_SCROLL ? FETCH_PER_SCROLL : Math.abs(newSpaceTotal - prev.length - FETCH_PER_SCROLL);
|
||||
return [...prev, ...Array(amountToFetch).fill({})];
|
||||
});
|
||||
const data = parseSearchParams(searchParams);
|
||||
|
||||
const user_id = localStorage.getItem("user");
|
||||
|
||||
var from_price, to_price;
|
||||
if (data.price_range) {
|
||||
var arr = data.price_range.split("-");
|
||||
if (arr.length > 1) {
|
||||
from_price = arr[0].trim().slice(1);
|
||||
to_price = arr[1].trim().slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
let where = [
|
||||
`ergo_property_spaces.space_status = ${SPACE_STATUS.APPROVED} AND ergo_property_spaces.draft_status = ${DRAFT_STATUS.COMPLETED} AND ergo_property_spaces.availability = ${SPACE_VISIBILITY.VISIBLE} AND ergo_property_spaces_images.is_approved = 1`,
|
||||
];
|
||||
|
||||
if (data.category) {
|
||||
where.push(`ergo_spaces.category = '${data.category}'`);
|
||||
}
|
||||
|
||||
if (data.space_name) {
|
||||
where.push(`ergo_property.name LIKE '%${data.space_name}%'`);
|
||||
}
|
||||
|
||||
if (data.price_range) {
|
||||
where.push(`ergo_property_spaces.rate BETWEEN ${from_price} AND ${to_price}`);
|
||||
}
|
||||
|
||||
if (data.location) {
|
||||
where.push(
|
||||
`(ergo_property.address_line_1 LIKE '%${location}%' OR ergo_property.address_line_2 LIKE '%${location}%' OR ergo_property.city LIKE '%${location[0] ?? ""}%' OR ergo_property.country LIKE '%${location.length === 1 ? location[0] : location.length === 2 ? location[1] : location[2]}%' OR ergo_property.zip LIKE '%${location}%' OR ergo_property.name LIKE '%${location}%')`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/popular/PAGINATE",
|
||||
{
|
||||
page: page ?? 1,
|
||||
limit: FETCH_PER_SCROLL,
|
||||
user_id: Number(user_id),
|
||||
where,
|
||||
sortId: "update_at",
|
||||
direction: "DESC",
|
||||
booking_start_time: isValidDate(data.from || "") ? new Date(data.from).toISOString() : undefined,
|
||||
booking_end_time: isValidDate(data.to || "") ? new Date(data.to).toISOString() : undefined,
|
||||
},
|
||||
"POST",
|
||||
ctrl.signal,
|
||||
);
|
||||
if (Array.isArray(result.list)) {
|
||||
setNewSpaces((prev) => {
|
||||
return [...prev.filter((item) => Object.keys(item).length > 0), ...result.list].filter((v, i, a) => a.findIndex((v2) => v2.id === v.id) === i);
|
||||
});
|
||||
setNewSpaceTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHosts() {
|
||||
const filter = parseSearchParams(searchParams);
|
||||
const data = parseSearchParams(searchParams);
|
||||
const location = (data.location?.replace(', undefined', '')?.split(","))
|
||||
|
||||
const user_id = localStorage.getItem("user");
|
||||
|
||||
var from_price, to_price;
|
||||
if (data.price_range) {
|
||||
var arr = data.price_range.split("-");
|
||||
if (arr.length > 1) {
|
||||
from_price = arr[0].trim().slice(1);
|
||||
to_price = arr[1].trim().slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
let where = [];
|
||||
where.push('ergo_property.id IS NOT NULL');
|
||||
if (data.category) {
|
||||
where.push(`ergo_spaces.category = '${data.category}'`);
|
||||
}
|
||||
|
||||
if (data.space_name) {
|
||||
where.push(`ergo_property.name LIKE '%${data.space_name}%'`);
|
||||
}
|
||||
if (data.from) {
|
||||
where.push(`ergo_user.create_at BETWEEN '${data.from}' AND '${data.to}'`);
|
||||
}
|
||||
if (data.price_range) {
|
||||
where.push(`ergo_property_spaces.rate BETWEEN ${from_price} AND ${to_price}`);
|
||||
}
|
||||
if (data.location) {
|
||||
where.push([
|
||||
`(ergo_profile.address_line_1 LIKE '%${data.location}%' OR ergo_profile.address_line_2 LIKE '%${data.location}%' OR ergo_profile.city LIKE '%${location[0]}%' OR ergo_profile.country LIKE '%${location[1]}%' OR ergo_profile.zip LIKE '%${data.location}%')`,
|
||||
]);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/top-hosts/PAGINATE",
|
||||
{
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
sortId: "avg_host_rating",
|
||||
direction: "DESC",
|
||||
where,
|
||||
booking_start_time: isValidDate(data.from || "") ? new Date(data.from).toISOString() : undefined,
|
||||
booking_end_time: isValidDate(data.to || "") ? new Date(data.to).toISOString() : undefined,
|
||||
}, "POST", ctrl.signal);
|
||||
setHosts(result.list);
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
switch (searchParams.get("section")) {
|
||||
case "popular":
|
||||
fetchPopularSpaces();
|
||||
break;
|
||||
case "hosts":
|
||||
fetchHosts();
|
||||
break;
|
||||
case "new-spaces":
|
||||
fetchNewSpaces();
|
||||
break;
|
||||
default:
|
||||
fetchHosts();
|
||||
fetchPopularSpaces();
|
||||
fetchNewSpaces();
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceRender) {
|
||||
setPopularSpaces([]);
|
||||
setNewSpaces([]);
|
||||
fetchPopularSpaces();
|
||||
fetchNewSpaces();
|
||||
}
|
||||
}, [forceRender]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// TODO: abort this only when component unmounts
|
||||
// console.log("aborting");
|
||||
// ctrl.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (window.innerWidth < 700) {
|
||||
setShowFilter(false);
|
||||
}
|
||||
|
||||
if (data.location.includes("undefined")) {
|
||||
const parts = inputString.split(",");
|
||||
const result = parts[0].trim();
|
||||
data.location = result;
|
||||
}
|
||||
searchParams.set("category", data.category);
|
||||
searchParams.set("price_range", data.price_range);
|
||||
searchParams.set("space_name", data.space_name);
|
||||
searchParams.set("location", data.location);
|
||||
searchParams.set("from", dirtyFields?.from ? data.from.toISOString() : "");
|
||||
searchParams.set("to", dirtyFields?.to ? data.to.toISOString() : "");
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const sortByDate = (a, b) => {
|
||||
if (direction == "NONE") return 0;
|
||||
if (direction == "DESC") {
|
||||
return new Date(b.id) - new Date(a.id);
|
||||
}
|
||||
return new Date(a.id) - new Date(b.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<section className="container mx-auto bg-white px-6 pt-[120px] normal-case 2xl:px-16">
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-8 text-sm md:text-base"
|
||||
>
|
||||
<div className="mb-[30px] flex justify-between gap-4 md:gap-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-grow items-center justify-between gap-2 rounded-md border p-2 md:max-w-[120px]"
|
||||
onClick={() => setShowFilter((prev) => !prev)}
|
||||
>
|
||||
<span>Filters</span>
|
||||
<AdjustmentsHorizontalIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<CustomSelectV2
|
||||
items={[
|
||||
{ label: "By Date: Newest First", value: "DESC" },
|
||||
{ label: "By Date: Oldest First", value: "ASC" },
|
||||
]}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="h-full w-full max-w-[12rem]"
|
||||
className={`w-full border py-2 px-3`}
|
||||
placeholder={"By Date: Newest First"}
|
||||
control={control}
|
||||
name="direction"
|
||||
/>
|
||||
</div>
|
||||
<div className={` ${showFilter ? "md:flex" : "hidden"} animate-filter hidden flex-wrap gap-[12px] gap-y-[20px]`}>
|
||||
<CustomSelectV2
|
||||
items={[{ label: "All Categories", value: "" }, ...globalState.spaceCategories.map((sp) => ({ label: sp.category, value: sp.category }))]}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="flex-grow max-w-xs min-w-[10rem]"
|
||||
className={`w-full border py-2 px-3`}
|
||||
placeholder={"All Categories"}
|
||||
control={control}
|
||||
name="category"
|
||||
/>
|
||||
<CustomSelectV2
|
||||
items={prices}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="flex-grow max-w-xs min-w-[10rem]"
|
||||
className={`w-full border py-2 px-3`}
|
||||
placeholder={"All Prices"}
|
||||
control={control}
|
||||
name="price_range"
|
||||
/>
|
||||
{/* <CustomLocationAutoCompleteV2
|
||||
control={control}
|
||||
setValue={(val) => setValue("location", val)}
|
||||
name="location"
|
||||
className={`rounded border py-3 px-3 leading-tight text-gray-700 focus:outline-none`}
|
||||
containerClassName={"w-[unset] flex-gro max-w-xs"}
|
||||
placeholder="Location"
|
||||
suggestionType={["(regions)"]}
|
||||
hideIcons
|
||||
/> */}
|
||||
<CustomLocationAutoCompleteV2
|
||||
control={control}
|
||||
setValue={(val) => setValue("location", val)}
|
||||
name="location"
|
||||
className={`rounded border py-3 px-3 leading-tight text-gray-700 focus:outline-none`}
|
||||
containerClassName={"w-[unset] flex-gro max-w-xs"}
|
||||
placeholder="Location"
|
||||
suggestionType={["(regions)"]}
|
||||
hideIcons
|
||||
/>
|
||||
<div className="z-10 flex min-w-[190px] items-center gap-2 rounded-md border bg-white px-2">
|
||||
<DatePickerV3
|
||||
reset={() => resetField("from", { keepDirty: false, keepTouched: false })}
|
||||
setValue={(val) => setValue("from", val, { shouldDirty: true })}
|
||||
control={control}
|
||||
name="from"
|
||||
labelClassName="justify-between flex-grow flex-row-reverse"
|
||||
placeholder="From"
|
||||
/>
|
||||
</div>
|
||||
<div className="z-10 flex min-w-[190px] items-center gap-2 rounded-md border bg-white px-2">
|
||||
<DatePickerV3
|
||||
reset={() => resetField("to", { keepDirty: false, keepTouched: false })}
|
||||
setValue={(val) => setValue("to", val, { shouldDirty: true })}
|
||||
control={control}
|
||||
name="to"
|
||||
labelClassName="justify-between flex-grow flex-row-reverse"
|
||||
placeholder="To"
|
||||
min={fromDate}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
className="max-w-[180px] rounded-md border p-2 focus:outline-none active:outline-none"
|
||||
{...register("space_name")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-black p-2 px-6"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{(section == "popular" || section == "all") && (
|
||||
<section
|
||||
className="container mx-auto pt-[40px] 2xl:px-16"
|
||||
id="popular"
|
||||
>
|
||||
<div className="mb-[26px] flex items-end justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-3xl font-bold">{searchParams.get("category") || "Popular" + " spaces"}</h3>
|
||||
</div>
|
||||
{popularSpaces.length == 0 && (
|
||||
<div className="flex min-h-[300px] items-center justify-center normal-case text-[#667085]">
|
||||
<h2 className="flex gap-3">
|
||||
<NoteIcon /> No spaces found
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<InfiniteScroll
|
||||
dataLength={popularSpaces.length}
|
||||
next={() => {
|
||||
fetchPopularSpaces(Math.round(popularSpaces.length / FETCH_PER_SCROLL + 1));
|
||||
}}
|
||||
scrollThreshold={1}
|
||||
hasMore={popularSpaces.length < popularTotal}
|
||||
loader={<></>}
|
||||
endMessage={
|
||||
<p className="text-center normal-case">
|
||||
<b></b>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{
|
||||
<div className="property-space-grid pb-[100px]">
|
||||
{popularSpaces.sort(sortByDate).map((property, idx) => (
|
||||
<PropertySpaceCard
|
||||
key={property.id ?? idx}
|
||||
data={property}
|
||||
forceRender={setForceRender}
|
||||
/>
|
||||
))}
|
||||
{popularSpaces.length < 4 ? (
|
||||
<>
|
||||
<div className="hidden 2xl:block"></div>
|
||||
<div className="hidden lg:block"></div>
|
||||
<div className="hidden md:block"></div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
)}
|
||||
{section == "all" && (
|
||||
<section className="container mx-auto flex flex-wrap pt-[40px] pb-[40px] md:pb-[140px] 2xl:px-16">
|
||||
<div className="px-6 md:w-2/5 md:px-0">
|
||||
<h3 className="mb-[70px] text-[30px] font-semibold md:text-center">Browse By Category</h3>
|
||||
</div>
|
||||
<div className="browse-grid md:w-3/5">
|
||||
{globalState.spaceCategories.map((tab, idx) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={``}
|
||||
onClick={() => {
|
||||
setPopularSpaces(Array(FETCH_PER_SCROLL).fill({}));
|
||||
reset();
|
||||
window.scrollTo({ top: 0, left: 0 });
|
||||
searchParams.set("category", tab.category);
|
||||
searchParams.set("section", "popular");
|
||||
searchParams.delete("price_range");
|
||||
searchParams.delete("space_name");
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={tab.image}
|
||||
alt={tab.category}
|
||||
className="h-24 w-full rounded-lg object-cover md:h-40"
|
||||
/>
|
||||
<p className="text-lg py-3 px-5 text-left font-semibold">{tab.category}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{(section == "hosts" || section == "all") && (
|
||||
<section
|
||||
className="container mx-auto pt-[12px] pb-[64px] 2xl:px-16"
|
||||
id="hosts"
|
||||
>
|
||||
<div className="mb-[26px] flex items-end justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-3xl font-bold">Top rated hosts</h3>
|
||||
</div>
|
||||
<div className="px-2 md:px-0">
|
||||
<HostCardSlider hosts={hosts} />
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
{(section == "new-spaces" || section == "all") && (
|
||||
<section
|
||||
className="container mx-auto pt-[40px] 2xl:px-16"
|
||||
id="new-spaces"
|
||||
>
|
||||
<div className="mb-[26px] flex items-end justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-3xl font-bold">New Spaces</h3>
|
||||
</div>
|
||||
{newSpaces.length == 0 && (
|
||||
<div className="flex min-h-[300px] items-center justify-center normal-case text-[#667085]">
|
||||
<h2 className="flex gap-3">
|
||||
<NoteIcon /> No spaces found
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
<InfiniteScroll
|
||||
dataLength={newSpaces.length}
|
||||
next={() => {
|
||||
fetchNewSpaces(Math.round(newSpaces.length / FETCH_PER_SCROLL + 1));
|
||||
}}
|
||||
scrollThreshold={1}
|
||||
hasMore={newSpaces.length < newSpaceTotal}
|
||||
loader={<></>}
|
||||
endMessage={
|
||||
<p className="text-center normal-case">
|
||||
<b></b>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{
|
||||
<div className="property-space-grid pb-[100px]">
|
||||
{newSpaces.sort(sortByDate).map((property, idx) => (
|
||||
<PropertySpaceCard
|
||||
key={property.id ?? idx}
|
||||
data={property}
|
||||
forceRender={setForceRender}
|
||||
/>
|
||||
))}
|
||||
{newSpaces.length < 4 ? (
|
||||
<>
|
||||
<div className="hidden 2xl:block"></div>
|
||||
<div className="hidden lg:block"></div>
|
||||
<div className="hidden md:block"></div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
)}
|
||||
<PropertySpaceFiltersModal
|
||||
modalOpen={showFilter}
|
||||
closeModal={() => setShowFilter(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ExplorePage;
|
||||
@@ -0,0 +1,106 @@
|
||||
import React, { useState } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import FaqTile from "@/components/frontend/FaqTile";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import { Tab } from "@headlessui/react";
|
||||
|
||||
const FaqPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [faqs, setFaqs] = useState([]);
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
async function fetchFaqs() {
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
try {
|
||||
const result = await callCustomAPI(
|
||||
"faq",
|
||||
"post",
|
||||
{
|
||||
page: 1,
|
||||
limit: 1000,
|
||||
where: [`1`],
|
||||
},
|
||||
"PAGINATE",
|
||||
);
|
||||
if (Array.isArray(result.list)) {
|
||||
setFaqs(result.list);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchFaqs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="bg-black pb-[80px] pt-[170px] md:pt-[120px]">
|
||||
<h1 className="text-center text-4xl font-semibold text-white md:text-7xl">Frequently asked questions</h1>
|
||||
</section>
|
||||
<section className="container mx-auto min-h-screen px-4 pt-[40px] pb-[140px] normal-case 2xl:px-16">
|
||||
<p className="px-4 md:px-0">Below are some common questions people ask.</p>
|
||||
<Tab.Group
|
||||
as={"div"}
|
||||
className="mt-8"
|
||||
onChange={(v) => {
|
||||
setSearchParams({ tab: v == 0 ? "customers" : "hosts" });
|
||||
window.scrollTo({ top: 0, left: 0 });
|
||||
}}
|
||||
defaultIndex={localStorage.getItem("role") == "host" ? 1 : 0 || searchParams.get("tab") == "hosts" ? 1 : 0}
|
||||
>
|
||||
<Tab.List className={"two-tab-menu small mb-4"}>
|
||||
<Tab className={"px-5 py-3 text-xl text-gray-700 focus:outline-none ui-selected:text-black"}>For guests</Tab>
|
||||
<Tab className={"px-5 py-3 text-xl text-gray-700 focus:outline-none ui-selected:text-black"}>For hosts</Tab>
|
||||
<div className="mover"></div>
|
||||
</Tab.List>
|
||||
<Tab.Panels>
|
||||
<Tab.Panel as="section">
|
||||
{faqs
|
||||
.filter((faq) => faq.status != 1)
|
||||
.map((faq) => (
|
||||
<FaqTile
|
||||
data={faq}
|
||||
key={faq.id}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panel>
|
||||
<Tab.Panel as="section">
|
||||
{faqs
|
||||
.filter((faq) => faq.status == 1)
|
||||
.map((faq) => (
|
||||
<FaqTile
|
||||
data={faq}
|
||||
key={faq.id}
|
||||
/>
|
||||
))}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
<p>
|
||||
If you can’t find your answers we’re here to help. <br />
|
||||
</p>
|
||||
<Link
|
||||
to="/contact-us"
|
||||
className="underline"
|
||||
>
|
||||
Contact us
|
||||
</Link>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FaqPage;
|
||||
@@ -0,0 +1,298 @@
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import PropertySpaceCard from "@/components/frontend/PropertySpaceCard";
|
||||
import { useContext } from "react";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import CustomSelectV2 from "@/components/CustomSelectV2";
|
||||
import CustomLocationAutoCompleteV2 from "@/components/CustomLocationAutoCompleteV2";
|
||||
import DatePickerV3 from "@/components/DatePickerV3";
|
||||
import { isValidDate, parseSearchParams } from "@/utils/utils";
|
||||
import { DRAFT_STATUS, SPACE_STATUS, SPACE_VISIBILITY } from "@/utils/constants";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import PropertySpaceFiltersModal from "@/components/PropertySpaceFiltersModal";
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/solid";
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
const ctrl = new AbortController();
|
||||
const prices = [
|
||||
{
|
||||
label: "All Prices",
|
||||
value: "",
|
||||
},
|
||||
{
|
||||
label: "$0 - $30",
|
||||
value: "$0 - $30",
|
||||
},
|
||||
{
|
||||
label: "$31 - $60",
|
||||
value: "$31 - $60",
|
||||
},
|
||||
{
|
||||
label: "$60 - $90",
|
||||
value: "$60 - $90",
|
||||
},
|
||||
{
|
||||
label: "$90 - $120",
|
||||
value: "$90 - $120",
|
||||
},
|
||||
{
|
||||
label: "$120 - $150",
|
||||
value: "$120 - $150",
|
||||
},
|
||||
{
|
||||
label: "$150 - $180",
|
||||
value: "$150 - $180",
|
||||
},
|
||||
];
|
||||
|
||||
const FavoritesPage = () => {
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
const { dispatch } = useContext(AuthContext);
|
||||
const [propertySpaces, setPropertySpaces] = useState(Array(4).fill({}));
|
||||
const [showFilter, setShowFilter] = useState(false);
|
||||
const [forceRender, setForceRender] = useState(new Date());
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { handleSubmit, register, watch, setValue, control, formState, resetField } = useForm({
|
||||
defaultValues: (() => {
|
||||
const params = parseSearchParams(searchParams);
|
||||
return {
|
||||
location: params.location ?? "",
|
||||
from: isValidDate(params.from ?? "") ? new Date(params.from) : new Date(),
|
||||
to: isValidDate(params.to ?? "") ? new Date(params.to) : new Date(),
|
||||
space_name: params.space_name ?? "",
|
||||
category: params.category ?? "",
|
||||
price_range: params.price_range ?? "",
|
||||
direction: "DESC",
|
||||
};
|
||||
})(),
|
||||
});
|
||||
|
||||
const { dirtyFields } = formState;
|
||||
|
||||
const direction = watch("direction");
|
||||
const fromDate = watch("from");
|
||||
|
||||
async function fetchPropertySpaces() {
|
||||
setPropertySpaces(Array(4).fill({}));
|
||||
const data = parseSearchParams(searchParams);
|
||||
const user_id = localStorage.getItem("user");
|
||||
|
||||
var from_price, to_price;
|
||||
if (data.price_range) {
|
||||
var arr = data.price_range.split("-");
|
||||
if (arr.length > 1) {
|
||||
from_price = arr[0].trim().slice(1);
|
||||
to_price = arr[1].trim().slice(1);
|
||||
}
|
||||
}
|
||||
|
||||
let where = [
|
||||
`ergo_property_spaces.space_status = ${SPACE_STATUS.APPROVED} AND ergo_property_spaces.draft_status = ${DRAFT_STATUS.COMPLETED} AND ergo_property_spaces.availability = ${SPACE_VISIBILITY.VISIBLE} AND ergo_user_property_spaces.user_id = ${user_id} AND ergo_property_spaces.deleted_at IS NULL`,
|
||||
];
|
||||
|
||||
if (data.category) {
|
||||
where.push(`ergo_spaces.category = '${data.category}'`);
|
||||
}
|
||||
|
||||
if (data.space_name) {
|
||||
where.push(`ergo_property.name LIKE '%${data.space_name}%'`);
|
||||
}
|
||||
|
||||
if (data.price_range) {
|
||||
where.push(`ergo_property_spaces.rate BETWEEN ${from_price} AND ${to_price}`);
|
||||
}
|
||||
|
||||
if (data.location) {
|
||||
where.push(
|
||||
`(ergo_property.address_line_1 LIKE '%${data.location}%' OR ergo_property.address_line_2 LIKE '%${data.location}%' OR ergo_property.city LIKE '%${data.location}%' OR ergo_property.country LIKE '%${data.location}%' OR ergo_property.zip LIKE '%${data.location}%' OR ergo_property.name LIKE '%${data.location}%')`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("favorites where ", where);
|
||||
|
||||
try {
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/popular/PAGINATE",
|
||||
{
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
user_id: Number(user_id),
|
||||
where,
|
||||
booking_start_time: isValidDate(data.from || "") ? new Date(data.from).toISOString() : undefined,
|
||||
booking_end_time: isValidDate(data.to || "") ? new Date(data.to).toISOString() : undefined,
|
||||
sortId: direction == "NONE" ? undefined : "id",
|
||||
direction: direction == "NONE" ? undefined : direction,
|
||||
},
|
||||
"POST",
|
||||
ctrl.signal,
|
||||
);
|
||||
if (Array.isArray(result.list)) {
|
||||
setPropertySpaces(result.list);
|
||||
}
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
useEffect(() => {
|
||||
fetchPropertySpaces();
|
||||
}, [searchParams, forceRender]);
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
if (window.innerWidth < 700) {
|
||||
setShowFilter(false);
|
||||
}
|
||||
console.log("submitting ", data);
|
||||
searchParams.set("category", data.category);
|
||||
searchParams.set("price_range", data.price_range);
|
||||
searchParams.set("space_name", data.space_name);
|
||||
searchParams.set("location", data.location);
|
||||
searchParams.set("from", dirtyFields?.from ? data.from.toISOString() : "");
|
||||
searchParams.set("to", dirtyFields?.to ? data.to.toISOString() : "");
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
const sortByDate = (a, b) => {
|
||||
if (direction == "DESC") {
|
||||
return new Date(b.id) - new Date(a.id);
|
||||
}
|
||||
return new Date(a.id) - new Date(b.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="container mx-auto min-h-screen bg-white px-6 pt-[120px] normal-case 2xl:px-16">
|
||||
<h1 className="mb-[40px] text-3xl font-semibold md:text-4xl">My Favorite spaces</h1>
|
||||
<section>
|
||||
<form
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="mb-8 text-sm md:text-base"
|
||||
>
|
||||
<div className="mb-[30px] flex justify-between gap-4 md:gap-0">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-grow items-center justify-between gap-2 rounded-md border p-2 md:max-w-[120px]"
|
||||
onClick={() => setShowFilter((prev) => !prev)}
|
||||
>
|
||||
<span>Filters</span>
|
||||
<AdjustmentsHorizontalIcon className="h-6 w-6" />
|
||||
</button>
|
||||
<CustomSelectV2
|
||||
items={[
|
||||
{ label: "By Date: Newest First", value: "DESC" },
|
||||
{ label: "By Date: Oldest First", value: "ASC" },
|
||||
]}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="h-full w-full max-w-[12rem]"
|
||||
className={`w-full border py-2 px-3`}
|
||||
placeholder={"By Date: Newest First"}
|
||||
control={control}
|
||||
name="direction"
|
||||
/>
|
||||
</div>
|
||||
<div className={` ${showFilter ? "md:flex" : "hidden"} animate-filter hidden flex-wrap gap-[12px] gap-y-[20px]`}>
|
||||
<CustomSelectV2
|
||||
items={[{ label: "All Categories", value: "" }, ...globalState.spaceCategories.map((sp) => ({ label: sp.category, value: sp.category }))]}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="flex-grow max-w-xs min-w-[10rem]"
|
||||
className={`w-full border py-2 px-3`}
|
||||
placeholder={"All Categories"}
|
||||
control={control}
|
||||
name="category"
|
||||
/>
|
||||
<CustomSelectV2
|
||||
items={prices}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="flex-grow max-w-xs min-w-[10rem]"
|
||||
className={`w-full border py-2 px-3`}
|
||||
placeholder={"All Prices"}
|
||||
control={control}
|
||||
name="price_range"
|
||||
/>
|
||||
<CustomLocationAutoCompleteV2
|
||||
control={control}
|
||||
setValue={(val) => setValue("location", val, { shouldDirty: true })}
|
||||
name="location"
|
||||
className={`rounded border py-3 px-3 leading-tight text-gray-700 focus:outline-none`}
|
||||
containerClassName={"w-[unset] flex-grow max-w-xs"}
|
||||
placeholder="Location"
|
||||
hideIcons
|
||||
/>
|
||||
<div className="z-10 flex min-w-[190px] items-center gap-2 rounded-md border bg-white px-2">
|
||||
<DatePickerV3
|
||||
reset={() => resetField("from", { keepDirty: false, keepTouched: false })}
|
||||
setValue={(val) => setValue("from", val, { shouldDirty: true })}
|
||||
control={control}
|
||||
name="from"
|
||||
labelClassName="justify-between flex-grow flex-row-reverse"
|
||||
placeholder="From"
|
||||
min={new Date()}
|
||||
/>
|
||||
</div>
|
||||
<div className="z-10 flex min-w-[190px] items-center gap-2 rounded-md border bg-white px-2">
|
||||
<DatePickerV3
|
||||
reset={() => resetField("to", { keepDirty: false, keepTouched: false })}
|
||||
setValue={(val) => setValue("to", val, { shouldDirty: true })}
|
||||
control={control}
|
||||
name="to"
|
||||
labelClassName="justify-between flex-grow flex-row-reverse"
|
||||
placeholder="To"
|
||||
min={fromDate}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Space name"
|
||||
className="max-w-[180px] rounded-md border p-2 focus:outline-none active:outline-none"
|
||||
{...register("space_name")}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="rounded-md border border-black p-2 px-6"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
<div className="property-space-grid pb-[100px]">
|
||||
{propertySpaces.sort(sortByDate).map((property, idx) => (
|
||||
<PropertySpaceCard
|
||||
key={property.id ?? idx}
|
||||
data={property}
|
||||
forceRender={setForceRender}
|
||||
/>
|
||||
))}
|
||||
{propertySpaces.length < 4 ? (
|
||||
<>
|
||||
<div className="hidden 2xl:block"></div>
|
||||
<div className="hidden lg:block"></div>
|
||||
<div className="hidden md:block"></div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<PropertySpaceFiltersModal
|
||||
modalOpen={showFilter}
|
||||
closeModal={() => setShowFilter(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default FavoritesPage;
|
||||
@@ -0,0 +1,574 @@
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { createSearchParams, Link, useLocation, useNavigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import PropertySpaceCard from "@/components/frontend/PropertySpaceCard";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import PeopleIcon from "@/components/frontend/icons/PeopleIcon";
|
||||
import TrustedIcon from "@/components/frontend/icons/TrustedIcon";
|
||||
import FlexibleIcon from "@/components/frontend/icons/FlexibleIcon";
|
||||
import InfiniteScroll from "react-infinite-scroll-component";
|
||||
import SearchIcon from "@/components/frontend/icons/SearchIcon";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import "react-tooltip/dist/react-tooltip.css";
|
||||
import HostCardSlider from "@/components/frontend/HostCardSlider";
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import useUserCurrentLocation from "@/hooks/api/useUserCurrentLocation";
|
||||
import { DRAFT_STATUS } from "@/utils/constants";
|
||||
import { useForm } from "react-hook-form";
|
||||
import CustomLocationAutoCompleteV2 from "@/components/CustomLocationAutoCompleteV2";
|
||||
import DatePickerV3 from "@/components/DatePickerV3";
|
||||
import CustomSelectV2 from "@/components/CustomSelectV2";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import CustomStaticLocationAutoCompleteV2 from "@/components/CustomStaticLocationAutoCompleteV2 ";
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const HomePage = () => {
|
||||
const FETCH_PER_SCROLL = 12;
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
const { state } = useContext(AuthContext);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeTab, setActiveTab] = useState(searchParams.get("category") || "all");
|
||||
const [hosts, setHosts] = useState(Array(5).fill({}));
|
||||
const [popularSpaces, setPopularSpaces] = useState([]);
|
||||
const [newSpaces, setNewSpaces] = useState([]);
|
||||
const [forceRender, setForceRender] = useState("");
|
||||
const location = useLocation()
|
||||
const { state: authState, dispatch: authDispatch } = useContext(AuthContext);
|
||||
|
||||
const { dispatch } = useContext(AuthContext);
|
||||
const [popularTotal, setPopularTotal] = useState(1000);
|
||||
const [newTotal, setNewTotal] = useState(1000);
|
||||
const spaceCategories = globalState.spaceCategories;
|
||||
|
||||
const { handleSubmit, control, setValue, resetField, formState, register } = useForm({
|
||||
defaultValues: {
|
||||
booking_start_time: new Date(),
|
||||
location: globalState.location,
|
||||
size: "",
|
||||
},
|
||||
});
|
||||
|
||||
const { touchedFields } = formState;
|
||||
|
||||
const { city, country, done: currentLocationChecked } = useUserCurrentLocation();
|
||||
const [noCurrentLocationData, setNoCurrentLocationData] = useState(false);
|
||||
|
||||
const navigate = useNavigate();
|
||||
const userRole = localStorage.getItem("role");
|
||||
const isLoggedIn = !!localStorage.getItem("token");
|
||||
|
||||
async function fetchPopularSpaces(page) {
|
||||
// only add empty spaces if there's no empty card i.e we are not currently fetching
|
||||
if (popularSpaces.every((space) => Object.keys(space).length > 0)) {
|
||||
setPopularSpaces((prev) => {
|
||||
const amountToFetch = popularTotal - prev.length > FETCH_PER_SCROLL ? FETCH_PER_SCROLL : Math.abs(popularTotal - prev.length - FETCH_PER_SCROLL);
|
||||
return [...prev, ...Array(amountToFetch).fill({})];
|
||||
});
|
||||
}
|
||||
const user_id = localStorage.getItem("user");
|
||||
const where = [
|
||||
`${activeTab != "all" ? `ergo_spaces.category LIKE '%${activeTab}%'` : "1"} AND ergo_property_spaces.space_status = 1 AND ergo_property_spaces.draft_status = ${DRAFT_STATUS.COMPLETED
|
||||
} AND ergo_property_spaces_images.is_approved = 1 AND ergo_property_spaces.deleted_at IS NULL AND schedule_template_id IS NOT NULL AND (${city && !noCurrentLocationData ? `ergo_property.city LIKE '%${city}%'` : "1"} OR ${country && !noCurrentLocationData ? `ergo_property.country LIKE '%${country}%'` : "1"
|
||||
})`,
|
||||
];
|
||||
try {
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/popular/PAGINATE", { page: page ?? 1, limit: FETCH_PER_SCROLL, user_id: Number(user_id), where }, "POST", ctrl.signal);
|
||||
if (Array.isArray(result.list)) {
|
||||
|
||||
setPopularSpaces((prev) => {
|
||||
return [...prev.filter((item) => Object.keys(item).length > 0), ...result.list].filter((v, i, a) => a.findIndex((v2) => v2.id === v.id) === i);
|
||||
});
|
||||
setPopularTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchNewSpaces(page) {
|
||||
if (newSpaces.every((space) => Object.keys(space).length > 0)) {
|
||||
setNewSpaces((prev) => {
|
||||
const amountToFetch = newTotal - prev.length > FETCH_PER_SCROLL ? FETCH_PER_SCROLL : Math.abs(newTotal - prev.length - FETCH_PER_SCROLL);
|
||||
return [...prev, ...Array(amountToFetch).fill({})];
|
||||
});
|
||||
}
|
||||
const user_id = localStorage.getItem("user");
|
||||
const where = [
|
||||
`${activeTab != "all" ? `ergo_spaces.category LIKE '%${activeTab}%'` : "1"} AND ergo_property_spaces.space_status = 1 AND ergo_property_spaces.draft_status = ${DRAFT_STATUS.COMPLETED
|
||||
} AND ergo_property_spaces_images.is_approved = 1 AND schedule_template_id IS NOT NULL AND ergo_property_spaces.deleted_at IS NULL AND (${city && !noCurrentLocationData ? `ergo_property.city LIKE '%${city}%'` : "1"} OR ${country && !noCurrentLocationData ? `ergo_property.country LIKE '%${country}%'` : "1"
|
||||
})`,
|
||||
];
|
||||
try {
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/popular/PAGINATE",
|
||||
{ page: page ?? 1, limit: 6, user_id: Number(user_id), where, sortId: "update_at", direction: "DESC" },
|
||||
// { page: page ?? 1, limit: FETCH_PER_SCROLL, user_id: null, where, sortId: "update_at", direction: "DESC" },
|
||||
"POST",
|
||||
ctrl?.signal,
|
||||
);
|
||||
if (Array.isArray(result.list)) {
|
||||
setNewSpaces((prev) => {
|
||||
return [...prev.filter((item) => Object.keys(item).length > 0), ...result.list].filter((v, i, a) => a.findIndex((v2) => v2.id === v.id) === i);
|
||||
});
|
||||
setNewTotal(result.total);
|
||||
}
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchHosts() {
|
||||
try {
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/top-hosts/PAGINATE",
|
||||
{
|
||||
page: 1,
|
||||
limit: 10,
|
||||
sortId: "avg_host_rating",
|
||||
direction: "DESC",
|
||||
where: [
|
||||
`${city && !noCurrentLocationData ? `ergo_profile.city LIKE '%${city}%'` : "1"} AND ${country && !noCurrentLocationData ? `ergo_profile.country LIKE '%${country}%'` : "1"}`,
|
||||
"ergo_user.deleted_at IS NULL", "ergo_property.id IS NOT NULL",
|
||||
],
|
||||
},
|
||||
"POST",
|
||||
ctrl.signal,
|
||||
);
|
||||
|
||||
setHosts(result.list);
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onSubmit(data) {
|
||||
navigate({
|
||||
pathname: "/search",
|
||||
search: createSearchParams({
|
||||
location: globalState.location ?? "",
|
||||
booking_start_time: touchedFields.booking_start_time ? data.booking_start_time.toISOString() : "",
|
||||
max_capacity: data.max_capacity ?? "",
|
||||
capacity: data.capacity ?? "",
|
||||
size: data.size ?? "",
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function setDevice() {
|
||||
if (!localStorage.getItem("token") || localStorage.getItem("token") !== undefined) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await sdk.setUUId()
|
||||
} catch (error) {
|
||||
console.log(error.message)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let setter;
|
||||
if (!setter) {
|
||||
setDevice()
|
||||
}
|
||||
return () => {
|
||||
setter = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLocationChecked) return;
|
||||
if (!noCurrentLocationData) {
|
||||
globalDispatch({
|
||||
type: "SETLOCATION",
|
||||
payload: {
|
||||
location:(city ?? "") + (city && country ? ", " : "") + (country ?? "")
|
||||
},
|
||||
})
|
||||
setValue("location", (city ?? "") + (city && country ? ", " : "") + (country ?? ""));
|
||||
}
|
||||
fetchHosts();
|
||||
}, [currentLocationChecked, noCurrentLocationData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentLocationChecked) return;
|
||||
setPopularSpaces([]);
|
||||
setNewSpaces([]);
|
||||
fetchPopularSpaces();
|
||||
fetchNewSpaces();
|
||||
}, [activeTab, currentLocationChecked, noCurrentLocationData]);
|
||||
|
||||
useEffect(() => {
|
||||
if (forceRender && currentLocationChecked) {
|
||||
setPopularSpaces([]);
|
||||
setNewSpaces([]);
|
||||
fetchPopularSpaces();
|
||||
fetchNewSpaces();
|
||||
}
|
||||
}, [forceRender]);
|
||||
|
||||
function switchToCustomer() {
|
||||
authDispatch({ type: "SWITCH_TO_CUSTOMER" });
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Success",
|
||||
message: `You are now signed in as a customer`,
|
||||
btn: "Ok got it",
|
||||
},
|
||||
});
|
||||
navigate("/signup")
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<section
|
||||
style={{
|
||||
height: 600,
|
||||
background: `url('${spaceCategories.find((cat) => activeTab == cat.category)?.image ?? "/jumbotron1.jpg"}'), linear-gradient(0deg, rgba(16, 24, 40, 0.79), rgba(16, 24, 40, 0.79))`,
|
||||
}}
|
||||
className="my-background-image mb-6 pt-[70px] md:rounded-b-[3rem]"
|
||||
>
|
||||
<nav className="mb-[60px] flex justify-center border-t border-b border-gray-500 text-sm text-gray-300 md:mb-[103px] md:text-base">
|
||||
<div className="horizontal-scroll-categories">
|
||||
<button
|
||||
key={0}
|
||||
className={`${activeTab == "all" ? "active text-white" : ""} flex w-[105px] items-center justify-center whitespace-nowrap py-[12px]`}
|
||||
onClick={() => {
|
||||
setActiveTab("all");
|
||||
searchParams.set("category", "all");
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
All Spaces
|
||||
</button>
|
||||
{spaceCategories.map((tab) => {
|
||||
return (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`${activeTab == tab.category ? "active text-white" : ""} flex w-[105px] items-center justify-center whitespace-nowrap py-[12px]`}
|
||||
onClick={() => {
|
||||
setActiveTab(tab.category);
|
||||
searchParams.set("category", tab.category);
|
||||
// searchParams.set("section", "popular");
|
||||
setSearchParams(searchParams);
|
||||
// navigate(`/explore?category=${tab.category}§ion=popular`)
|
||||
}}
|
||||
>
|
||||
{tab.category}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className="mover"></div>
|
||||
</div>
|
||||
</nav>
|
||||
<h1 className="mb-[30px] px-4 text-center text-5xl font-bold text-white md:text-6xl lg:text-7xl">Spaces tailored to your needs</h1>
|
||||
<form
|
||||
className="fourteenth-step flex flex-wrap justify-center px-6 text-sm md:px-24 md:text-base lg:flex-nowrap"
|
||||
id="search-bar"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<CustomStaticLocationAutoCompleteV2
|
||||
setValue={(val) => globalDispatch({
|
||||
type: "SETLOCATION",
|
||||
payload: {
|
||||
location:val
|
||||
},
|
||||
})}
|
||||
type="static"
|
||||
containerClassName={"flex h-[40px] w-full items-center gap-2 rounded-t-md border-2 border-r-0 bg-white px-4 py-2 md:h-[unset] lg:max-w-[331px] lg:rounded-none lg:py-0"}
|
||||
className="border-0 focus:outline-none"
|
||||
placeholder="Search by city or zip code"
|
||||
onClear={() => setNoCurrentLocationData(true)}
|
||||
suggestionType={["(regions)"]}
|
||||
/>
|
||||
<div className="flex min-h-[40px] w-1/2 items-center gap-2 rounded-bl-md border-l-2 bg-white px-2 lg:min-w-[230px] lg:max-w-[230px] lg:rounded-none">
|
||||
<DatePickerV3
|
||||
reset={() => resetField("booking_start_time")}
|
||||
setValue={(val) => setValue("booking_start_time", val)}
|
||||
control={control}
|
||||
name="booking_start_time"
|
||||
labelClassName="justify-between flex-grow flex-row-reverse"
|
||||
placeholder="Select Date"
|
||||
min={new Date()}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-1/2 items-center gap-2 rounded-br-md border-l bg-white px-2 lg:max-w-[174px] lg:rounded-none lg:border-2">
|
||||
<PeopleIcon />
|
||||
<input
|
||||
type="number"
|
||||
placeholder={activeTab == "Parking" ? "Number of spaces" : "2 People"}
|
||||
className="remove-arrow w-full focus:outline-none"
|
||||
{...register("max_capacity")}
|
||||
/>
|
||||
</div>
|
||||
{spaceCategories.find((cat) => activeTab == cat.category)?.has_sizes == 1 && (
|
||||
<div className="flex w-1/2 items-center gap-2 rounded-br-md !border-l-0 bg-white px-2 lg:max-w-[174px] lg:rounded-none lg:border-2">
|
||||
<CustomSelectV2
|
||||
items={[
|
||||
{ label: "All Sizes", value: "" },
|
||||
{ label: "Small", value: 0 },
|
||||
{ label: "Medium", value: 1 },
|
||||
{ label: "Large", value: 2 },
|
||||
{ label: "X-Large", value: 3 },
|
||||
]}
|
||||
labelField="label"
|
||||
valueField="value"
|
||||
containerClassName="h-full flex-grow"
|
||||
placeholder={"All sizes"}
|
||||
control={control}
|
||||
name="size"
|
||||
optionsClassName={"mt-3 w-[150%] -left-1/3 -right-1/3 normal-case"}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="login-btn-gradient login-btn-gradient mt-4 flex w-full items-center justify-center gap-2 rounded-tr rounded-br rounded-tl rounded-bl py-3 px-6 tracking-wide text-white outline-none focus:outline-none lg:mt-0 lg:w-[unset] lg:rounded-tl-none lg:rounded-bl-none"
|
||||
id="search-button"
|
||||
>
|
||||
<SearchIcon />
|
||||
<span>Search</span>
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div className="mb-[48px] w-full">
|
||||
<h2 className="mb-[5px] px-4 text-center text-3xl font-bold normal-case md:text-4xl">Top-quality spaces and customer service</h2>
|
||||
<h5 className="mb-[8px] px-4 text-center text-md font-normal normal-case md:text-2xl">Your number one stop for renting and offering space(s) for work and leisure</h5>
|
||||
<div className="mt-10 flex justify-center mx-auto max-w-max">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 w-full items-center justify-even gap-[15px] text-xl text-gray-300 w-full">
|
||||
{spaceCategories.map((cat, idx) => (
|
||||
<div key={cat.id} className="w-full block">
|
||||
<span className="flex items-center gap-2 rounded-full py-1 text-black">
|
||||
<img src= {cat.icon} className="w-5 h-5 object-cover"/>
|
||||
{cat.category}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section className="container mx-auto pt-[40px] 2xl:px-16 px-6">
|
||||
<div className="mb-[26px] flex items-center justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-3xl font-bold">Popular</h3>
|
||||
<Link
|
||||
to={`/explore?section=popular`}
|
||||
className="my-text-gradient text-sm font-semibold tracking-wider"
|
||||
id="view-all-popular"
|
||||
>
|
||||
VIEW ALL POPULAR
|
||||
</Link>
|
||||
</div>
|
||||
{popularSpaces.length < 1 && (
|
||||
<p className="flex min-h-[400px] items-center justify-center text-center normal-case">
|
||||
<b>No Spaces found</b>
|
||||
</p>
|
||||
)}
|
||||
<InfiniteScroll
|
||||
dataLength={popularSpaces.length}
|
||||
next={() => {
|
||||
console.log("calling next", popularSpaces.length / FETCH_PER_SCROLL + 1);
|
||||
fetchPopularSpaces(Math.round(popularSpaces.length / FETCH_PER_SCROLL + 1));
|
||||
}}
|
||||
scrollThreshold={0.5}
|
||||
hasMore={popularSpaces.length < popularTotal}
|
||||
loader={<></>}
|
||||
endMessage={<></>}
|
||||
>
|
||||
{
|
||||
<div className="property-space-grid pb-[100px]">
|
||||
{popularSpaces.slice(0, 6).map((property, idx) => (
|
||||
<PropertySpaceCard
|
||||
key={property.id ?? idx}
|
||||
data={property}
|
||||
forceRender={setForceRender}
|
||||
/>
|
||||
))}
|
||||
{popularSpaces.length < 4 ? (
|
||||
<>
|
||||
<div className="hidden 2xl:block"></div>
|
||||
<div className="hidden lg:block"></div>
|
||||
<div className="hidden md:block"></div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
<section className="container mx-auto pb-[80px] normal-case md:pt-[40px] md:pb-[140px] 2xl:px-16 px-6">
|
||||
<div className="mb-[26px] flex items-center justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-3xl font-bold">Browse By Category</h3>
|
||||
<Link
|
||||
to={`/explore?category=§ion=popular&price_range=&space_name=&location=&from=&to=`}
|
||||
className="my-text-gradient text-sm font-semibold tracking-wider"
|
||||
id="view-all-popular"
|
||||
>
|
||||
VIEW ALL CATEGORIES
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="md:browse-grid flex flex-wrap justify-between w-full gap-4">
|
||||
{spaceCategories.slice(0,spaceCategories.length-4).map((tab, idx) => (
|
||||
<Link
|
||||
key={tab.id}
|
||||
to={`/explore?category=${tab.category}§ion=popular`}
|
||||
className="rounded-[6px] border w-full flex grow flex-cols w-full md:max-w- border-[#EAECF0] bg-[#F9FAFB]"
|
||||
>
|
||||
<img
|
||||
src={tab.image}
|
||||
alt={tab.category}
|
||||
className="h-24 w-full rounded-lg object-cover md:h-40"
|
||||
/>
|
||||
<p className="text-lg w-full py-3 px-5 text-right font-semibold">{tab.category}</p>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section className="container mx-auto py-[64px] 2xl:px-16 px-6">
|
||||
<div className="mb-[26px] flex items-center justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-2xl font-bold md:text-3xl">Top rated hosts</h3>
|
||||
<Link
|
||||
to={`/explore?section=hosts`}
|
||||
className="my-text-gradient text-sm font-semibold tracking-wider md:text-base"
|
||||
id="view-all-hosts"
|
||||
>
|
||||
VIEW ALL HOSTS
|
||||
</Link>
|
||||
</div>
|
||||
<HostCardSlider hosts={hosts} />
|
||||
</section>
|
||||
|
||||
<section className="container mx-auto pt-[40px] 2xl:px-16 px-6">
|
||||
<div className="mb-[26px] flex items-center justify-between border-b border-gray-300 px-6 pb-[12px] md:px-0">
|
||||
<h3 className="text-2xl font-bold md:text-3xl">New Spaces</h3>
|
||||
<Link
|
||||
to={`/explore?section=new-spaces`}
|
||||
className="my-text-gradient text-sm font-semibold tracking-wider md:text-base"
|
||||
id="view-all-new-spaces"
|
||||
>
|
||||
VIEW ALL NEW SPACES
|
||||
</Link>
|
||||
</div>
|
||||
{newSpaces.length == 0 && (
|
||||
<p className="flex min-h-[400px] items-center justify-center text-center normal-case">
|
||||
<b>No Spaces found</b>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InfiniteScroll
|
||||
dataLength={newSpaces.length}
|
||||
next={() => {
|
||||
fetchNewSpaces(Math.round(newSpaces.length / FETCH_PER_SCROLL + 1));
|
||||
}}
|
||||
scrollThreshold={0.9}
|
||||
hasMore={newSpaces.length < newTotal}
|
||||
loader={<></>}
|
||||
endMessage={
|
||||
<p className="text-center normal-case">
|
||||
<b></b>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
{
|
||||
<div className="property-space-grid pb-[100px]">
|
||||
{newSpaces.slice(0, 6).map((property, idx) => (
|
||||
<PropertySpaceCard
|
||||
key={property.id ?? idx}
|
||||
data={property}
|
||||
forceRender={setForceRender}
|
||||
/>
|
||||
))}
|
||||
{newSpaces.length < 4 ? (
|
||||
<>
|
||||
<div className="hidden 2xl:block"></div>
|
||||
<div className="hidden lg:block"></div>
|
||||
<div className="hidden md:block"></div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
}
|
||||
</InfiniteScroll>
|
||||
</section>
|
||||
|
||||
{(!isLoggedIn || userRole === "customer") && (
|
||||
<section className="container bg-gray-100 mx-auto py-20 rounded-xl mb-12 2xl:px-16 px-6 md:flex justify-between md:flex-nowrap items-center">
|
||||
<div className="w-full md:w-[25%] mt-8 md:mt-0">
|
||||
<h3 className="text-xl pb-3 leading-10 font-bold md:text-4xl">Host Your Space Today!</h3>
|
||||
<p className="text-base text-left my-4">
|
||||
Unlock new income opportunities by listing your space on our platform. Join a community of successful hosts, reach thousands of potential guests, and maximize your property's potential.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => authState.originalRole === "customer" ? navigate("/become-a-host") : navigate("/signup")}
|
||||
className="login-btn-gradient mb-4 py-3 px-4 rounded-3xl text-sm text-white">
|
||||
Start Hosting Now
|
||||
</button>
|
||||
</div>
|
||||
<div className="hidden md:flex justify-between w-full md:w-1/2">
|
||||
<img
|
||||
src="https://freepngimg.com/thumb/building/154733-building-hotel-download-hq.png"
|
||||
alt="Descriptive Alt Text"
|
||||
className="w-full md:w-[70%] h-auto rounded-lg object-cover"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
anchorId="search-button"
|
||||
place="right"
|
||||
content="Search"
|
||||
noArrow
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId="view-all-popular"
|
||||
place="bottom"
|
||||
content="All popular"
|
||||
noArrow
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId="view-all-hosts"
|
||||
place="bottom"
|
||||
content="All hosts"
|
||||
noArrow
|
||||
/>
|
||||
<Tooltip
|
||||
anchorId="view-all-new-spaces"
|
||||
place="bottom"
|
||||
content="New spaces"
|
||||
noArrow
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -0,0 +1,428 @@
|
||||
import React, { useEffect, useState } from "react";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { useForm } from "react-hook-form";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { AuthContext } from "@/authContext";
|
||||
import { oauthLoginApi } from "@/utils/callCustomAPI";
|
||||
import LoadingButton from "@/components/frontend/LoadingButton";
|
||||
import Icon from "@/components/Icons";
|
||||
import SuggestPasswordChangeModal from "./SuggestPasswordChangeModal";
|
||||
import SuggestResendVerificationModal from "./SuggestResendVerificationModal";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import axios from "axios";
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
export default function LoginPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const role = searchParams.get("role") || "customer";
|
||||
const [hideDownloadButton, setHideDownloadButton] = useState(false);
|
||||
const [deferredPrompt, setDeferredPrompt] = useState(null);
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email("Email must be valid").required("Email is required"),
|
||||
password: yup.string().required("Password is required"),
|
||||
});
|
||||
|
||||
const { dispatch: authDispatch } = React.useContext(AuthContext);
|
||||
const { dispatch: globalDispatch } = React.useContext(GlobalContext);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [suggestPasswordChange, setSuggestPasswordChange] = React.useState(false);
|
||||
const [suggestResendVerification, setSuggestResendVerification] = React.useState(false);
|
||||
const [inCorrectPasswordCount, setInCorrectPasswordCount] = React.useState(0);
|
||||
const [disableEmails, setDisableEmails] = React.useState([]);
|
||||
const [disableLogin, setDisableLogin] = React.useState(false);
|
||||
const [locationInfo, setLocationInfo] = useState(null);
|
||||
const [located, setLocated] = useState(false);
|
||||
const [latitude, setLatitude] = useState('');
|
||||
const [longitude, setLongitude] = useState('');
|
||||
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
watch,
|
||||
formState: { errors, isSubmitting, isDirty },
|
||||
} = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
password: "",
|
||||
},
|
||||
});
|
||||
|
||||
const email = watch("email");
|
||||
|
||||
const checkParams = () => {
|
||||
if (searchParams.get('error')) {
|
||||
showToast(dispatch, searchParams.get('message'), 5000, "error")
|
||||
navigate("/login")
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
let setter;
|
||||
if (!setter) {
|
||||
checkParams()
|
||||
}
|
||||
return () => {
|
||||
setter = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const handleBeforeInstallPrompt = (event) => {
|
||||
event.preventDefault();
|
||||
setDeferredPrompt(event);
|
||||
};
|
||||
|
||||
window.addEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('beforeinstallprompt', handleBeforeInstallPrompt);
|
||||
};
|
||||
}, []);
|
||||
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
try {
|
||||
const result = await sdk.oauthLoginApi("google", role);
|
||||
const data = (searchParams.get("oauth"))
|
||||
window.open(result, "_self");
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
showToast(authDispatch, error.message)
|
||||
}
|
||||
};
|
||||
const handleFacebookLogin = async () => {
|
||||
const data = (JSON.parse(searchParams.get("oauth")))
|
||||
try {
|
||||
const result = await sdk.oauthLoginApi("facebook", role);
|
||||
window.open(result, "_self");
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
showToast(authDispatch, error.message)
|
||||
}
|
||||
};
|
||||
const handleAppleLogin = async () => {
|
||||
try {
|
||||
const result = await sdk.oauthLoginApi("apple", role);
|
||||
window.open(result, "_self");
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
};
|
||||
|
||||
var options = {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 5000,
|
||||
maximumAge: 0,
|
||||
};
|
||||
function success(pos) {
|
||||
var crd = pos.coords;
|
||||
setLatitude(crd.latitude);
|
||||
setLongitude(crd.longitude);
|
||||
setLocated(true)
|
||||
}
|
||||
|
||||
const handleDownloadNowClick = () => {
|
||||
console.log(deferredPrompt)
|
||||
if (deferredPrompt) {
|
||||
deferredPrompt.prompt();
|
||||
|
||||
deferredPrompt.userChoice.then((choiceResult) => {
|
||||
if (choiceResult.outcome === 'accepted') {
|
||||
console.log('User accepted the A2HS prompt');
|
||||
} else {
|
||||
console.log('User dismissed the A2HS prompt');
|
||||
}
|
||||
});
|
||||
|
||||
setDeferredPrompt(null);
|
||||
}
|
||||
};
|
||||
|
||||
function _errors(err) {
|
||||
console.warn(`ERROR(${err.code}): ${err.message}`);
|
||||
}
|
||||
|
||||
// useEffect(() => {
|
||||
// if (navigator && navigator?.geolocation && navigator?.geolocation) {
|
||||
// navigator?.permissions
|
||||
// .query({ name: "geolocation" })
|
||||
// .then(function (result) {
|
||||
// if (result.state === "granted") {
|
||||
// //If granted then you can directly call your function here
|
||||
// showToast(globalDispatch, "Access to location is granted");
|
||||
// navigator.geolocation.getCurrentPosition(success, _errors, options);
|
||||
// } else if (result.state === "prompt") {
|
||||
// //If prompt then the user will be asked to give permission
|
||||
// // showToast(globalDispatch, "Access to location needs to be granted");
|
||||
// navigator.geolocation.getCurrentPosition(success, _errors, options);
|
||||
// globalDispatch({
|
||||
// type: "SHOW_ERROR",
|
||||
// payload: {
|
||||
// heading: "Location Access",
|
||||
// message: "Access to location needs to be granted",
|
||||
// },
|
||||
// });
|
||||
// } else if (result.state === "denied") {
|
||||
// //If denied then you have to show instructions to enable location
|
||||
// // showToast(globalDispatch, "Access to location is denied");
|
||||
// globalDispatch({
|
||||
// type: "SHOW_ERROR",
|
||||
// payload: {
|
||||
// heading: "Location Access",
|
||||
// message: "Access to location needs to be granted",
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// }).catch(error => {
|
||||
// globalDispatch({
|
||||
// type: "SHOW_ERROR",
|
||||
// payload: {
|
||||
// heading: "Location Access",
|
||||
// message: "Access to location needs to be granted",
|
||||
// },
|
||||
// });
|
||||
// });
|
||||
// } else {
|
||||
// // console.log("Geolocation is not supported by this browser.");
|
||||
// globalDispatch({
|
||||
// type: "SHOW_ERROR",
|
||||
// payload: {
|
||||
// heading: "Location Access",
|
||||
// message: "Geolocation is not supported by this browser.",
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
// }, [])
|
||||
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
|
||||
// if (located || !located) {
|
||||
try {
|
||||
// const response = await axios.get(
|
||||
// `https://maps.googleapis.com/maps/api/geocode/json?latlng=${latitude},${longitude}&key=${import.meta.env.VITE_GOOGLE_API_KEY}`
|
||||
// );
|
||||
|
||||
// const addressComponents = response.data.results[0]?.address_components;
|
||||
// const state = addressComponents.find(
|
||||
// component => component.types.includes('administrative_area_level_1')
|
||||
// );
|
||||
|
||||
// if (["Florida", "California", "New York", "Lagos", "Islamabad", "Rawalpindi"].includes(state.long_name)) {
|
||||
setLocationInfo('Location Granted.');
|
||||
try {
|
||||
const result = await sdk.customLogin(data);
|
||||
if (!result.error) {
|
||||
authDispatch({ type: "LOGIN", payload: { ...result, originalRole: result.role } });
|
||||
if (["superadmin", "admin"].includes(result.role)) {
|
||||
navigate(searchParams.get("redirect_uri") ?? "/admin/dashboard");
|
||||
} else {
|
||||
navigate(searchParams.get("redirect_uri") ?? "/");
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.message == "Your account is inactive" || err.message == "This email is not registered") {
|
||||
setDisableEmails((prev) => {
|
||||
const copy = [...prev, data.email.toLowerCase()];
|
||||
return copy;
|
||||
});
|
||||
setDisableLogin(true);
|
||||
}
|
||||
if (err.message == "Your email is not verified") {
|
||||
setSuggestResendVerification(true);
|
||||
}
|
||||
if (err.message == "Invalid Password") {
|
||||
setInCorrectPasswordCount((prev) => prev + 1);
|
||||
} else {
|
||||
setInCorrectPasswordCount(0);
|
||||
}
|
||||
setError("email", {
|
||||
type: "manual",
|
||||
message: err.message,
|
||||
});
|
||||
if (inCorrectPasswordCount >= 3) {
|
||||
setSuggestPasswordChange(true);
|
||||
}
|
||||
}
|
||||
// } else {
|
||||
// setLocationInfo('Location Denied.');
|
||||
// globalDispatch({
|
||||
// type: "SHOW_ERROR",
|
||||
// payload: {
|
||||
// heading: "Location Denied",
|
||||
// message: "Access to site is only allowed for Florida, California and New York Residents",
|
||||
// },
|
||||
// });
|
||||
// }
|
||||
} catch (error) {
|
||||
console.error('Error fetching location information', error);
|
||||
// setDisableLogin(true);
|
||||
};
|
||||
|
||||
// }
|
||||
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="absolute top-0 left-0 pt-4 pl-6 md:pl-16">
|
||||
<Link to="/">
|
||||
<Icon
|
||||
type="logo"
|
||||
fill="fill-[#101828]"
|
||||
/>
|
||||
</Link>
|
||||
</header>
|
||||
<div className="flex min-h-screen w-full justify-center">
|
||||
<section className="flex w-full flex-col items-center justify-center bg-white md:w-1/2">
|
||||
<form
|
||||
className="flex w-full max-w-md flex-col px-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<h1 className="mb-8 text-center text-3xl font-semibold md:text-5xl md:font-bold">Log In</h1>
|
||||
<input
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
className="mb-8 resize-none rounded-sm border-2 bg-transparent p-2 px-4 focus:outline-none active:outline-none"
|
||||
{...register("email", {
|
||||
onChange: (e) => {
|
||||
if (disableEmails.includes(e.target.value.toLowerCase())) {
|
||||
setDisableLogin(true);
|
||||
} else {
|
||||
setDisableLogin(false);
|
||||
}
|
||||
},
|
||||
})}
|
||||
placeholder="Email"
|
||||
/>
|
||||
<div className="relative mb-4 flex items-center justify-between rounded-sm border-2 bg-transparent">
|
||||
<input
|
||||
autoComplete={showPassword ? "off" : "new-password"}
|
||||
type={showPassword ? "text" : "password"}
|
||||
{...register("password")}
|
||||
className="flex-grow border-0 p-2 px-4 focus:outline-none active:outline-none "
|
||||
placeholder="Password"
|
||||
/>{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-1"
|
||||
>
|
||||
{" "}
|
||||
{showPassword ? (
|
||||
<img
|
||||
src="/show.png"
|
||||
alt=""
|
||||
className="m-2 w-6"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/invisible.png"
|
||||
alt=""
|
||||
className="m-2 w-6"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
<Link
|
||||
to={"/request-reset?role=" + role}
|
||||
className="my-text-gradient mb-6 self-end text-sm font-semibold"
|
||||
>
|
||||
Forgot Password
|
||||
</Link>
|
||||
{isDirty && Object.entries(errors).length > 0 ? (
|
||||
<p className="error-vibrate my-3 rounded-md border border-[#C42945] bg-white py-2 px-3 text-center text-sm normal-case text-[#C42945]">{Object.values(errors)[0].message}</p>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
disabled={disableLogin}
|
||||
loading={isSubmitting}
|
||||
type="submit"
|
||||
className={`login-btn-gradient rounded tracking-wide text-white outline-none focus:outline-none ${isSubmitting ? "py-1" : "py-2"}`}
|
||||
>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</form>
|
||||
<div className="hr my-6 text-center">OR</div>
|
||||
<div className="oauth flex w-full max-w-md flex-col gap-4 px-6 text-[#344054]">
|
||||
<button
|
||||
onClick={() => handleGoogleLogin()}
|
||||
className="flex items-center justify-center gap-2 border-2 py-[10px]"
|
||||
>
|
||||
<img
|
||||
src="/google-icon.png"
|
||||
className="h-[18px] w-[18px]"
|
||||
/>
|
||||
<span>Sign in With Google</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFacebookLogin()}
|
||||
className="flex items-center justify-center gap-2 border-2 py-[10px]"
|
||||
>
|
||||
<img
|
||||
src="/facebook-icon.png"
|
||||
className="h-[16px] w-[16px]"
|
||||
/>
|
||||
<span>Sign in With Facebook</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAppleLogin()}
|
||||
className="flex items-center justify-center gap-2 border-2 py-[10px]"
|
||||
>
|
||||
<img
|
||||
src="/apple-icon.png"
|
||||
className="h-[16px] w-[16px]"
|
||||
/>
|
||||
<span>Sign in With Apple</span>
|
||||
</button>
|
||||
<div>
|
||||
<h3 className="mb-2 text-center text-sm normal-case text-gray-800">
|
||||
Don't have an account?{" "}
|
||||
<Link
|
||||
to={"/signup"}
|
||||
className="my-text-gradient mb-8 self-end text-sm font-semibold"
|
||||
>
|
||||
Sign up
|
||||
</Link>{" "}
|
||||
</h3>
|
||||
<h3 className="text-center text-sm normal-case text-gray-800">
|
||||
Account issues? Please visit our{" "}
|
||||
<Link
|
||||
to={"/faq"}
|
||||
className="my-text-gradient mb-8 self-end text-sm font-semibold"
|
||||
>
|
||||
FAQ page
|
||||
</Link>{" "}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
<section
|
||||
style={{ backgroundImage: `url(${"/login-bg.jpg"})`, backgroundSize: "cover", backgroundPosition: "center" }}
|
||||
className="hidden w-1/2 md:block"
|
||||
></section>
|
||||
</div>
|
||||
|
||||
<SuggestPasswordChangeModal
|
||||
modalOpen={suggestPasswordChange}
|
||||
closeModal={() => setSuggestPasswordChange(false)}
|
||||
/>
|
||||
<SuggestResendVerificationModal
|
||||
modalOpen={suggestResendVerification}
|
||||
closeModal={() => setSuggestResendVerification(false)}
|
||||
email={email}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { parseJsonSafely } from "@/utils/utils";
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import { AuthContext } from "@/authContext";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import TreeSDK from "@/utils/TreeSDK";
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
|
||||
const OauthRedirect = () => {
|
||||
const { dispatch: authDispatch } = React.useContext(AuthContext);
|
||||
const { dispatch: globalDispatch } = React.useContext(GlobalContext);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const treeSdk = new TreeSDK();
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
useEffect(() => {
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const data = parseJsonSafely(urlParams.get("data"), {});
|
||||
if (data?.error) {
|
||||
showToast(globalDispatch, data?.message, 3000, "error")
|
||||
navigate("/login");
|
||||
} else {
|
||||
authDispatch({ type: "LOGIN", payload: data });
|
||||
localStorage.setItem("first_login", data.user_id);
|
||||
localStorage.setItem("token", data.token ?? data.access_token);
|
||||
//register db
|
||||
if (!localStorage.getItem("device-uid") || localStorage.getItem("device-uid") !== undefined) {
|
||||
sdk.setUUId();
|
||||
}
|
||||
navigate("/");
|
||||
}
|
||||
}, []);
|
||||
|
||||
return <h1 className="mt-96 text-7xl"></h1>;
|
||||
};
|
||||
|
||||
export default OauthRedirect;
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import Icon from "@/components/Icons";
|
||||
import { LoginContextProvider } from "./loginContext";
|
||||
|
||||
const PageWrapper = () => {
|
||||
return (
|
||||
<LoginContextProvider>
|
||||
<div>
|
||||
<header className="absolute top-0 left-0 pt-4 md:pl-16 pl-6">
|
||||
<Link to="/">
|
||||
<Icon
|
||||
type="logo"
|
||||
fill="fill-[#101828]"
|
||||
/>
|
||||
</Link>
|
||||
</header>
|
||||
<Outlet />
|
||||
</div>
|
||||
</LoginContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageWrapper;
|
||||
@@ -0,0 +1,120 @@
|
||||
import React from "react";
|
||||
import { useState } from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import * as yup from "yup";
|
||||
import { useForm } from "react-hook-form";
|
||||
import GreenCheckIcon from "@/components/frontend/icons/GreenCheckIcon";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import Icon from "@/components/Icons";
|
||||
|
||||
export default function RequestReset() {
|
||||
const [codeSent, setCodeSent] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
const schema = yup.object({
|
||||
email: yup.string().email("Invalid email").required("Email is required"),
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
setError,
|
||||
} = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
email: "",
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
console.log("submitting", data);
|
||||
setLoading(true);
|
||||
const role = await sdk.callRawAPI("/v2/api/custom/ergo/userinfo/PAGINATE", {
|
||||
"where": [
|
||||
`email='${data.email}'`
|
||||
],
|
||||
"page": 1,
|
||||
"limit": 10
|
||||
}, "POST");
|
||||
const originalRole = (role?.list[0]?.role)
|
||||
|
||||
try {
|
||||
await sdk.forgot(data.email, originalRole);
|
||||
setCodeSent(true);
|
||||
setTimeout(() => {
|
||||
navigate("/login");
|
||||
}, 6000);
|
||||
} catch (err) {
|
||||
setError("email", {
|
||||
type: "manual",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<header className="absolute top-0 left-0 pt-4 md:pl-16 pl-6">
|
||||
<Link to="/">
|
||||
<Icon
|
||||
type="logo"
|
||||
fill="fill-[#101828]"
|
||||
/>
|
||||
</Link>
|
||||
</header>
|
||||
<div className="bg-login h-screen flex items-center justify-center relative normal-case">
|
||||
{!codeSent && (
|
||||
<form
|
||||
className="bg-white p-5 w-[472px] flex flex-col mb-40"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<h2 className="mb-4 text-2xl font-semibold">Request Password Reset</h2>
|
||||
<p className="mb-4 text-gray-500 text-lg">We will email you a link to reset your password.</p>
|
||||
<input
|
||||
autoComplete="off"
|
||||
type="text"
|
||||
{...register("email")}
|
||||
className="resize-none border-2 rounded-sm p-2 px-4 bg-transparent mb-4 focus:outline-none active:outline-none"
|
||||
placeholder="Email"
|
||||
/>
|
||||
{errors.email?.message ? (
|
||||
<p className="border border-[#C42945] py-2 px-3 rounded-md bg-white text-[#C42945] text-center text-sm my-3 normal-case error-vibrate">{errors.email.message}</p>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
type="submit"
|
||||
className={`login-btn-gradient text-white tracking-wide outline-none focus:outline-none rounded ${loading ? "py-1" : "py-2"}`}
|
||||
>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{codeSent && (
|
||||
<div className="bg-white p-5 w-[422px] flex flex-col mb-40">
|
||||
<h2 className="mb-4 text-2xl font-semibold">
|
||||
<GreenCheckIcon />
|
||||
Email Sent!
|
||||
</h2>
|
||||
<p className="mb-4 text-gray-500 text-sm">You should receive an email with the instruction to reset your password. Sometimes it will go to your spam folder.</p>
|
||||
</div>
|
||||
)}
|
||||
<footer className="absolute bottom-[27px] lowercase left-0 right-0 flex justify-between text-[#667085] items-center 2xl:px-16 container mx-auto">
|
||||
<p>2022 in ergo</p>
|
||||
<p>Contact: Support@ergobooking.com</p>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import React, { useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import * as yup from "yup";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import moment from "moment";
|
||||
import commonPasswords from "../SignUp/common-passwords.json";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
|
||||
const ResetForm = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const role = searchParams.get("role") || "customer";
|
||||
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const schema = yup.object({
|
||||
code: yup.number().required("Code is required").typeError("Code is required").positive("Invalid Code").integer(),
|
||||
password: yup
|
||||
.string()
|
||||
.required("Password is required")
|
||||
.min(10, "Password must be at least 10 characters long")
|
||||
.matches(/^(?=.*[0-9])/, "Password must contain at least one digit(0-9)")
|
||||
.matches(/^(?=.*[a-z])/, "Password must contain at least one lowercase letter")
|
||||
.matches(/^(?=.*[A-Z])/, "Password must contain at least one uppercase letter")
|
||||
.matches(/^(?=.*[!@#\$%\^&\*])/, "Password must contain at least one symbol")
|
||||
.test("is-not-dictionary", "Password must not contain a common word", (val) => {
|
||||
return commonPasswords.every((pass) => !val.includes(pass));
|
||||
})
|
||||
.test("does-not-contain-user-info", "Password must not contain your name or date of birth", (val) => {
|
||||
const d = moment("2001-01-01");
|
||||
return ["john", "doe", d.format("yyyyMMDD"), d.format("DDMMyyyy"), d.format("MMDDyyyy"), d.format("YYMMDD"), d.format("MMDDYY"), d.format("DDMMYY")].every(
|
||||
(field) => field.trim() == "" || !val.toLowerCase().includes(field.toLowerCase()),
|
||||
);
|
||||
}),
|
||||
confirm_password: yup.string().oneOf([yup.ref("password"), null], "Passwords don't match"),
|
||||
});
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
trigger,
|
||||
formState: { errors, dirtyFields },
|
||||
setError,
|
||||
} = useForm({ resolver: yupResolver(schema), defaultValues: { password: "" }, criteriaMode: "all" });
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
console.log("submitting", data);
|
||||
setLoading(true);
|
||||
try {
|
||||
// await sdk.reset(searchParams.get("token"), data.code, data.password);
|
||||
await callCustomAPI("reset", "post", { code: data.code, password: data.password, token: searchParams.get("token") }, "");
|
||||
navigate("/login?role=" + role);
|
||||
} catch (err) {
|
||||
setError("code", { message: err.message == "Password is same as old password" ? "Please use a different password" : err.message });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
function getPasswordErrors() {
|
||||
var arr = [];
|
||||
if (Array.isArray(errors.password?.types.matches)) {
|
||||
arr = [...errors.password.types.matches];
|
||||
}
|
||||
if (typeof errors.password?.types.matches === "string") {
|
||||
arr.push(errors.password.types.matches);
|
||||
}
|
||||
if (errors.password?.types.min) {
|
||||
arr.push(errors.password.types.min);
|
||||
}
|
||||
if (errors.password?.types["does-not-contain-user-info"]) {
|
||||
arr.push(errors.password?.types["does-not-contain-user-info"]);
|
||||
}
|
||||
if (errors.password?.types["is-not-dictionary"]) {
|
||||
arr.push(errors.password?.types["is-not-dictionary"]);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
const passwordErrors = getPasswordErrors();
|
||||
|
||||
return (
|
||||
<div className="bg-login flex h-screen items-center justify-center">
|
||||
<form
|
||||
className="flex w-96 flex-col bg-white p-5"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<h2 className="mb-4 text-2xl font-semibold">Set New Password</h2>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
{...register("code")}
|
||||
className="remove-arrows mb-4 border-2 py-2 px-4 focus:outline-none"
|
||||
autoComplete="off"
|
||||
placeholder="Enter code"
|
||||
/>
|
||||
<div className="relative mb-4 flex justify-between rounded-sm border-2 bg-transparent">
|
||||
<input
|
||||
autoComplete={showPassword ? "off" : "new-password"}
|
||||
type={showPassword ? "text" : "password"}
|
||||
{...register("password", { onChange: () => trigger("password") })}
|
||||
className="flex-grow border-0 p-2 px-4 focus:outline-none active:outline-none "
|
||||
placeholder="Password"
|
||||
/>{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-1 top-[20%]"
|
||||
>
|
||||
{" "}
|
||||
{showPassword ? (
|
||||
<img
|
||||
src="/show.png"
|
||||
alt=""
|
||||
className="mr-2 w-6"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/invisible.png"
|
||||
alt=""
|
||||
className="mr-2 w-6"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{dirtyFields.password && (
|
||||
<div className="fade-in mb-4 space-y-2 rounded-sm border border-[#C42945] p-3 text-sm normal-case text-[#C42945] empty:hidden">
|
||||
{passwordErrors.map((msg) => (
|
||||
<p>{msg}</p>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="relative mb-4 flex justify-between rounded-sm border-2 bg-transparent">
|
||||
<input
|
||||
autoComplete={showConfirmPassword ? "off" : "new-password"}
|
||||
type={showConfirmPassword ? "text" : "password"}
|
||||
{...register("confirm_password")}
|
||||
className="flex-grow border-0 p-2 px-4 focus:outline-none active:outline-none "
|
||||
placeholder="Confirm password"
|
||||
/>{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowConfirmPassword((prev) => !prev)}
|
||||
className="absolute right-1 top-[20%]"
|
||||
>
|
||||
{" "}
|
||||
{showConfirmPassword ? (
|
||||
<img
|
||||
src="/show.png"
|
||||
alt=""
|
||||
className="mr-2 w-6"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/invisible.png"
|
||||
alt=""
|
||||
className="mr-2 w-6"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{Object.entries(errors).length > 0 && dirtyFields.password && !errors.password ? (
|
||||
<p className="error-vibrate my-3 rounded-md border border-[#C42945] bg-white py-2 px-3 text-center text-sm normal-case text-[#C42945]">{Object.values(errors)[0].message}</p>
|
||||
) : null}
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
type="submit"
|
||||
className={`login-btn-gradient rounded tracking-wide text-white outline-none focus:outline-none ${loading ? "loading py-1" : "py-2"}`}
|
||||
>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResetForm;
|
||||
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import { Navigate } from "react-router";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
const ResetRedirect = ({ role }) => {
|
||||
const [searchParams] = useSearchParams();
|
||||
return <Navigate to={`/reset-password?token=${searchParams.get("token")}&role=${role}`} />;
|
||||
};
|
||||
|
||||
export default ResetRedirect;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import React, { Fragment } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export default function SuggestPasswordChangeModal({ modalOpen, closeModal }) {
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={modalOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
onClose={closeModal}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
Can't remember your password?
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">We noticed you tried to login several times, would you like to change your password</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-4 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border px-4 py-2 text-sm font-medium focus:outline-none"
|
||||
onClick={closeModal}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<Link
|
||||
to={"/request-reset"}
|
||||
className={`inline-flex justify-center rounded-md py-2 px-4 text-sm font-medium login-btn-gradient text-white`}
|
||||
>
|
||||
Reset Password
|
||||
</Link>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import React, { Fragment, useContext, useState } from "react";
|
||||
|
||||
export default function SuggestResendVerificationModal({ modalOpen, closeModal, email }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { dispatch } = useContext(AuthContext);
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
const [ctrl] = useState(new AbortController());
|
||||
|
||||
async function sendEmailVerification() {
|
||||
setLoading(true);
|
||||
const sdk = new MkdSDK();
|
||||
try {
|
||||
await sdk.callRawAPI("/v2/api/custom/ergo/resend-verification-email", { email }, "POST", ctrl.signal);
|
||||
showToast(globalDispatch, "Email sent, Please check your inbox", 8000);
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
if (err.name == "AbortError") {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
tokenExpireError(dispatch, err.message);
|
||||
showToast(globalDispatch, err.message, 4000, "ERROR");
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<Transition
|
||||
appear
|
||||
show={modalOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
onClose={closeModal}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900"
|
||||
>
|
||||
Resend verification email?
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<p className="text-sm text-gray-500">
|
||||
Your email address is not verified, would you like us to resend verification email to <b>{email}</b>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex justify-end gap-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex justify-center rounded-md border px-4 py-2 text-sm font-medium focus:outline-none"
|
||||
onClick={() => {
|
||||
console.log("aborting");
|
||||
ctrl.abort();
|
||||
closeModal();
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<LoadingButton
|
||||
type="button"
|
||||
loading={loading}
|
||||
onClick={sendEmailVerification}
|
||||
className={`login-btn-gradient inline-flex justify-center rounded-md px-4 text-sm font-medium text-white ${loading ? "py-1" : "py-2"}`}
|
||||
>
|
||||
Yes, resend
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, { createContext, useContext, useReducer } from "react";
|
||||
|
||||
const initialLoginData = {
|
||||
email: "",
|
||||
password: "",
|
||||
forgetPasswordEmail: "",
|
||||
reset_token: ""
|
||||
};
|
||||
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "SET_DATA":
|
||||
return { ...state, email: action.payload.email, password: action.payload.password };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// create context here
|
||||
const loginContext = createContext({});
|
||||
|
||||
// wrap this component around App.tsx to get access to userData in all components
|
||||
const LoginContextProvider = ({ children }) => {
|
||||
const [loginData, dispatch] = useReducer(reducer, initialLoginData);
|
||||
|
||||
return <loginContext.Provider value={{ loginData, dispatch }}>{children}</loginContext.Provider>;
|
||||
};
|
||||
|
||||
// use this custom hook to get the data in any component in component tree
|
||||
const useLoginContext = () => useContext(loginContext);
|
||||
export { useLoginContext, LoginContextProvider };
|
||||
@@ -0,0 +1,141 @@
|
||||
import ThreeDotsMenu from "@/components/frontend/ThreeDotsMenu";
|
||||
import { ARCHIVE_STATUS } from "@/utils/constants";
|
||||
import moment from "moment";
|
||||
import React, { useEffect, useState } from "react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
|
||||
|
||||
const formatDate = (time) => {
|
||||
let currentTime = moment(new Date());
|
||||
let messageDate = moment(time);
|
||||
if (currentTime.diff(messageDate, "days") > 1) {
|
||||
return moment(messageDate).format("Do MMM");
|
||||
} else {
|
||||
return moment(messageDate).format("hh:mm A");
|
||||
}
|
||||
};
|
||||
|
||||
export default function ChatTile({ markMessagesAsRead, first, virtual, last, room, rooms, activeRoomId, setActiveRoom, setActiveBooking, setActiveProperty, setMobileChatSection, messages, deleteRoom, archiveRoom, unArchiveRoom }) {
|
||||
const ctrl = new AbortController();
|
||||
let sdk = new MkdSDK();
|
||||
const [photo, setPhoto] = useState()
|
||||
const [roomUnreadCounter, setRoomUnreadCounter] = useState([])
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const params = searchParams.get("room_id")
|
||||
|
||||
async function getOtherUser() {
|
||||
await sdk.setTable("user")
|
||||
const payload = { id: room?.other_user_id }
|
||||
const result = await sdk.callRestAPI(payload, "GET");
|
||||
setPhoto(result?.model?.is_photo_approved !== 1 ? null : result?.model?.photo)
|
||||
return "yes"
|
||||
}
|
||||
|
||||
async function getMessages(room_id) {
|
||||
const result = await sdk.getChats(room_id);
|
||||
const unread = result.model.filter((msg) => msg.unread === 1 && msg.chat.user_id !== Number(localStorage.getItem("user"))).map((msg) => msg.id);
|
||||
markMessagesAsRead(room.id, unread);
|
||||
return result.model
|
||||
}
|
||||
async function getAllMessages(room_id) {
|
||||
const result = await sdk.getChats(room_id);
|
||||
const unread = result.model.filter((msg) => msg.unread === 1 && msg.chat.user_id !== Number(localStorage.getItem("user")));
|
||||
setRoomUnreadCounter(unread)
|
||||
}
|
||||
|
||||
async function markMessagesAsRead(room_id, arr) {
|
||||
sdk.setTable("chat");
|
||||
await Promise.all(arr.map((id) => sdk.callRestAPI({ id, unread: 0 }, "PUT")));
|
||||
setRoomUnreadCounter((prev) => (arr.length > prev ? 0 : prev - arr.length));
|
||||
}
|
||||
|
||||
async function getBookingDetails() {
|
||||
setActiveProperty({});
|
||||
setActiveBooking({});
|
||||
if (room?.booking_id !== null) {
|
||||
await sdk.setTable("booking")
|
||||
const payload = { id: room?.booking_id }
|
||||
const result = await sdk.callRestAPI(payload, "GET");
|
||||
setActiveBooking(result.model)
|
||||
setActiveProperty({});
|
||||
} else {
|
||||
const user_id = localStorage.getItem("user");
|
||||
const where = [`ergo_property_spaces.id = ${Number(room?.property_id)} AND ergo_property_spaces.deleted_at IS NULL`];
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/popular/PAGINATE", { page: 1, limit: 1, user_id: Number(user_id), where, all: true }, "POST", ctrl.signal);
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setActiveProperty(result.list[0]);
|
||||
setActiveBooking({})
|
||||
} else setActiveProperty({})
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getOtherUser()
|
||||
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (room?.id === "temp") return;
|
||||
getAllMessages(room.id)
|
||||
}, [room]);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${room.id && activeRoomId == room.id ? "chat-active " : ""} lg:flex w-full justify-between items-center border-b p-3`}
|
||||
id={`chat-tile-btn-${room.id}`}
|
||||
>
|
||||
<div
|
||||
onClick={() => {
|
||||
setActiveRoom(room);
|
||||
setMobileChatSection(true);
|
||||
getMessages(room.id)
|
||||
getBookingDetails()
|
||||
}}
|
||||
className="flex gap-2 mr-2 cursor-pointer items-center justify-between">
|
||||
<img
|
||||
src={photo ?? "/default.png"}
|
||||
alt=""
|
||||
className="h-[48px] w-[48px] rounded-full border-2 border-[#D0D5DD] object-cover"
|
||||
/>
|
||||
<div className="flex flex-col items-start w-fit">
|
||||
<h5 className="text-sm font-semibold capitalize">
|
||||
{first || <Skeleton width={100} />} {last}
|
||||
</h5>
|
||||
<p className="text-xs font-light">{" " || <Skeleton width={80} />}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap- items-center justify-end relative">
|
||||
{room?.id !== "temp" && roomUnreadCounter.length > 0 && <span className="bg-my-gradient h-[20px] w-[20px] rounded-full text-center text-xs leading-[1.7] text-white flex items-center justify-center">{roomUnreadCounter.length}</span>}
|
||||
<span style={{ fontSize: "10px" }} className="font-light w-[50px] block text-center">{formatDate(room.update_at)}</span>
|
||||
<div className="block">
|
||||
<ThreeDotsMenu
|
||||
direction="vert"
|
||||
items={[
|
||||
{
|
||||
label: "Delete chat",
|
||||
icon: null,
|
||||
onClick: () => deleteRoom(room.id),
|
||||
},
|
||||
{
|
||||
label: "Archive chat",
|
||||
icon: <></>,
|
||||
onClick: () => archiveRoom(room.id),
|
||||
notShow: room.is_archive == ARCHIVE_STATUS.IS_ARCHIVE,
|
||||
},
|
||||
{
|
||||
label: "Unarchive chat",
|
||||
icon: <></>,
|
||||
onClick: () => unArchiveRoom(room.id),
|
||||
notShow: room.is_archive == ARCHIVE_STATUS.NOT_ARCHIVE,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import { BOOKING_STATUS } from "@/utils/constants";
|
||||
import * as yup from "yup";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import React, { useContext } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
|
||||
|
||||
const ImagePreviewModal = ({ activeRoom, getRooms, setMessages, state, setRooms, spaceId, setShowImagePreviewModal, activeBooking }) => {
|
||||
const [imageSrc, setImageSrc] = React.useState("");
|
||||
const [messageError, setMessageError] = React.useState("");
|
||||
const [uploadedFile, setUploadedFile] = React.useState();
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { state: globalState, dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
const schema = yup
|
||||
.object({
|
||||
photo: yup.string()
|
||||
})
|
||||
.required();
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
|
||||
function handleFilePreview(file){
|
||||
if (file) {
|
||||
setUploadedFile(file[0])
|
||||
setImageSrc(URL.createObjectURL(file[0]));
|
||||
}
|
||||
};
|
||||
|
||||
async function sendImageMessage() {
|
||||
|
||||
if (imageSrc === "") {
|
||||
setMessageError("Please select an Image");
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
const handleImageUpload = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const upload = await sdk.uploadImage(formData);
|
||||
return upload;
|
||||
} catch (err) {
|
||||
console.log("err", err);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
const upload = await handleImageUpload(uploadedFile);
|
||||
if (upload?.id) {
|
||||
setLoading(false);
|
||||
}
|
||||
try {
|
||||
let date = new Date().toISOString().split("T")[0];
|
||||
let date2 = new Date().toISOString().replace("T", " ").split(".")[0];
|
||||
// if its temporary chat then create new room before message
|
||||
let result = null;
|
||||
|
||||
let is_temp_chat = activeRoom?.is_temp_chat;
|
||||
|
||||
let room_id = searchParams.get("room_id");
|
||||
|
||||
if (is_temp_chat && room_id === "temp") {
|
||||
sdk.setTable("room");
|
||||
result = await sdk.callRestAPI(
|
||||
{
|
||||
user_id: state.user,
|
||||
other_user_id: Number(activeRoom?.other_user_id),
|
||||
booking_id: activeRoom.booking_id === null ? null : Number(activeRoom?.booking_id),
|
||||
property_id: spaceId,
|
||||
user_update_at: date2,
|
||||
other_user_update_at: date2,
|
||||
chat_id: -1,
|
||||
},
|
||||
"POST",
|
||||
)
|
||||
setRooms((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[prev.findIndex((rm) => rm?.is_temp_chat)].id = result?.message;
|
||||
return copy;
|
||||
})
|
||||
// setRooms((prev) => [...prev,r])
|
||||
setActiveRoom((prev) => ({ ...prev, id: result.message }));
|
||||
}
|
||||
|
||||
await sdk.postMessage({
|
||||
room_id: room_id === "temp" ? result?.message : Number(room_id),
|
||||
user_id: state.user,
|
||||
message: upload.url,
|
||||
date,
|
||||
other_user_id: activeRoom.other_user_id,
|
||||
});
|
||||
let newMessageObj = {
|
||||
room_id: activeRoom.id,
|
||||
chat: {
|
||||
message: upload.url,
|
||||
user_id: state.user,
|
||||
// other_user_id: activeRoom.other_user_id,
|
||||
is_image: true,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
unread: 1,
|
||||
create_at: new Date().toISOString(),
|
||||
update_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => {
|
||||
const copy = { ...prev };
|
||||
copy[room_id === "temp" ? result?.message : Number(room_id)] = [...copy[room_id === "temp" ? result?.message : Number(room_id)], newMessageObj];
|
||||
return copy;
|
||||
});
|
||||
// is_temp_chat = false;
|
||||
setLoading(false);
|
||||
setShowImagePreviewModal(false);
|
||||
getRooms()
|
||||
|
||||
// send email alert
|
||||
// sendEmailAlert(activeRoom.other_user_id, activeBooking.property_name, message, activeRoom.id);
|
||||
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Sending image failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto">
|
||||
<div
|
||||
className="fixed inset-0 w-full h-full bg-black opacity-40"
|
||||
onClick={()=>{setShowImagePreviewModal(false);setImageSrc("")}}
|
||||
></div>
|
||||
|
||||
<div className="flex items-center min-h-screen px-4 mt-4 py-8">
|
||||
<div className="relative w-full max-w-lg p-4 mx-auto bg-white rounded-md shadow-lg">
|
||||
<div className="flex mb-4">
|
||||
<h1 className="text-2xl">Please select an Image</h1>
|
||||
</div>
|
||||
<div className="flex mb-4 text-red-600">
|
||||
<span className="text-xs">{messageError}</span>
|
||||
</div>
|
||||
<label
|
||||
htmlFor="send-picture"
|
||||
className={`cursor-pointer border p-2.5 rounded-md`}>
|
||||
<input
|
||||
className="hidden"
|
||||
onChange={(e)=> handleFilePreview(e.target.files)}
|
||||
id="send-picture"
|
||||
type="file"
|
||||
accept="image/png, image/gif, image/jpeg"
|
||||
name="file"
|
||||
/>
|
||||
{imageSrc === "" ?
|
||||
"Select Image"
|
||||
:
|
||||
"Update Image"
|
||||
}
|
||||
</label>
|
||||
|
||||
<div className="mt-3 sm:flex w-full">
|
||||
<form
|
||||
onSubmit={handleSubmit(sendImageMessage)}
|
||||
className="mt-2 text-center sm:text-left w-full">
|
||||
{imageSrc &&
|
||||
<img
|
||||
className="block object-cover w-full h-[200px] md:h-[300px]"
|
||||
src={imageSrc}
|
||||
/>
|
||||
}
|
||||
<div className="items-center w-full mt-3 flex">
|
||||
<button
|
||||
className="flex-1 rounded border border-[#667085] hover:bg-gray-200 !bg-gradient-to-r px-6 py-[10px] text-sm font-semibold text-[#667085] outline-none focus:outline-none"
|
||||
onClick={()=>{setShowImagePreviewModal(false);setImageSrc("")}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
|
||||
{uploadedFile &&
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
type="submit"
|
||||
className={`ml-5 flex-1 block rounded !bg-gradient-to-r from-[#33D4B7] to-[#0D9895] px-4 py-[10px] text-sm font-semibold text-white outline-none focus:outline-none w-[150px] ${loading ? "py-[5px]" : "py[12px]"}`}>
|
||||
Send
|
||||
</LoadingButton>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ImagePreviewModal;
|
||||
@@ -0,0 +1,49 @@
|
||||
import { AuthContext } from "@/authContext";
|
||||
import moment from "moment";
|
||||
import React from "react";
|
||||
import { useContext } from "react";
|
||||
|
||||
export default function MessagesContainer({ messages, messageErr }) {
|
||||
const { state } = useContext(AuthContext);
|
||||
return (
|
||||
<div className="">
|
||||
<div className="flex-grow flex-cols overflow-y-auto tiny-scroll normal-case">
|
||||
{messages && (
|
||||
<div className="py-2 relative">
|
||||
{messages.map((message, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="mb-4 flex"
|
||||
>
|
||||
<div className={`flex-1 px-2 ${message?.chat?.user_id === state.user && "text-right"}`}>
|
||||
<div className="inline-block">
|
||||
{message?.chat?.message.startsWith("https://s3.us-east-2.amazonaws.com") ? (
|
||||
<div className={`${message?.chat?.user_id === state.user ? "" : "text-right"} border bg-[#F2F4F7] p-2 rounded-md`}>
|
||||
<img
|
||||
src={message?.chat.message}
|
||||
className="min-h-30 w-[150px] object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p className={`${message?.chat?.user_id === state.user ? "border-[#F2F4F7] border to-chat" : "bg-[#15212A] text-white from-chat"} block text-start break-all rounded-xl p-2 px-6`}>
|
||||
{message?.chat?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-4 text-[#8E8E93] text-xs">
|
||||
<small className="text-gray-500">{moment(message?.chat?.timestamp).format("DD-MM, hh:mm A")}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{messageErr && (
|
||||
<div className="fixed bottom-[6rem] left-0 w-full flex justify-center z-70">
|
||||
<p className="border text-center border-green-500 bg-green-100 text-green-800 text-sm p-3 rounded-xl">{messageErr}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,994 @@
|
||||
import React, { useContext, useState, useEffect } from "react";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { AuthContext, tokenExpireError } from "@/authContext";
|
||||
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||
import SmileIcon from "@/components/frontend/icons/SmileIcon";
|
||||
import PictureIcon from "@/components/frontend/icons/PictureIcon";
|
||||
import EmojiPicker from "emoji-picker-react";
|
||||
import badWords from "./badWords.json";
|
||||
import * as linkify from "linkifyjs";
|
||||
import { formatAMPM, monthsMapping } from "@/utils/date-time-utils";
|
||||
import CircleCheckIcon from "@/components/frontend/icons/CircleCheckIcon";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
import FavoriteButton from "@/components/frontend/FavoriteButton";
|
||||
import ChatTile from "./ChatTile";
|
||||
import MessagesContainer from "./MessagesContainer";
|
||||
import { ARCHIVE_STATUS, BOOKING_STATUS } from "@/utils/constants";
|
||||
import { parseJsonSafely } from "@/utils/utils";
|
||||
import { ArrowLeftIcon, PaperAirplaneIcon } from "@heroicons/react/24/outline";
|
||||
import TreeSDK from "@/utils/TreeSDK";
|
||||
import StarIcon from "@/components/frontend/icons/StarIcon";
|
||||
import PersonIcon from "@/components/frontend/icons/PersonIcon";
|
||||
import PropertySpaceMapImage from "@/components/frontend/PropertySpaceMapImage";
|
||||
import ImagePreviewModal from "./ImagePreviewModal";
|
||||
|
||||
|
||||
let sdk = new MkdSDK();
|
||||
let treeSdk = new TreeSDK();
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const MessagesPage = () => {
|
||||
const { state, dispatch } = useContext(AuthContext);
|
||||
const [rooms, setRooms] = useState(Array(4).fill({}));
|
||||
const [roomUnread, setRoomUnread] = useState([]);
|
||||
const { state: globalState, dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [activeRoom, setActiveRoom] = useState({});
|
||||
const [activeBooking, setActiveBooking] = useState({});
|
||||
const [activeProperty, setActiveProperty] = useState({});
|
||||
const [spaceId, setSpaceId] = useState();
|
||||
const [messageErr, setMessageErr] = useState("");
|
||||
const [showMap, setShowMap] = useState(false);
|
||||
const [virtual, setVirtual] = useState(false);
|
||||
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [unReadCount, setUnreadCount] = useState(globalState.unreadMessages);
|
||||
const [archivedCount, setArchivedAccount] = useState(0);
|
||||
const [mobileChatSection, setMobileChatSection] = useState(false);
|
||||
const [mobilePreviewOpen, setMobilePreviewOpen] = useState(false);
|
||||
|
||||
const [favoriteId, setFavoriteId] = useState(null);
|
||||
const [render, forceRender] = useState(false);
|
||||
const [fetchingExtra, setFetchingExtra] = useState(true);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [messages, setMessages] = useState({});
|
||||
const [sending, setSending] = useState(false);
|
||||
const [roomsFetched, setRoomsFetched] = useState(false);
|
||||
|
||||
const [showImagePreviewModal, setShowImagePreviewModal] = useState(false)
|
||||
|
||||
const formatAmenities = (propertyAmenities) => {
|
||||
var amenities = (propertyAmenities ?? "").split(",");
|
||||
amenities = Array.from(new Set(amenities));
|
||||
return amenities
|
||||
}
|
||||
|
||||
const bookingExpired = activeBooking.booking_start_time && activeBooking.status < BOOKING_STATUS.ONGOING ? new Date(activeBooking.booking_end_time) < Date.now() : false;
|
||||
|
||||
async function getRooms() {
|
||||
try {
|
||||
// const result2 = await treeSdk.getList("room", { join: ["user|other_user_id", "booking"], filter: [`user_id,eq,${state.user}`] });
|
||||
const result = await sdk.getMyRoom();
|
||||
if (Array.isArray(result.messages)) {
|
||||
setUnreadCount(
|
||||
result.messages.filter((msg) => {
|
||||
const messageSenderId = JSON.parse(msg.chat).user_id;
|
||||
return Number(messageSenderId) != Number(state.user);
|
||||
}).length,
|
||||
);
|
||||
globalDispatch({
|
||||
type: "SET_UNREAD_MESSAGES_COUNT",
|
||||
payload: result.messages.filter((msg) => {
|
||||
const messageSenderId = JSON.parse(msg.chat).user_id;
|
||||
return Number(messageSenderId) != Number(state.user);
|
||||
}).length,
|
||||
});
|
||||
}
|
||||
setRooms(result?.list);
|
||||
setRoomUnread(result?.messages)
|
||||
|
||||
setRoomsFetched(true);
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed getting rooms",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getArchivedRooms() {
|
||||
try {
|
||||
const result = await treeSdk.getList("room", { join: ["user|other_user_id", "booking"], filter: [`user_id,eq,${state.user}`] });
|
||||
setArchivedAccount((result?.list.filter((item) => item.is_archive == 1)).length)
|
||||
setRooms(result.list.filter((item) => item.is_archive == 1));
|
||||
|
||||
console.log((result?.list.filter((item) => item.is_archive == 1)).length)
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed getting rooms",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
async function getMessages(room_id) {
|
||||
try {
|
||||
const result = await sdk.getChats(room_id);
|
||||
if (Array.isArray(result.model)) {
|
||||
setMessages((prev) => {
|
||||
const copy = { ...prev };
|
||||
copy[room_id] = result.model.sort(sortByUpdateAt);
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
return result.model
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed getting messages " + room_id,
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function sendMessage() {
|
||||
if (message == "") return;
|
||||
|
||||
setShowEmoji(false);
|
||||
|
||||
//Add checks to validate message based on active booking
|
||||
|
||||
setSending(true);
|
||||
|
||||
try {
|
||||
let date = new Date().toISOString().split("T")[0];
|
||||
let date2 = new Date().toISOString().replace("T", " ").split(".")[0];
|
||||
// if its temporary chat then create new room before message
|
||||
let result = null;
|
||||
|
||||
let is_temp_chat = activeRoom.is_temp_chat;
|
||||
|
||||
let room_id = searchParams.get("room_id");
|
||||
|
||||
if (is_temp_chat && room_id === "temp") {
|
||||
sdk.setTable("room");
|
||||
result = await sdk.callRestAPI(
|
||||
{
|
||||
user_id: state.user,
|
||||
other_user_id: Number(activeRoom.other_user_id),
|
||||
booking_id: activeRoom.booking_id === null ? null : Number(activeRoom.booking_id),
|
||||
property_id: spaceId,
|
||||
user_update_at: date2,
|
||||
other_user_update_at: date2,
|
||||
chat_id: -1,
|
||||
},
|
||||
"POST",
|
||||
)
|
||||
setRooms((prev) => {
|
||||
const copy = [...prev];
|
||||
copy[prev.findIndex((rm) => rm?.is_temp_chat)].id = result?.message;
|
||||
return copy;
|
||||
})
|
||||
// setRooms((prev) => [...prev,r])
|
||||
setActiveRoom((prev) => ({ ...prev, id: result.message }));
|
||||
}
|
||||
|
||||
await sdk.postMessage({
|
||||
room_id: room_id === "temp" ? result?.message : Number(room_id),
|
||||
user_id: state.user,
|
||||
message,
|
||||
date,
|
||||
other_user_id: activeRoom.other_user_id,
|
||||
});
|
||||
let newMessageObj = {
|
||||
room_id: activeRoom.id,
|
||||
chat: {
|
||||
message: message,
|
||||
user_id: state.user,
|
||||
// other_user_id: activeRoom.other_user_id,
|
||||
is_image: false,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
unread: 1,
|
||||
create_at: new Date().toISOString(),
|
||||
update_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setMessages((prev) => {
|
||||
const copy = { ...prev };
|
||||
copy[room_id === "temp" ? result?.message : Number(room_id)] = [...copy[room_id === "temp" ? result?.message : Number(room_id)], newMessageObj];
|
||||
return copy;
|
||||
});
|
||||
// is_temp_chat = false;
|
||||
getRooms()
|
||||
|
||||
// send email alert
|
||||
sendEmailAlert(activeRoom.other_user_id, activeBooking.property_name, message, activeRoom.id);
|
||||
setMessage("");
|
||||
|
||||
// TODO: scroll to bottom
|
||||
} catch (err) {
|
||||
console.log(err)
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Sending message failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
setSending(false);
|
||||
}
|
||||
|
||||
async function sendEmailAlert(to, property_name, message, room_id) {
|
||||
try {
|
||||
// get receiver preferences
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/get-user", { id: to }, "POST");
|
||||
|
||||
if (parseJsonSafely(result.settings, {}).email_on_new_chat_message == true) {
|
||||
let sender_name = globalState.user.first_name + " " + globalState.user.last_name;
|
||||
// get email template
|
||||
const tmpl = await sdk.getEmailTemplate("chat-message-alert");
|
||||
const body = tmpl.html
|
||||
?.replace(new RegExp("{{{sender_name}}}", "g"), sender_name)
|
||||
.replace(new RegExp("{{{property_name}}}", "g"), property_name)
|
||||
.replace(new RegExp("{{{message}}}", "g"), message)
|
||||
.replace(new RegExp("{{{room_id}}}", "g"), room_id);
|
||||
|
||||
// send email
|
||||
await sdk.sendEmail(result.email, tmpl.subject, body);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log("ERROR", err);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFavoriteStatus(property_spaces_id, user_id) {
|
||||
const payload = { property_spaces_id, user_id };
|
||||
sdk.setTable("user_property_spaces");
|
||||
try {
|
||||
const result = await sdk.callRestAPI({ payload }, "GETALL");
|
||||
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setFavoriteId(result.list[0].id);
|
||||
} else {
|
||||
throw new Error("");
|
||||
}
|
||||
} catch (err) {
|
||||
setFavoriteId(null);
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
async function deleteRoom(id) {
|
||||
sdk.setTable("room");
|
||||
try {
|
||||
const result = await sdk.callRestAPI({ id }, "DELETE");
|
||||
if (!result.error) {
|
||||
getRooms()
|
||||
setActiveRoom({})
|
||||
setActiveBooking({})
|
||||
setActiveProperty({})
|
||||
showToast(globalDispatch, result.message, 5000)
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function archiveRoom(id) {
|
||||
sdk.setTable("room");
|
||||
// call API - callRestAPI (You can see callRestAPI implementation in other functions) here to archive room chat. Method is PUT
|
||||
// update archived state of selected room chat without refreshing the page and toast a success message
|
||||
//Also switch to the archive tab on success of the API, with the archived room chat showing under there
|
||||
}
|
||||
|
||||
async function unArchiveRoom(id) {
|
||||
sdk.setTable("room");
|
||||
// call API - callRestAPI (You can see callRestAPI implementation in other functions) here to unarchive room chat. Method is PUT
|
||||
// update unarchived state of selected room chat without refreshing the page and toast a success message
|
||||
//Also switch to the inbox tab on success of the API, with the unarchived room chat showing under there
|
||||
}
|
||||
|
||||
async function fetchExtraBookingDetails() {
|
||||
if (activeBooking?.extrasFetched) return;
|
||||
setFetchingExtra(true);
|
||||
try {
|
||||
const result = await sdk.callRawAPI(`/v2/api/custom/ergo/booking/details`, { where: [`ergo_booking.id = ${activeRoom.booking_id} AND (ergo_booking.deleted_at IS NULL AND ergo_booking.status = ${BOOKING_STATUS.ONGOING} OR ergo_booking.status = ${BOOKING_STATUS.UPCOMING})`] }, "POST");
|
||||
if (result.list.id && new Date(new Date(result.list.booking_end_time).setDate(new Date(result.list.booking_end_time).getDate() + 1)) > new Date()) {
|
||||
const fullBooking = {
|
||||
...result.list,
|
||||
...activeBooking,
|
||||
add_ons: result.list.add_ons,
|
||||
property_name: result.list.property_name,
|
||||
image: result.list.image_url,
|
||||
address_line_1: result.list.address_line_1,
|
||||
address_line_2: result.list.address_line_2,
|
||||
extrasFetched: true,
|
||||
};
|
||||
setRooms((prev) => {
|
||||
const copy = [...prev];
|
||||
const pos = copy.findIndex((r) => r.id == activeRoom.id);
|
||||
if (pos != -1) {
|
||||
copy[pos].booking = fullBooking;
|
||||
}
|
||||
return copy;
|
||||
});
|
||||
setActiveRoom((prev) => {
|
||||
const copy = { ...prev };
|
||||
copy.booking = fullBooking;
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setFetchingExtra(false);
|
||||
}, 500);
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({ type: "SHOW_ERROR", payload: { heading: "Error fetching booking details", message: err.message } });
|
||||
}
|
||||
}
|
||||
|
||||
async function markMessagesAsRead(room_id, arr) {
|
||||
try {
|
||||
sdk.setTable("chat");
|
||||
await Promise.all(arr.map((id) => sdk.callRestAPI({ id, unread: 0 }, "PUT")));
|
||||
setMessages((prev) => {
|
||||
const copy = { ...prev };
|
||||
copy[room_id] = (copy[room_id] ?? []).map((msg) => ({ ...msg, unread: 0 }));
|
||||
return copy;
|
||||
});
|
||||
setUnreadCount((prev) => {
|
||||
const newCount = arr.length > prev ? 0 : prev - arr.length;
|
||||
globalDispatch({
|
||||
type: "SET_UNREAD_MESSAGES_COUNT",
|
||||
payload: newCount,
|
||||
});
|
||||
return newCount;
|
||||
});
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Error marking messages as read",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getPropertyDetails(id) {
|
||||
const user_id = localStorage.getItem("user");
|
||||
const where = [`ergo_property_spaces.id = ${id} AND ergo_property_spaces.deleted_at IS NULL`];
|
||||
try {
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/popular/PAGINATE", { page: 1, limit: 1, user_id: Number(user_id), where, all: true }, "POST", ctrl.signal);
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setActiveProperty(result.list[0]);
|
||||
fetchFavoriteStatus(Number(result.list[0].id), Number(user_id))
|
||||
} else setActiveProperty({})
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
if (err.name == "AbortError") return;
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
async function createVirtualRoom(other_user_id, booking_id, new_booking_id) {
|
||||
setSpaceId(Number(new_booking_id))
|
||||
try {
|
||||
const result = await treeSdk.getOne("user", other_user_id, { join: [] });
|
||||
const room = {
|
||||
id: "temp",
|
||||
is_temp_chat: true,
|
||||
user_id: state.user,
|
||||
other_user_id,
|
||||
booking_id,
|
||||
is_archive: 0,
|
||||
property_id: new_booking_id,
|
||||
create_at: new Date(),
|
||||
update_at: new Date(),
|
||||
user_update_at: new Date(),
|
||||
other_user_update_at: new Date(),
|
||||
deleted_at: null,
|
||||
user: {
|
||||
id: other_user_id,
|
||||
first_name: result.model.deleted_at == null ? result.model.first_name : "[Deleted User]",
|
||||
last_name: result.model.deleted_at == null ? result.model.last_name : "",
|
||||
photo: result.model.deleted_at == null ? result.model.photo : null,
|
||||
},
|
||||
booking: {
|
||||
id: booking_id === null ? null : booking_id,
|
||||
},
|
||||
};
|
||||
|
||||
setRooms((prev) => [...prev, room]);
|
||||
setVirtual(true)
|
||||
setActiveRoom(room);
|
||||
} catch (err) {
|
||||
tokenExpireError(dispatch, err.message);
|
||||
globalDispatch({ type: "SHOW_ERROR", payload: { heading: "Failed to fetch other user data", message: err.message } });
|
||||
}
|
||||
}
|
||||
|
||||
async function getBookingDetails() {
|
||||
setFetchingExtra(true);
|
||||
const room_id = searchParams.get("room_id");
|
||||
const booking_id = searchParams.get("booking");
|
||||
setActiveProperty({});
|
||||
setActiveBooking({});
|
||||
if (room_id === "temp" || room_id === null) return;
|
||||
sdk.setTable("room")
|
||||
const data = await sdk.callRestAPI({ id: Number(room_id) }, "GET")
|
||||
|
||||
await sdk.setTable("booking")
|
||||
const bookings = await sdk.callRestAPI({}, "GETALL");
|
||||
const fetched_booking = bookings.list.reverse().find((item) =>
|
||||
(item.host_id === data.model?.user_id || item.host_id === data.model?.other_user_id) &&
|
||||
(item.customer_id === data.model?.user_id || item.customer_id === data.model?.other_user_id) &&
|
||||
(item.property_space_id === data?.model?.property_id) &&
|
||||
(item.status === BOOKING_STATUS.UPCOMING || item.status === BOOKING_STATUS.ONGOING)
|
||||
)
|
||||
if (data.model?.booking_id !== null || booking_id !== null || (fetched_booking !== undefined && fetched_booking !== null) ) {
|
||||
if (fetched_booking) {
|
||||
setActiveBooking(fetched_booking)
|
||||
return;
|
||||
}
|
||||
await sdk.setTable("booking")
|
||||
const payload = { id: data.model?.booking_id ?? (booking_id ?? fetched_booking?.id) }
|
||||
const result = await sdk.callRestAPI(payload, "GET");
|
||||
setActiveBooking((result?.model?.status === BOOKING_STATUS.ONGOING || result?.model?.status === BOOKING_STATUS.UPCOMING) ? result.model : {})
|
||||
} else {
|
||||
const user_id = localStorage.getItem("user");
|
||||
const where = [`ergo_property_spaces.id = ${Number(data.model?.property_id)} AND ergo_property_spaces.deleted_at IS NULL`];
|
||||
const result = await sdk.callRawAPI("/v2/api/custom/ergo/popular/PAGINATE", { page: 1, limit: 1, user_id: Number(user_id), where, all: true }, "POST", ctrl.signal);
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setActiveProperty(result.list[0]);
|
||||
setActiveBooking({})
|
||||
} else setActiveProperty({})
|
||||
}
|
||||
setTimeout(() => {
|
||||
setFetchingExtra(false);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
getBookingDetails()
|
||||
}, [searchParams.get("room_id")]);
|
||||
|
||||
useEffect(() => {
|
||||
getRooms();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomsFetched) return;
|
||||
const room_id = searchParams.get("room_id");
|
||||
const property_space_id = searchParams.get("space");
|
||||
setActiveProperty({});
|
||||
setActiveBooking({});
|
||||
let room = rooms.find((rm) => rm.id == room_id);
|
||||
if (room) {
|
||||
setActiveRoom(room);
|
||||
return;
|
||||
}
|
||||
const other_user_id = searchParams.get("other_user_id");
|
||||
if (!other_user_id) return;
|
||||
const booking_id = searchParams.get("booking");
|
||||
room = rooms.find((rm) => ((rm.booking_id === booking_id && rm.other_user_id === other_user_id) || rm.property_id === Number(property_space_id)));
|
||||
if (room) {
|
||||
setActiveRoom(room);
|
||||
} else {
|
||||
getPropertyDetails(property_space_id)
|
||||
createVirtualRoom(other_user_id, booking_id, property_space_id)
|
||||
|
||||
}
|
||||
}, [roomsFetched]);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const controller = new AbortController();
|
||||
const pollMessages = async () => {
|
||||
const abortController = new AbortController();
|
||||
try {
|
||||
const poll = await sdk.startPolling(localStorage.getItem("user"), abortController.signal)
|
||||
if (poll.message) {
|
||||
// Do whatever you want here
|
||||
let room = searchParams.get("room_id");
|
||||
if (room === "temp") return;
|
||||
getRooms()
|
||||
if (Number(searchParams.get("room_id"))) {
|
||||
getMessages(Number(searchParams.get("room_id"))).then((res) => {
|
||||
const unread = res.filter((msg) => msg.unread === 1 && msg.chat.user_id !== state.user).map((msg) => msg.id);
|
||||
markMessagesAsRead(Number(searchParams.get("room_id")), unread);
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
}
|
||||
finally {
|
||||
if (!abortController.signal.aborted) {
|
||||
pollMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(5000);
|
||||
controller.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
function showImageModal(){
|
||||
setShowImagePreviewModal(true)
|
||||
}
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
setFetchingExtra(true);
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
setActiveProperty({});
|
||||
setActiveBooking({});
|
||||
if (!activeRoom.id) return;
|
||||
searchParams.set("room_id", activeRoom.id);
|
||||
searchParams.set("booking", activeRoom.booking_id);
|
||||
if (!searchParams.get("booking")) {
|
||||
searchParams.delete("booking");
|
||||
}
|
||||
searchParams.delete("space");
|
||||
searchParams.delete("other_user_id");
|
||||
setSearchParams(searchParams);
|
||||
setMessage("");
|
||||
getMessages(activeRoom.id).then((res) => {
|
||||
const unread = res.filter((msg) => msg.unread === 1 && msg.chat.user_id !== state.user).map((msg) => msg.id);
|
||||
markMessagesAsRead(activeRoom.id, unread);
|
||||
});
|
||||
if (activeRoom.booking_id !== null) {
|
||||
fetchExtraBookingDetails();
|
||||
}
|
||||
if (activeRoom.id) {
|
||||
getPropertyDetails(activeRoom.property_id)
|
||||
}
|
||||
setTimeout(() => {
|
||||
setFetchingExtra(false);
|
||||
}, 500);
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
|
||||
}, [activeRoom.id, render]);
|
||||
|
||||
function sortByUpdateAt(a, b) {
|
||||
return new Date(a.update_at) - new Date(b.update_at);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="relative -mx-4 flex h-[var(--messages-page-height)] border border-t-0 normal-case md:mx-0"
|
||||
onClick={() => setShowEmoji(false)}
|
||||
>
|
||||
<div className="w-full md:w-[26%]">
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex border-b border-t nineteen-step">
|
||||
<button
|
||||
className={`${searchParams.get("message_tab") != "archive" ? "border-b-2 border-black font-semibold text-black" : ""} flex-grow px-[] py-[12px] text-[]`}
|
||||
onClick={() => {
|
||||
searchParams.set("message_tab", "inbox");
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
Inbox ({unReadCount})
|
||||
</button>
|
||||
<button
|
||||
className={`${searchParams.get("message_tab") == "archive" ? "border-b-2 border-black font-semibold text-black" : ""} flex-grow px-[] py-[12px] text-[]`}
|
||||
onClick={() => {
|
||||
searchParams.set("message_tab", "archive");
|
||||
setSearchParams(searchParams);
|
||||
}}
|
||||
>
|
||||
Archive
|
||||
</button>
|
||||
</div>
|
||||
{roomsFetched &&
|
||||
<div className="tiny-scroll bg-white md:bg-[#f9fafb] flex-grow overflow-y-auto">
|
||||
{searchParams.get("message_tab") != "archive" &&
|
||||
rooms &&
|
||||
rooms
|
||||
.filter((rm) => rm.is_archive == ARCHIVE_STATUS.NOT_ARCHIVE)
|
||||
.sort((a, b) => new Date(b.update_at) - new Date(a.update_at))
|
||||
.map((room, idx) => {
|
||||
return (
|
||||
<ChatTile
|
||||
key={idx}
|
||||
room={room}
|
||||
rooms={rooms}
|
||||
virtual={virtual}
|
||||
roomUnread={roomUnread}
|
||||
activeRoomId={activeRoom.id}
|
||||
setActiveRoom={setActiveRoom}
|
||||
setActiveBooking={setActiveBooking}
|
||||
setActiveProperty={setActiveProperty}
|
||||
activeBooking={activeBooking}
|
||||
first={room.first_name ? room.first_name : room.user.first_name}
|
||||
last={room.last_name ? room.last_name : room.user.last_name}
|
||||
setMobileChatSection={setMobileChatSection}
|
||||
markMessagesAsRead={() => markMessagesAsRead}
|
||||
messages={messages}
|
||||
deleteRoom={deleteRoom}
|
||||
archiveRoom={archiveRoom}
|
||||
unArchiveRoom={unArchiveRoom}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{searchParams.get("message_tab") == "archive" &&
|
||||
rooms &&
|
||||
rooms
|
||||
.filter((rm) => rm.is_archive == ARCHIVE_STATUS.IS_ARCHIVE)
|
||||
.sort((a, b) => new Date(b.update_at) - new Date(a.update_at))
|
||||
.map((room, idx) => {
|
||||
return (
|
||||
<ChatTile
|
||||
key={idx}
|
||||
room={room}
|
||||
roomUnread={roomUnread}
|
||||
activeRoomId={activeRoom.id}
|
||||
setActiveRoom={setActiveRoom}
|
||||
setActiveBooking={setActiveBooking}
|
||||
setActiveProperty={setActiveProperty}
|
||||
activeBooking={activeBooking}
|
||||
first={room.first_name ? room.first_name : room.user.first_name}
|
||||
last={room.last_name ? room.last_name : room.user.last_name}
|
||||
setMobileChatSection={setMobileChatSection}
|
||||
messages={messages}
|
||||
deleteRoom={deleteRoom}
|
||||
archiveRoom={archiveRoom}
|
||||
unArchiveRoom={unArchiveRoom}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${(mobilePreviewOpen && messages[activeRoom.id].length > 0 && !fetchingExtra) ? "block" : "hidden"} absolute top-0 right-0 -left-0 overflow-y-hidden bg-white md:static md:block md:max-h-[unset] md:w-[48%]`}>
|
||||
<div className="flex h-full flex-col border-t">
|
||||
{activeRoom?.id ? (
|
||||
<>
|
||||
<div className={`${mobileChatSection ? "md:hidden" : "hidden"} pl-2`}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setMobileChatSection(false);
|
||||
setMobilePreviewOpen(false);
|
||||
}}
|
||||
className="mr-2 mb-2 inline-flex items-center py-2.5 pr-5 text-center text-sm font-semibold"
|
||||
>
|
||||
<ArrowLeftIcon className="h-6 w-6" />
|
||||
<span className="ml-2">Back</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex justify-between border-b py-[13px] px-2 md:px-4">
|
||||
<h3 className="md:text-lg text-base font-semibold">Chat with {activeRoom?.first_name === undefined ? rooms[0]?.user?.first_name : activeRoom?.first_name + " " + activeRoom?.last_name === undefined ? rooms[0]?.user?.last_name : activeRoom?.last_name}</h3>
|
||||
{mobileChatSection && activeRoom.booking_id && (
|
||||
<button
|
||||
onClick={() => setMobilePreviewOpen(true)}
|
||||
className="inline whitespace-nowrap bg-gradient-to-r from-primary to-primary-dark bg-clip-text text-xs font-bold text-transparent md:hidden"
|
||||
>
|
||||
Preview booking
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="h-full w-full overflow-x-hidden relative hidden-scrollbar">
|
||||
<div className="h-[90%] z-10 pb-12 md:pb-0 overflow-auto">
|
||||
<MessagesContainer
|
||||
messageErr={messageErr}
|
||||
messages={messages[activeRoom.id] ?? []}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="fixed z- md:absolute bottom-0 w-full overflow-hidden flex h-fit bottom-0 justify-start items-center gap-4 border border-r-0 border-l-0 bg-white px-[20px] py-[12px]">
|
||||
<div className="flex flex-gro items-center gap-2">
|
||||
<label
|
||||
onClick={()=>{([BOOKING_STATUS.PENDING, BOOKING_STATUS.COMPLETED, BOOKING_STATUS.DELETED, BOOKING_STATUS.DECLINED, BOOKING_STATUS.CANCELLED].includes(activeBooking.status) || !activeBooking.status) ? showToast(globalDispatch, "Without a booking, you can’t send images or emojis.", 4000, "ERROR") : showImageModal()}}
|
||||
className={`cursor-pointer ${activeBooking?.status != BOOKING_STATUS.COMPLETED ? "strike-opacity-50 pointer-events-non opacity-50" : ""}`}
|
||||
>
|
||||
<PictureIcon />
|
||||
</label>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
([BOOKING_STATUS.PENDING, BOOKING_STATUS.COMPLETED, BOOKING_STATUS.DELETED, BOOKING_STATUS.DECLINED, BOOKING_STATUS.CANCELLED].includes(activeBooking.status) || !activeBooking.status) ? showToast(globalDispatch, "Without a booking, you can’t send images or emojis.", 4000, "ERROR") :
|
||||
e.stopPropagation();
|
||||
setShowEmoji(!showEmoji);
|
||||
}}
|
||||
className="strike-opacity-50 relative disabled:opacity-50"
|
||||
>
|
||||
<SmileIcon />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="relative w-full rounded-md border-[#E5E5EA]"
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault();
|
||||
sendMessage();
|
||||
}}
|
||||
>
|
||||
<input
|
||||
name="message"
|
||||
className="border w-full bg-[#F9FAFB] py-1 pl-2 pr-16 text-sm outline-none"
|
||||
rows="1"
|
||||
placeholder="Type a message..."
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className={message ? "absolute right-0 top-0 p-1 px-4 text-gray-900 duration-75 hover:text-primary" : "hidden"}
|
||||
>
|
||||
{sending ? (
|
||||
<svg
|
||||
className="inline h-4 w-4 animate-spin text-primary"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
></circle>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
></path>
|
||||
</svg>
|
||||
) : (
|
||||
<PaperAirplaneIcon className="h-5 w-5 -rotate-45" />
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-grow items-center justify-center text-4xl text-gray-700 ">Select a chat to view</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`${(mobilePreviewOpen && messages[activeRoom.id].length > 0 && !fetchingExtra ) ? "block" : "hidden"} lg:block absolute top-0 right-0 -left-0 overflow-y-auto max-h-[var(--messages-page-height)] bg-white md:static md:block md:max-h-[unset] w-full md:w-[26%]`}>
|
||||
<div className="flex h-full max-h-[var(--messages-page-height)] flex-col border">
|
||||
<div className="flex justify-between py-[12px] px-4">
|
||||
|
||||
{(activeRoom?.booking_id && activeRoom?.booking?.id) &&
|
||||
<h3 className="text-lg font-semibold">Booking Preview</h3>
|
||||
}
|
||||
|
||||
{(activeRoom?.booking_id && !activeRoom?.booking?.id) &&
|
||||
<h3 className="text-lg font-semibold">Property Preview</h3>
|
||||
}
|
||||
{mobilePreviewOpen && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobilePreviewOpen(false)}
|
||||
className="inline rounded-full border p-1 px-3 text-2xl font-normal duration-100 hover:bg-gray-200 active:bg-gray-300 md:hidden"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="tiny-scroll flex-grow overflow-y-auto">
|
||||
<div className="min-h-[500px] px-[20px] py-4">
|
||||
{activeRoom.id && !activeRoom?.booking?.id ? (
|
||||
<div className="">
|
||||
<div
|
||||
className="mb-[8px] rounded-lg bg-cover bg-center bg-no-repeat px-[8px] pb-[13px] relative"
|
||||
style={{ backgroundImage: `url(${(activeProperty?.url) ?? "/default-property.jpg"})`, height: 150 }}
|
||||
>
|
||||
<FavoriteButton
|
||||
space_id={activeProperty?.id}
|
||||
user_property_spaces_id={favoriteId || activeProperty?.favourite}
|
||||
withLoader={true}
|
||||
reRender={forceRender}
|
||||
className="flex flex-grow justify-end w-fit float-right pt-2"
|
||||
/>
|
||||
<span className="absolute mt-3 px-2 py-1 text-white bg-black font-bold rounded-lg text-xs self-start">{activeProperty?.category || "N/A"}</span>
|
||||
</div>
|
||||
<div className="py-6 block justify-between lg:items-start items-end lg:pl-0 w-full">
|
||||
<div className="">
|
||||
<h2 className="text-[18px] font-semibold mb-[6px] whitespace-normal md:whitespace-nowrap">{activeProperty?.name}</h2>
|
||||
<p className="text-[#475467] tracking-wider md:truncate mb-1">{activeProperty?.city}</p>
|
||||
<p className="text-[#475467] tracking-wider md:truncate">{activeProperty?.country} </p>
|
||||
<div className="lg:mt-[21px] mt-[6px] flex justify-between">
|
||||
<p className="mr-[31px]">
|
||||
from: <span className="font-bold">${activeProperty?.rate}</span>/<span className="">hour</span>
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<PersonIcon />
|
||||
<span>{activeProperty?.max_capacity}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid items-start">
|
||||
<div className="flex items-center justify-between lg:mb-[9px] mt-3">
|
||||
<p className="flex gap-2 items-center ">
|
||||
<StarIcon />
|
||||
<strong className="font-semibold">
|
||||
{(Number(activeProperty?.average_space_rating) || 0).toFixed(1)}
|
||||
<span className="font-normal">({activeProperty?.space_rating_count})</span>
|
||||
</strong>
|
||||
</p>
|
||||
<button
|
||||
className="text-sm underline whitespace-nowrap"
|
||||
target="_blank"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setShowMap(true);
|
||||
}}
|
||||
>
|
||||
(view on map)
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-6 lg:mt-[50px] flex flex-wrap gap-[12px] whitespace-nowrap">
|
||||
{formatAmenities(activeProperty?.amenities).slice(0, 3).map((am, idx) => (
|
||||
<span
|
||||
className="text-[14px] bg-[#F2F4F7] rounded-[3px] pt-[2px] px-[8px] pb-[3px] text-[#667085]"
|
||||
key={idx}
|
||||
>
|
||||
{am}
|
||||
</span>
|
||||
))}
|
||||
{formatAmenities(activeProperty?.amenities).length > 3 ? <span className="text-[14px] bg-[#F2F4F7] rounded-[3px] pt-[2px] px-[8px] pb-[3px] text-[#667085]">+{formatAmenities(activeProperty?.amenities).length - 3} more</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4" />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(activeRoom?.booking_id && activeRoom?.booking?.id) ? (
|
||||
<>
|
||||
<div
|
||||
className="mb-[8px] rounded-lg bg-cover bg-center bg-no-repeat px-[8px] pb-[13px]"
|
||||
style={{ backgroundImage: `url(${(activeRoom.booking?.image_url) ?? "/default-property.jpg"})`, height: 150 }}
|
||||
>
|
||||
<span className="px-2 py-1 mt-3 inline-flex text-white bg-black font-bold rounded-lg text-xs self-start">{activeRoom?.booking?.space_category ? activeRoom?.booking?.space_category : "N/A"}</span>
|
||||
</div>
|
||||
<div className="">
|
||||
<div className="mb-6 flex justify-between">
|
||||
<p>Date</p>
|
||||
<p className="font-semibold">
|
||||
{" "}
|
||||
{monthsMapping[new Date(activeRoom?.booking?.booking_start_time).getMonth()] +
|
||||
" " +
|
||||
new Date(activeRoom?.booking?.booking_start_time).getDate() +
|
||||
"/" +
|
||||
new Date(activeRoom?.booking?.booking_start_time).getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 flex justify-between">
|
||||
<p>Time</p>
|
||||
<p className="font-semibold">
|
||||
{formatAMPM(activeRoom.booking?.booking_start_time)} - {formatAMPM(activeRoom?.booking?.booking_end_time)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<p>Duration</p>
|
||||
<p className="font-semibold">{activeRoom?.booking?.duration / 3600} hours</p>
|
||||
</div>
|
||||
</div>
|
||||
<hr className="my-4" />
|
||||
<h4 className="mb-6 text-xl font-semibold">Add-ons:</h4>
|
||||
<div className="">
|
||||
{activeRoom?.booking?.add_ons?.map((addon, idx) => (
|
||||
<div
|
||||
className="mb-4 flex gap-[14px]"
|
||||
key={idx}
|
||||
>
|
||||
<CircleCheckIcon />
|
||||
<p>{addon.name}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<hr className="my-4" />
|
||||
</>
|
||||
) : null}
|
||||
<div className="text-center">
|
||||
{(activeRoom?.booking_id && activeRoom?.booking?.id) &&
|
||||
<Link
|
||||
to={"/account/my-bookings/" + activeRoom.booking?.id}
|
||||
className="my-text-gradient text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
View booking
|
||||
</Link>}
|
||||
|
||||
{activeRoom.id && !activeRoom?.booking?.id &&
|
||||
<Link
|
||||
to={"/property/" + activeProperty?.id}
|
||||
className="my-text-gradient text-xs font-semibold uppercase tracking-wider"
|
||||
>
|
||||
View property
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEmoji && (
|
||||
<div className="absolute w-full h-full left-0 right-0">
|
||||
<div style={{"left":"50px !important"}} className="emoji-picker flex w-full h-full items-center justify-center">
|
||||
<EmojiPicker
|
||||
className="absolute -top-10 md:top-10 z-[1000] bottom-0 left-0 right-0"
|
||||
onEmojiClick={(em) => {
|
||||
setMessage((prev) => prev + em.emoji);
|
||||
setShowEmoji(false);
|
||||
}}
|
||||
searchDisabled
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImagePreviewModal &&
|
||||
<ImagePreviewModal activeRoom={activeRoom} getRooms={()=>getRooms()} state={state} setMessages={setMessages} setActiveRoom={setActiveRoom} setRooms={setRooms} spaceId={spaceId} activeBooking={activeBooking} setShowImagePreviewModal={setShowImagePreviewModal}/>
|
||||
}
|
||||
|
||||
{activeRoom?.booking_id === null &&
|
||||
<PropertySpaceMapImage
|
||||
modalImage={`https://maps.googleapis.com/maps/api/staticmap?center=${activeProperty.address_line_1 || ""}, ${activeProperty.address_line_2 || ""}, ${activeProperty.city || ""}, ${activeProperty.country || ""
|
||||
}&zoom=15&size=600x400&maptype=roadmap&markers=color:red|${activeProperty.address_line_1 || ""}, ${activeProperty.address_line_2 || ""}
|
||||
&key=${import.meta.env.VITE_GOOGLE_API_KEY}`}
|
||||
modalOpen={showMap}
|
||||
closeModal={() => setShowMap(false)}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MessagesPage;
|
||||
@@ -0,0 +1,405 @@
|
||||
[
|
||||
"2g1c",
|
||||
"2 girls 1 cup",
|
||||
"acrotomophilia",
|
||||
"alabama hot pocket",
|
||||
"alaskan pipeline",
|
||||
"anal",
|
||||
"anilingus",
|
||||
"anus",
|
||||
"apeshit",
|
||||
"arsehole",
|
||||
"ass",
|
||||
"asshole",
|
||||
"assmunch",
|
||||
"auto erotic",
|
||||
"autoerotic",
|
||||
"babeland",
|
||||
"baby batter",
|
||||
"baby juice",
|
||||
"ball gag",
|
||||
"ball gravy",
|
||||
"ball kicking",
|
||||
"ball licking",
|
||||
"ball sack",
|
||||
"ball sucking",
|
||||
"bangbros",
|
||||
"bangbus",
|
||||
"bareback",
|
||||
"barely legal",
|
||||
"barenaked",
|
||||
"bastard",
|
||||
"bastardo",
|
||||
"bastinado",
|
||||
"bbw",
|
||||
"bdsm",
|
||||
"beaner",
|
||||
"beaners",
|
||||
"beaver cleaver",
|
||||
"beaver lips",
|
||||
"beastiality",
|
||||
"bestiality",
|
||||
"big black",
|
||||
"big breasts",
|
||||
"big knockers",
|
||||
"big tits",
|
||||
"bimbos",
|
||||
"birdlock",
|
||||
"bitch",
|
||||
"bitches",
|
||||
"black cock",
|
||||
"blonde action",
|
||||
"blonde on blonde action",
|
||||
"blowjob",
|
||||
"blow job",
|
||||
"blow your load",
|
||||
"blue waffle",
|
||||
"blumpkin",
|
||||
"bollocks",
|
||||
"bondage",
|
||||
"boner",
|
||||
"boob",
|
||||
"boobs",
|
||||
"booty call",
|
||||
"brown showers",
|
||||
"brunette action",
|
||||
"bukkake",
|
||||
"bulldyke",
|
||||
"bullet vibe",
|
||||
"bullshit",
|
||||
"bung hole",
|
||||
"bunghole",
|
||||
"busty",
|
||||
"butt",
|
||||
"buttcheeks",
|
||||
"butthole",
|
||||
"camel toe",
|
||||
"camgirl",
|
||||
"camslut",
|
||||
"camwhore",
|
||||
"carpet muncher",
|
||||
"carpetmuncher",
|
||||
"chocolate rosebuds",
|
||||
"cialis",
|
||||
"circlejerk",
|
||||
"cleveland steamer",
|
||||
"clit",
|
||||
"clitoris",
|
||||
"clover clamps",
|
||||
"clusterfuck",
|
||||
"cock",
|
||||
"cocks",
|
||||
"coprolagnia",
|
||||
"coprophilia",
|
||||
"cornhole",
|
||||
"coon",
|
||||
"coons",
|
||||
"creampie",
|
||||
"cum",
|
||||
"cumming",
|
||||
"cumshot",
|
||||
"cumshots",
|
||||
"cunnilingus",
|
||||
"cunt",
|
||||
"darkie",
|
||||
"date rape",
|
||||
"daterape",
|
||||
"deep throat",
|
||||
"deepthroat",
|
||||
"dendrophilia",
|
||||
"dick",
|
||||
"dildo",
|
||||
"dingleberry",
|
||||
"dingleberries",
|
||||
"dirty pillows",
|
||||
"dirty sanchez",
|
||||
"doggie style",
|
||||
"doggiestyle",
|
||||
"doggy style",
|
||||
"doggystyle",
|
||||
"dog style",
|
||||
"dolcett",
|
||||
"domination",
|
||||
"dominatrix",
|
||||
"dommes",
|
||||
"donkey punch",
|
||||
"double dong",
|
||||
"double penetration",
|
||||
"dp action",
|
||||
"dry hump",
|
||||
"dvda",
|
||||
"eat my ass",
|
||||
"ecchi",
|
||||
"ejaculation",
|
||||
"erotic",
|
||||
"erotism",
|
||||
"escort",
|
||||
"eunuch",
|
||||
"fag",
|
||||
"faggot",
|
||||
"fecal",
|
||||
"felch",
|
||||
"fellatio",
|
||||
"feltch",
|
||||
"female squirting",
|
||||
"femdom",
|
||||
"figging",
|
||||
"fingerbang",
|
||||
"fingering",
|
||||
"fisting",
|
||||
"foot fetish",
|
||||
"footjob",
|
||||
"frotting",
|
||||
"fuck",
|
||||
"fuck buttons",
|
||||
"fuckin",
|
||||
"fucking",
|
||||
"fucktards",
|
||||
"fudge packer",
|
||||
"fudgepacker",
|
||||
"futanari",
|
||||
"gangbang",
|
||||
"gang bang",
|
||||
"gay sex",
|
||||
"genitals",
|
||||
"giant cock",
|
||||
"girl on",
|
||||
"girl on top",
|
||||
"girls gone wild",
|
||||
"goatcx",
|
||||
"goatse",
|
||||
"god damn",
|
||||
"gokkun",
|
||||
"golden shower",
|
||||
"goodpoop",
|
||||
"goo girl",
|
||||
"goregasm",
|
||||
"grope",
|
||||
"group sex",
|
||||
"g-spot",
|
||||
"guro",
|
||||
"hand job",
|
||||
"handjob",
|
||||
"hard core",
|
||||
"hardcore",
|
||||
"hentai",
|
||||
"homoerotic",
|
||||
"honkey",
|
||||
"hooker",
|
||||
"horny",
|
||||
"hot carl",
|
||||
"hot chick",
|
||||
"how to kill",
|
||||
"how to murder",
|
||||
"huge fat",
|
||||
"humping",
|
||||
"incest",
|
||||
"intercourse",
|
||||
"jack off",
|
||||
"jail bait",
|
||||
"jailbait",
|
||||
"jelly donut",
|
||||
"jerk off",
|
||||
"jigaboo",
|
||||
"jiggaboo",
|
||||
"jiggerboo",
|
||||
"jizz",
|
||||
"juggs",
|
||||
"kike",
|
||||
"kinbaku",
|
||||
"kinkster",
|
||||
"kinky",
|
||||
"knobbing",
|
||||
"leather restraint",
|
||||
"leather straight jacket",
|
||||
"lemon party",
|
||||
"livesex",
|
||||
"lolita",
|
||||
"lovemaking",
|
||||
"make me come",
|
||||
"male squirting",
|
||||
"masturbate",
|
||||
"masturbating",
|
||||
"masturbation",
|
||||
"menage a trois",
|
||||
"milf",
|
||||
"missionary position",
|
||||
"mong",
|
||||
"motherfucker",
|
||||
"mound of venus",
|
||||
"mr hands",
|
||||
"muff diver",
|
||||
"muffdiving",
|
||||
"nambla",
|
||||
"nawashi",
|
||||
"negro",
|
||||
"neonazi",
|
||||
"nigga",
|
||||
"nigger",
|
||||
"nig nog",
|
||||
"nimphomania",
|
||||
"nipple",
|
||||
"nipples",
|
||||
"nsfw",
|
||||
"nsfw images",
|
||||
"nude",
|
||||
"nudity",
|
||||
"nutten",
|
||||
"nympho",
|
||||
"nymphomania",
|
||||
"octopussy",
|
||||
"omorashi",
|
||||
"one cup two girls",
|
||||
"one guy one jar",
|
||||
"orgasm",
|
||||
"orgy",
|
||||
"paedophile",
|
||||
"paki",
|
||||
"panties",
|
||||
"panty",
|
||||
"pedobear",
|
||||
"pedophile",
|
||||
"pegging",
|
||||
"penis",
|
||||
"phone sex",
|
||||
"piece of shit",
|
||||
"pikey",
|
||||
"pissing",
|
||||
"piss pig",
|
||||
"pisspig",
|
||||
"playboy",
|
||||
"pleasure chest",
|
||||
"pole smoker",
|
||||
"ponyplay",
|
||||
"poof",
|
||||
"poon",
|
||||
"poontang",
|
||||
"punany",
|
||||
"poop chute",
|
||||
"poopchute",
|
||||
"porn",
|
||||
"porno",
|
||||
"pornography",
|
||||
"prince albert piercing",
|
||||
"pthc",
|
||||
"pubes",
|
||||
"pussy",
|
||||
"queaf",
|
||||
"queef",
|
||||
"quim",
|
||||
"raghead",
|
||||
"raging boner",
|
||||
"rape",
|
||||
"raping",
|
||||
"rapist",
|
||||
"rectum",
|
||||
"reverse cowgirl",
|
||||
"rimjob",
|
||||
"rimming",
|
||||
"rosy palm",
|
||||
"rosy palm and her 5 sisters",
|
||||
"rusty trombone",
|
||||
"sadism",
|
||||
"santorum",
|
||||
"scat",
|
||||
"schlong",
|
||||
"scissoring",
|
||||
"semen",
|
||||
"sex",
|
||||
"sexcam",
|
||||
"sexo",
|
||||
"sexy",
|
||||
"sexual",
|
||||
"sexually",
|
||||
"sexuality",
|
||||
"shaved beaver",
|
||||
"shaved pussy",
|
||||
"shemale",
|
||||
"shibari",
|
||||
"shit",
|
||||
"shitblimp",
|
||||
"shitty",
|
||||
"shota",
|
||||
"shrimping",
|
||||
"skeet",
|
||||
"slanteye",
|
||||
"slut",
|
||||
"s&m",
|
||||
"smut",
|
||||
"snatch",
|
||||
"snowballing",
|
||||
"sodomize",
|
||||
"sodomy",
|
||||
"spastic",
|
||||
"spic",
|
||||
"splooge",
|
||||
"splooge moose",
|
||||
"spooge",
|
||||
"spread legs",
|
||||
"spunk",
|
||||
"strap on",
|
||||
"strapon",
|
||||
"strappado",
|
||||
"strip club",
|
||||
"style doggy",
|
||||
"suck",
|
||||
"sucks",
|
||||
"suicide girls",
|
||||
"sultry women",
|
||||
"swastika",
|
||||
"swinger",
|
||||
"tainted love",
|
||||
"taste my",
|
||||
"tea bagging",
|
||||
"threesome",
|
||||
"throating",
|
||||
"thumbzilla",
|
||||
"tied up",
|
||||
"tight white",
|
||||
"tit",
|
||||
"tits",
|
||||
"titties",
|
||||
"titty",
|
||||
"tongue in a",
|
||||
"topless",
|
||||
"tosser",
|
||||
"towelhead",
|
||||
"tranny",
|
||||
"tribadism",
|
||||
"tub girl",
|
||||
"tubgirl",
|
||||
"tushy",
|
||||
"twat",
|
||||
"twink",
|
||||
"twinkie",
|
||||
"two girls one cup",
|
||||
"undressing",
|
||||
"upskirt",
|
||||
"urethra play",
|
||||
"urophilia",
|
||||
"vagina",
|
||||
"venus mound",
|
||||
"viagra",
|
||||
"vibrator",
|
||||
"violet wand",
|
||||
"vorarephilia",
|
||||
"voyeur",
|
||||
"voyeurweb",
|
||||
"voyuer",
|
||||
"vulva",
|
||||
"wank",
|
||||
"wetback",
|
||||
"wet dream",
|
||||
"white power",
|
||||
"whore",
|
||||
"worldsex",
|
||||
"wrapping men",
|
||||
"wrinkled starfish",
|
||||
"xx",
|
||||
"xxx",
|
||||
"yaoi",
|
||||
"yellow showers",
|
||||
"yiffy",
|
||||
"zoophilia",
|
||||
"🖕"
|
||||
]
|
||||
@@ -0,0 +1,48 @@
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import React, { useState } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function PrivacyPolicyPage() {
|
||||
const [content, setContent] = useState("");
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
async function fetchPrivacyPolicy() {
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
const sdk = new MkdSDK();
|
||||
sdk.setTable("cms");
|
||||
try {
|
||||
const result = await callCustomAPI("cms", "post", { payload: { content_key: "privacy_policy" }, limit: 1000, page: 1 }, "PAGINATE");
|
||||
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setContent(result.list.find((stg) => stg.content_key == "privacy_policy")?.content_value);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Cannot get Privacy policy",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrivacyPolicy();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-[120px] min-h-screen normal-case text-sm">
|
||||
<div className="container mx-auto 2xl:px-32 px-4">
|
||||
<article
|
||||
className="sun-editor-editable"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
></article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,476 @@
|
||||
import React from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import PropertySpaceTile from "@/components/frontend/PropertySpaceTile";
|
||||
import "react-calendar/dist/Calendar.css";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import PeopleIcon from "@/components/frontend/icons/PeopleIcon";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { useContext } from "react";
|
||||
import CustomSelect from "@/components/frontend/CustomSelect";
|
||||
import FilterIcon from "@/components/frontend/icons/FilterIcon";
|
||||
import { Tooltip } from "react-tooltip";
|
||||
import NoteIcon from "@/components/frontend/icons/NoteIcon";
|
||||
import { formatDate } from "@/utils/date-time-utils";
|
||||
import { DRAFT_STATUS, SPACE_STATUS } from "@/utils/constants";
|
||||
import { useForm } from "react-hook-form";
|
||||
import CustomLocationAutoCompleteV2 from "@/components/CustomLocationAutoCompleteV2";
|
||||
import DatePickerV3 from "@/components/DatePickerV3";
|
||||
import { isValidDate, parseSearchParams } from "@/utils/utils";
|
||||
import FilterCheckBoxesV2 from "@/components/FilterCheckBoxesV2";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import useAmenityCategories from "@/hooks/api/useAmenityCategories";
|
||||
import CustomStaticLocationAutoCompleteV2 from "@/components/CustomStaticLocationAutoCompleteV2 ";
|
||||
|
||||
const prices = [
|
||||
{ name: "$0 - $30", id: 0 },
|
||||
{ name: "$31 - $60", id: 1 },
|
||||
{ name: "$60 - $90", id: 2 },
|
||||
{ name: "$90 - $120", id: 3 },
|
||||
{ name: "$120 - $150", id: 4 },
|
||||
{ name: "$150 - $180", id: 5 },
|
||||
];
|
||||
const capacity = [
|
||||
{ name: "0 - 4", id: 0 },
|
||||
{ name: "5 - 9", id: 1 },
|
||||
{ name: "10 - 14", id: 2 },
|
||||
{ name: "15 - 19", id: 3 },
|
||||
{ name: "20 - 24", id: 4 },
|
||||
{ name: "25 - 30", id: 5 },
|
||||
{ name: "Greater Than 30", id: 6 },
|
||||
];
|
||||
|
||||
const reviews = [
|
||||
{ name: "4", id: 0 },
|
||||
{ name: "3", id: 1 },
|
||||
{ name: "2", id: 2 },
|
||||
{ name: "1", id: 3 },
|
||||
];
|
||||
|
||||
const sdk = new MkdSDK();
|
||||
const ctrl = new AbortController();
|
||||
|
||||
const SearchPage = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const { dispatch: globalDispatch, state: globalState } = useContext(GlobalContext);
|
||||
|
||||
const [filterPopup, setFilterPopup] = useState(false);
|
||||
|
||||
const spaceCategories = globalState.spaceCategories;
|
||||
const amenityCategories = useAmenityCategories();
|
||||
const [propertySpaces, setPropertySpaces] = useState([]);
|
||||
const [render, forceRender] = useState(false);
|
||||
|
||||
const { handleSubmit, control, setValue, resetField, register } = useForm({
|
||||
defaultValues: (() => {
|
||||
const params = parseSearchParams(searchParams);
|
||||
return {
|
||||
...params,
|
||||
location: params.location ?? "",
|
||||
booking_start_time: "",
|
||||
category: params.category?.split(",") || [],
|
||||
capacity: params.capacity?.split(",") || [],
|
||||
price: params.price?.split(",") || [],
|
||||
amenity: params.amenity?.split(",") || [],
|
||||
review: params.review?.split(",") || [],
|
||||
};
|
||||
})(),
|
||||
});
|
||||
const [sortAsc, setSortAsc] = useState(false);
|
||||
|
||||
async function fetchSpaces() {
|
||||
const params = parseSearchParams(searchParams);
|
||||
const location = (params.location?.split(","))
|
||||
const d = new Date(params.booking_start_time || undefined);
|
||||
|
||||
const filter = {
|
||||
...params,
|
||||
booking_start_time: isNaN(d) ? undefined : d,
|
||||
category: params.category?.split(",") || [],
|
||||
price: params.price?.split(",") || [],
|
||||
capacity: params.capacity?.split(",") || [],
|
||||
amenity: params.amenity?.split(",") || [],
|
||||
review: params.review?.split(",") || [],
|
||||
};
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
|
||||
// make sure only approved and non-draft spaces
|
||||
var where = [`ergo_property_spaces.space_status = ${SPACE_STATUS.APPROVED} AND schedule_template_id IS NOT NULL AND ergo_property_spaces_images.is_approved = 1 AND ergo_property_spaces.draft_status = ${DRAFT_STATUS.COMPLETED} AND ergo_property_spaces.deleted_at IS NULL`];
|
||||
|
||||
// use data.location to search address, city, country and zip
|
||||
if (filter.location) {
|
||||
where.push(
|
||||
`(ergo_property.address_line_1 LIKE '%${filter.location}%' OR ergo_property.address_line_2 LIKE '%${filter.location}%' OR ergo_property.city LIKE '%${location[0] && location[0]}%' OR ergo_property.country LIKE '%${location.length === 1 ? location[0] : location.length === 2 ? location[1] : location[2]}%' OR ergo_property.zip LIKE '%${filter.location}%' OR ergo_property.name LIKE '%${filter.location}%')`,
|
||||
);
|
||||
}
|
||||
|
||||
if (filter.size) {
|
||||
where.push(`ergo_property_spaces.size = ${filter.size}`);
|
||||
}
|
||||
|
||||
if (filter.capacity.length > 0) {
|
||||
if (filter.capacity[filter.capacity.length-1] !== "Greater Than 30") {
|
||||
const str = filter.capacity[filter.capacity.length-1]; // Get the first (and only) element from the array
|
||||
const numbers = str.split('-').map(num => num.trim()); // Split the string and trim spaces
|
||||
const [num1, num2] = numbers; // Destructure the resulting array to get the numbers
|
||||
where.pop()
|
||||
where.push(
|
||||
`ergo_property_spaces.max_capacity BETWEEN ${num1} AND ${num2}`,
|
||||
);
|
||||
} else {
|
||||
where.push(
|
||||
`ergo_property_spaces.max_capacity > 30`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (filter.category.length > 0) {
|
||||
where.push(`(${filter.category.map((cg) => `ergo_spaces.category LIKE '%${cg}%'`).join(" OR ")})`);
|
||||
}
|
||||
|
||||
if (filter.amenity.length > 0) {
|
||||
where.push(`(${filter.amenity.map((am) => `ergo_amenity.name LIKE '%${am}%'`).join(" OR ")})`);
|
||||
}
|
||||
|
||||
if (filter.review.length > 0) {
|
||||
where.push(`(${filter.review.map((rv) => `ER.average_space_rating >= ${rv.replace("+", "")}`).join(" OR ")})`);
|
||||
}
|
||||
|
||||
if (filter.price.length > 0) {
|
||||
where.push(
|
||||
`(${filter.price
|
||||
.filter((pr) => pr.trim() != "")
|
||||
.map((pr) => pr.split("-"))
|
||||
.map(([from, to]) => `ergo_property_spaces.rate BETWEEN ${from.trim().slice(1)} AND ${to.trim().slice(1)} `)
|
||||
.join(" OR ")})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const user_id = Number(localStorage.getItem("user"));
|
||||
const result = await sdk.callRawAPI(
|
||||
"/v2/api/custom/ergo/popular/PAGINATE",
|
||||
{ page: 1, limit: 10000, user_id: Number(user_id), where, booking_start_time: isValidDate(filter.booking_start_time || "") ? new Date(filter.booking_start_time).toISOString() : undefined },
|
||||
"POST",
|
||||
ctrl.signal,
|
||||
);
|
||||
setPropertySpaces(result.list);
|
||||
} catch (err) {
|
||||
console.log("err", err);
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (isValidDate(searchParams.get("booking_start_time"))) {
|
||||
setValue("booking_start_time", new Date(searchParams.get("booking_start_time")), { shouldDirty: true });
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (render) {
|
||||
fetchSpaces();
|
||||
}
|
||||
}, [render]);
|
||||
|
||||
const removeFilter = (searchField, arrEl) => {
|
||||
if (!arrEl) {
|
||||
setValue(searchField, "");
|
||||
searchParams.set(searchField, "");
|
||||
setSearchParams(searchParams);
|
||||
} else {
|
||||
const prev = searchParams.get(searchField) ?? "";
|
||||
const arr = prev?.split(",") || [];
|
||||
const removeElIndex = arr.indexOf(arrEl);
|
||||
if (removeElIndex > -1) {
|
||||
arr.splice(removeElIndex, 1);
|
||||
setValue(searchField, arr);
|
||||
searchParams.set(searchField, arr.join(","));
|
||||
}
|
||||
}
|
||||
setSearchParams(searchParams);
|
||||
};
|
||||
|
||||
async function onSubmit(data) {
|
||||
if (globalState.location && globalState.location.includes("undefined")) {
|
||||
const parts = globalState.location.split(",");
|
||||
const result = parts[0].trim();
|
||||
globalState.location = result;
|
||||
}
|
||||
searchParams.set("location", globalState.location);
|
||||
searchParams.set("booking_start_time", isValidDate(data.booking_start_time) ? data.booking_start_time.toISOString() : "");
|
||||
searchParams.set("category", data.category.join(","));
|
||||
searchParams.set("price", data.price.join(","));
|
||||
searchParams.set("amenity", data.amenity.join(","));
|
||||
searchParams.set("review", data.review.join(","));
|
||||
if (data.max_capacity !== "NaN") {
|
||||
searchParams.set("max_capacity", Number(data.max_capacity));
|
||||
}
|
||||
searchParams.set("capacity", data.capacity);
|
||||
setSearchParams(searchParams);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchSpaces();
|
||||
}, [searchParams]);
|
||||
|
||||
const sortRating = (a, b) => {
|
||||
if (sortAsc == 1) {
|
||||
return (a.average_space_rating ?? 0) - (b.average_space_rating ?? 0);
|
||||
}
|
||||
return (b.average_space_rating ?? 0) - (a.average_space_rating ?? 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<section className="container mx-auto mb-[24px] bg-white px-6 pt-[120px] normal-case 2xl:px-32">
|
||||
<form
|
||||
className="flex w-full flex-wrap justify-center"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
id="search-bar"
|
||||
>
|
||||
<CustomStaticLocationAutoCompleteV2
|
||||
// type={true}
|
||||
// control={control}
|
||||
// setValue={(val) => setValue("location", val)}
|
||||
// name="location"
|
||||
type="static"
|
||||
setValue={(val) => globalDispatch({
|
||||
type: "SETLOCATION",
|
||||
payload: {
|
||||
location:val
|
||||
},
|
||||
})}
|
||||
containerClassName={"mb-4 flex min-h-[45px] w-full max-w-full flex-grow items-center gap-2 border-2 px-4 lg:mb-0 lg:w-[unset] lg:border-r-0 lg:border-b-2"}
|
||||
className="border-0 focus:outline-none"
|
||||
placeholder="Search by city or zip code"
|
||||
suggestionType={["(regions)"]}
|
||||
/>
|
||||
|
||||
<div className="relative mb-6 flex h-[45px] w-full lg:w-1/2 cursor-pointer items-center gap-2 border-2 lg:border-r-0 p-2 lg:mb-0 lg:w-[unset] lg:min-w-[230px]">
|
||||
<DatePickerV3
|
||||
reset={() => resetField("booking_start_time")}
|
||||
setValue={(val) => setValue("booking_start_time", val)}
|
||||
control={control}
|
||||
name="booking_start_time"
|
||||
labelClassName="justify-between flex-grow flex-row-reverse"
|
||||
placeholder="Select Date"
|
||||
min={new Date()}
|
||||
/>
|
||||
</div>
|
||||
{/* <div className="flex h-[45px] w-1/2 items-center gap-2 border-2 px-4 lg:w-[unset] lg:border-r-0">
|
||||
<PeopleIcon />
|
||||
<input
|
||||
type="number"
|
||||
placeholder="2 People (Max Capacity)"
|
||||
className="remove-arrow w-full focus:outline-none"
|
||||
{...register("max_capacity")}
|
||||
/>
|
||||
</div> */}
|
||||
<button
|
||||
className="login-btn-gradient mb-4 w-full whitespace-nowrap p-2 px-6 text-white disabled:text-[#98A2B3] lg:mb-0 lg:w-[unset]"
|
||||
type="submit"
|
||||
id="update-search"
|
||||
>
|
||||
Update Search
|
||||
</button>
|
||||
</form>
|
||||
<div className="block lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
className="mb-6 flex w-full items-center justify-center gap-2 border-2 py-2 text-center"
|
||||
onClick={() => setFilterPopup(true)}
|
||||
>
|
||||
<FilterIcon />
|
||||
Filters
|
||||
</button>
|
||||
<div className="snap-scroll flex gap-4">
|
||||
{Object.entries(parseSearchParams(searchParams)).map(([k, v]) => {
|
||||
if (!v) return null;
|
||||
if (k == "booking_start_time") {
|
||||
return (
|
||||
<span className="whitespace-nowrap rounded-[50px] bg-[#F2F4F7] py-[6px] px-[16px] text-[#475467]">
|
||||
{formatDate(v)}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3"
|
||||
onClick={() => removeFilter(k)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (k == "max_capacity" && v == 0) {
|
||||
return;
|
||||
}
|
||||
let vArr = v.split(",");
|
||||
if (vArr.length > 0) {
|
||||
return vArr.map((filter, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="whitespace-nowrap rounded-[50px] bg-[#F2F4F7] py-[6px] px-[16px] text-[#475467]"
|
||||
>
|
||||
{filter}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3"
|
||||
onClick={() => removeFilter(k, filter)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={k}
|
||||
className="whitespace-nowrap rounded-[50px] bg-[#F2F4F7] py-[6px] px-[16px] text-[#475467]"
|
||||
>
|
||||
{v}
|
||||
<button
|
||||
type="button"
|
||||
className="ml-3"
|
||||
onClick={() => removeFilter(k)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="search-page-container container mx-auto flex gap-[32px] bg-white px-6 normal-case 2xl:px-32">
|
||||
<aside
|
||||
className={`hidden xl:block xl:w-1/5 ${filterPopup ? "popup-tablet" : ""}`}
|
||||
onClick={() => setFilterPopup(false)}
|
||||
>
|
||||
<div
|
||||
className={`${filterPopup ? "w-[80%] max-w-[500px] rounded-xl p-5" : ""} flex flex-col bg-white `}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{filterPopup ? (
|
||||
<div className="mb-[18px] flex items-center justify-between border-b pb-2">
|
||||
<h3 className="text-2xl font-semibold">Filters</h3>
|
||||
<button
|
||||
onClick={() => setFilterPopup(false)}
|
||||
className="rounded-full border p-1 px-3 text-2xl font-normal duration-300 hover:bg-gray-200"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={`${filterPopup ? "snap-scroll h-[60vh]" : ""}`}>
|
||||
<FilterCheckBoxesV2
|
||||
control={control}
|
||||
name="category"
|
||||
title="Spaces"
|
||||
labelField="category"
|
||||
valueField="category"
|
||||
options={spaceCategories}
|
||||
reset={() => { resetField("category"); searchParams.set("category", ""); setSearchParams(searchParams) }}
|
||||
/>
|
||||
<FilterCheckBoxesV2
|
||||
control={control}
|
||||
name="price"
|
||||
title="Prices"
|
||||
labelField="name"
|
||||
valueField="name"
|
||||
options={prices}
|
||||
reset={() => { resetField("price"); searchParams.set("price", ""); setSearchParams(searchParams) }}
|
||||
/>
|
||||
<FilterCheckBoxesV2
|
||||
control={control}
|
||||
name="capacity"
|
||||
title="Capacity"
|
||||
labelField="name"
|
||||
valueField="name"
|
||||
options={capacity}
|
||||
reset={() => { resetField("capacity"); searchParams.set("capacity", ""); setSearchParams(searchParams) }}
|
||||
/>
|
||||
<FilterCheckBoxesV2
|
||||
name="amenity"
|
||||
control={control}
|
||||
title="Amenities"
|
||||
options={amenityCategories}
|
||||
labelField="name"
|
||||
valueField="name"
|
||||
reset={() => { resetField("amenity"); searchParams.set("amenity", ""); setSearchParams(searchParams) }}
|
||||
/>
|
||||
<FilterCheckBoxesV2
|
||||
name="review"
|
||||
control={control}
|
||||
title="Reviews"
|
||||
options={reviews}
|
||||
labelField="name"
|
||||
valueField="name"
|
||||
reset={() => { resetField("review"); searchParams.set("review", ""); setSearchParams(searchParams) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div className="mb-16 max-w-full flex-grow xl:w-4/5">
|
||||
<div className="mb-[15px] flex items-center justify-between">
|
||||
<h5 className={propertySpaces.length == 0 ? "md:invisible" : ""}>
|
||||
{propertySpaces.length == 0 ? (
|
||||
"No results Found"
|
||||
) : (
|
||||
<>
|
||||
{" "}
|
||||
Results Found <strong className="font-semibold">({propertySpaces.length})</strong>
|
||||
</>
|
||||
)}
|
||||
</h5>
|
||||
<CustomSelect
|
||||
options={[
|
||||
{ label: "Rating (High to Low)", value: 0 },
|
||||
{ label: "Rating (Low to High)", value: 1 },
|
||||
]}
|
||||
onChange={setSortAsc}
|
||||
accessor="label"
|
||||
valueAccessor="value"
|
||||
className="min-w-[200px]"
|
||||
listOptionClassName={"pl-4"}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap justify-center gap-6 lg:block">
|
||||
{propertySpaces.length == 0 && (
|
||||
<div className="hidden min-h-[300px] items-center justify-center normal-case text-[#667085] md:flex">
|
||||
<h2 className="flex gap-3">
|
||||
<NoteIcon /> No results found
|
||||
</h2>
|
||||
</div>
|
||||
)}
|
||||
{propertySpaces.sort(sortRating).map((sp) => (
|
||||
<PropertySpaceTile
|
||||
key={sp.id}
|
||||
data={sp}
|
||||
forceRender={forceRender}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Tooltip
|
||||
anchorId="update-search"
|
||||
place="right"
|
||||
content={"Search"}
|
||||
noArrow
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchPage;
|
||||
@@ -0,0 +1,464 @@
|
||||
import { AuthContext } from "@/authContext";
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import DatePickerV2 from "@/components/frontend/DatePickerV2";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import { NOTIFICATION_STATUS, NOTIFICATION_TYPE } from "@/utils/constants";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import moment from "moment/moment";
|
||||
import React, { useContext, useEffect, useState, useRef } from "react";
|
||||
import { FileUploader } from "react-drag-drop-files";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { Navigate, useNavigate } from "react-router";
|
||||
import { Link } from "react-router-dom";
|
||||
import countries from "@/utils/countries.json";
|
||||
import * as yup from "yup";
|
||||
import CustomLocationAutoCompleteV2 from "@/components/CustomLocationAutoCompleteV2";
|
||||
import CustomComboBox from "@/components/CustomComboBox";
|
||||
|
||||
const readImage = (file, previewEl) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
document.getElementById(previewEl).src = event.target.result;
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
async function getFileFromUrl(url) {
|
||||
if (!url) return null;
|
||||
try {
|
||||
let response = await fetch(url);
|
||||
let data = await response.blob();
|
||||
let metadata = {
|
||||
type: "image/jpeg",
|
||||
};
|
||||
return new File([data], url.split("/").pop(), metadata);
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function BecomeAHostPage() {
|
||||
const initialDate = useRef(new Date());
|
||||
const { state: globalState, dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
const { dispatch: authDispatch } = useContext(AuthContext);
|
||||
const [frontImage, setFrontImage] = useState(null);
|
||||
const [backImage, setBackImage] = useState(null);
|
||||
const [passport, setPassport] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [imageErr, setImageErr] = useState("");
|
||||
|
||||
const navigate = useNavigate();
|
||||
|
||||
const schema = yup.object({
|
||||
// dob: yup
|
||||
// .string()
|
||||
// .required("This field is required")
|
||||
// .test("is-not-in-future", "Not a valid date", (val) => {
|
||||
// if (val == "") return true;
|
||||
// const date = new Date(val);
|
||||
// return date.setDate(date.getDate() + 1) < new Date();
|
||||
// }),
|
||||
expiry_date: yup
|
||||
.string()
|
||||
.required("This field is required")
|
||||
.test("is-not-in-past", "Invalid expiry date", (val) => {
|
||||
const date = new Date(val);
|
||||
return date.setDate(date.getDate() - 1) > new Date();
|
||||
}),
|
||||
city: yup.string().required("This field is required"),
|
||||
country: yup.string().required("This field is required"),
|
||||
selectedType: yup.string().required("This field is required"),
|
||||
about: yup.string().required("This field is required"),
|
||||
});
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
register,
|
||||
setValue,
|
||||
control,
|
||||
watch,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
defaultValues: {
|
||||
dob: globalState.user.dob ? moment(globalState.user.dob).format("yyyy-MM-DD") : "",
|
||||
expiry_date: globalState.user.verificationExpiry ? moment(globalState.user.verificationExpiry).format("yyyy-MM-DD") : "",
|
||||
city: globalState.user.city || "",
|
||||
country: globalState.user.country || "",
|
||||
selectedType: globalState.user.verificationType || "Driver's License",
|
||||
about: globalState.user.about || "",
|
||||
},
|
||||
resolver: yupResolver(schema),
|
||||
});
|
||||
const sdk = new MkdSDK();
|
||||
|
||||
const selectedType = watch("selectedType");
|
||||
|
||||
const handleImageUpload = async (file) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
try {
|
||||
const upload = await sdk.uploadImage(formData);
|
||||
return upload.url;
|
||||
} catch (err) {
|
||||
console.log("err", err);
|
||||
return "";
|
||||
}
|
||||
};
|
||||
|
||||
async function onSubmit(data) {
|
||||
// check if images are uploaded
|
||||
if (selectedType == "Driver's License" && (!frontImage || !backImage)) {
|
||||
setImageErr("Please upload required documents");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedType == "Passport" && !passport) {
|
||||
setImageErr("Please upload required documents");
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("submitting", data);
|
||||
setLoading(true);
|
||||
try {
|
||||
// edit user
|
||||
await callCustomAPI(
|
||||
"edit-self",
|
||||
"post",
|
||||
{
|
||||
user: { role: ["superadmin", "admin"].includes(globalState.user.role) ? undefined : "host" },
|
||||
profile: {
|
||||
city: data.city,
|
||||
country: data.country,
|
||||
// dob: isSameDay(data.dob, initialDate.current) ? undefined : moment(data.dob).format("yyyy-MM-DD"),
|
||||
about: data.about,
|
||||
getting_started: 0,
|
||||
},
|
||||
},
|
||||
"",
|
||||
);
|
||||
// submit id verification
|
||||
if (selectedType == "Driver's License") {
|
||||
data.image_front = await handleImageUpload(frontImage);
|
||||
data.image_back = await handleImageUpload(backImage);
|
||||
} else {
|
||||
data.image_front = await handleImageUpload(passport);
|
||||
}
|
||||
|
||||
sdk.setTable("id_verification");
|
||||
const result = await sdk.callRestAPI(
|
||||
{
|
||||
id: globalState.user.verificationId,
|
||||
type: selectedType,
|
||||
expiry_date: data.expiry_date,
|
||||
status: 0,
|
||||
image_front: data.image_front,
|
||||
image_back: data.image_back,
|
||||
user_id: Number(localStorage.getItem("user")),
|
||||
},
|
||||
globalState.user.verificationId ? "PUT" : "POST",
|
||||
);
|
||||
|
||||
// create notification
|
||||
sdk.setTable("notification");
|
||||
await sdk.callRestAPI(
|
||||
{
|
||||
user_id: Number(localStorage.getItem("user")),
|
||||
actor_id: null,
|
||||
action_id: result.message,
|
||||
notification_time: new Date().toISOString().split(".")[0],
|
||||
message: "New ID Verification submitted",
|
||||
type: NOTIFICATION_TYPE.NEW_ID_VERIFICATION,
|
||||
status: NOTIFICATION_STATUS.NOT_ADDRESSED,
|
||||
},
|
||||
"POST",
|
||||
);
|
||||
|
||||
globalDispatch({
|
||||
type: "SHOW_CONFIRMATION",
|
||||
payload: {
|
||||
heading: "Success",
|
||||
message: `Host account created, please re login to your account`,
|
||||
btn: "Ok got it",
|
||||
onClose: () => {
|
||||
sdk.logout();
|
||||
authDispatch({ type: "LOGOUT" });
|
||||
navigate("/login");
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Operation failed",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const front = await getFileFromUrl(globalState.user.verificationImageFront);
|
||||
const back = await getFileFromUrl(globalState.user.verificationImageBack);
|
||||
if (globalState.user.verificationType == "Passport") {
|
||||
setPassport(front);
|
||||
} else {
|
||||
setFrontImage(front);
|
||||
setBackImage(back);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
if (!globalState.user.id) return <Navigate to={"/"} />;
|
||||
|
||||
return (
|
||||
<div className="mt-[120px] normal-case">
|
||||
<form
|
||||
className="mx-auto w-full max-w-5xl p-5"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
>
|
||||
<h1 className="mb-2 text-5xl">Become A Host</h1>
|
||||
<p className="mb-8">Gain the ability to rent your spaces by giving us some additional information</p>
|
||||
|
||||
<h3 className="mb-8 text-2xl font-semibold">Location</h3>
|
||||
<div className="mb-16 max-w-lg">
|
||||
<div className="mb-8">
|
||||
<label
|
||||
className="mb-2 block text-sm font-bold text-gray-700"
|
||||
htmlFor="city"
|
||||
>
|
||||
City
|
||||
</label>
|
||||
<CustomLocationAutoCompleteV2
|
||||
control={control}
|
||||
setValue={(val) => setValue("city", val)}
|
||||
name="city"
|
||||
className={`w-full rounded border py-2 px-3 leading-tight text-gray-700 ${errors.city?.message ? "border-red-500 focus:outline-red-500" : "focus-within:outline-primary"}`}
|
||||
placeholder=""
|
||||
hideIcons
|
||||
suggestionType={["(cities)"]}
|
||||
/>
|
||||
|
||||
{/* <div className="flex">
|
||||
<input
|
||||
placeholder=""
|
||||
type="text"
|
||||
{...register("city")}
|
||||
className={`focus:shadow-outline w-full rounded rounded-l-none border py-2 px-3 leading-tight text-gray-700 focus:outline-none ${errors.city?.message ? "border-red-600" : ""}`}
|
||||
/>
|
||||
</div> */}
|
||||
<p className="mt-2 text-sm italic text-red-600 empty:mt-0">{errors.city?.message}</p>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<label
|
||||
className="mb-2 block text-sm font-bold text-gray-700"
|
||||
htmlFor="country"
|
||||
>
|
||||
Country
|
||||
</label>
|
||||
<CustomComboBox
|
||||
control={control}
|
||||
name="country"
|
||||
labelField="name"
|
||||
valueField="name"
|
||||
setValue={(val) => setValue("country", val)}
|
||||
items={countries}
|
||||
containerClassName="relative w-full"
|
||||
className={`w-full truncate border py-2 px-3 text-black ${errors.country?.message ? "border-red-500 focus:outline-red-500" : "focus-within:outline-primary"}`}
|
||||
placeholder=""
|
||||
/>
|
||||
{/* <div className="flex">
|
||||
<input
|
||||
placeholder=""
|
||||
type="text"
|
||||
{...register("country")}
|
||||
className={`focus:shadow-outline w-full rounded rounded-l-none border py-2 px-3 leading-tight text-gray-700 focus:outline-none ${errors.country?.message ? "border-red-600" : ""}`}
|
||||
/>
|
||||
</div> */}
|
||||
<p className="mt-2 text-sm italic text-red-600 empty:mt-0">{errors.country?.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="mb-8 text-2xl font-semibold">Profile Information</h3>
|
||||
<div className={`mb-16 max-w-lg ${globalState.user.dob ? "hidden" : "hidden"}`}>
|
||||
<label
|
||||
className="mb-2 block text-sm font-bold text-gray-700"
|
||||
htmlFor="dob"
|
||||
>
|
||||
Date of birth <span className="ml-4 text-sm font-normal italic text-red-500">{errors.dob?.message}</span>
|
||||
</label>
|
||||
<DatePickerV2
|
||||
control={control}
|
||||
name="dob"
|
||||
min={new Date("1950-01-01")}
|
||||
max={initialDate.current}
|
||||
setValue={(v) => setValue("dob", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-16 max-w-lg">
|
||||
<label
|
||||
className="mb-2 block text-sm font-bold text-gray-700"
|
||||
htmlFor="about"
|
||||
>
|
||||
About
|
||||
</label>
|
||||
<textarea
|
||||
className={`focus:shadow-outline w-full rounded rounded-l-none border-2 py-2 px-3 leading-tight text-gray-700 focus:outline-none ${errors.about?.message ? "border-red-600" : ""}`}
|
||||
placeholder="Tell us about yourself"
|
||||
{...register("about")}
|
||||
cols="30"
|
||||
rows="10"
|
||||
></textarea>
|
||||
<p className="mt-2 text-sm italic text-red-600 empty:mt-0">{errors.about?.message}</p>
|
||||
</div>
|
||||
|
||||
<h3 className="mb-8 text-2xl font-semibold">Identity Verification</h3>
|
||||
<p className="mb-2 font-semibold">Explain what document(s) are allowed.</p>
|
||||
<div className="radio-container mb-8 flex max-w-lg justify-between">
|
||||
<label
|
||||
htmlFor="driversLicense"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="driversLicense"
|
||||
{...register("selectedType")}
|
||||
className="mr-2"
|
||||
value="Driver's License"
|
||||
/>
|
||||
<span></span>
|
||||
Driver's License
|
||||
</label>
|
||||
<label
|
||||
htmlFor="passport"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
id="passport"
|
||||
{...register("selectedType")}
|
||||
className="mr-2"
|
||||
value="Passport"
|
||||
/>
|
||||
<span></span>
|
||||
Passport
|
||||
</label>
|
||||
</div>
|
||||
<p className="mb-2 text-sm italic text-red-600 empty:mb-0">{imageErr}</p>
|
||||
<div className="mb-8 text-[#667085]">
|
||||
{selectedType == "Driver's License" ? (
|
||||
<div className="flex flex-col items-center gap-[16px] md:flex-row">
|
||||
<FileUploader
|
||||
multiple={false}
|
||||
handleChange={(file) => {
|
||||
setFrontImage(file);
|
||||
}}
|
||||
types={["SVG", "JPEG", "PNG", "GIF", "JPG"]}
|
||||
>
|
||||
<div className="flex h-[130px] w-full max-w-full cursor-pointer flex-col items-center justify-center gap-[12px] border-2 border-dashed border-[#D0D5DD] text-sm md:w-[333px]">
|
||||
{frontImage?.name ? (
|
||||
<img
|
||||
src={readImage(frontImage, "front-preview")}
|
||||
id="front-preview"
|
||||
className="h-full w-full rounded-sm object-cover"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-xl font-semibold">Front</h4>
|
||||
<p className="px-[20px]">
|
||||
<strong className="font-semibold underline">Click to upload</strong> or drag and drop SVG, PNG, JPG or GIF (max. 800x400px)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileUploader>
|
||||
<FileUploader
|
||||
multiple={false}
|
||||
handleChange={(file) => {
|
||||
setBackImage(file);
|
||||
}}
|
||||
types={["SVG", "JPEG", "PNG", "GIF", "JPG"]}
|
||||
>
|
||||
<div className="flex h-[130px] w-full max-w-full cursor-pointer flex-col items-center justify-center gap-[12px] border-2 border-dashed border-[#D0D5DD] text-sm md:w-[333px]">
|
||||
{backImage?.name ? (
|
||||
<img
|
||||
src={readImage(backImage, "back-preview")}
|
||||
id="back-preview"
|
||||
className="h-full w-full rounded-sm object-cover"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-xl font-semibold">Back</h4>
|
||||
<p className="px-[20px]">
|
||||
<strong className="font-semibold underline">Click to upload</strong> or drag and drop SVG, PNG, JPG or GIF (max. 800x400px)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileUploader>
|
||||
</div>
|
||||
) : (
|
||||
<FileUploader
|
||||
multiple={false}
|
||||
handleChange={(file) => {
|
||||
setPassport(file);
|
||||
}}
|
||||
types={["SVG", "JPEG", "PNG", "GIF", "JPG"]}
|
||||
>
|
||||
<div className="flex h-[130px] w-full max-w-full cursor-pointer flex-col items-center justify-center gap-[12px] border-2 border-dashed border-[#D0D5DD] text-sm md:w-[333px]">
|
||||
{passport?.name ? (
|
||||
<img
|
||||
src={readImage(passport, "passport-preview")}
|
||||
id="passport-preview"
|
||||
className="h-full w-full rounded-sm object-cover"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<h4 className="text-xl font-semibold">Passport page with photo</h4>
|
||||
<p className="px-[20px]">
|
||||
<strong className="font-semibold underline">Click to upload</strong> or drag and drop SVG, PNG, JPG or GIF (max. 800x400px)
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</FileUploader>
|
||||
)}
|
||||
</div>
|
||||
<div className="mb-16 max-w-lg">
|
||||
<label
|
||||
className="mb-2 block text-sm font-bold text-gray-700"
|
||||
htmlFor="expiry_date"
|
||||
>
|
||||
Expiry date <span className="ml-4 text-sm font-normal italic text-red-500">{errors.expiry_date?.message}</span>
|
||||
</label>
|
||||
<DatePickerV2
|
||||
control={control}
|
||||
name="expiry_date"
|
||||
min={initialDate.current}
|
||||
max={new Date("2050-01-01")}
|
||||
setValue={(v) => setValue("expiry_date", v)}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-16 flex gap-4">
|
||||
<Link
|
||||
to={-1}
|
||||
className="rounded border-2 border-gray-700 py-2 px-4 tracking-wide outline-none focus:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</Link>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
type="submit"
|
||||
className={`login-btn-gradient rounded tracking-wide text-white outline-none focus:outline-none ${loading ? "bg-opacity-50 py-1 px-8" : "py-2"} px-4`}
|
||||
>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { AuthContext } from "@/authContext";
|
||||
import React, { useContext, useEffect } from "react";
|
||||
import { Navigate } from "react-router";
|
||||
|
||||
export default function CheckVerificationPage() {
|
||||
const { state: authState, dispatch: authDispatch } = useContext(AuthContext);
|
||||
|
||||
useEffect(() => {
|
||||
let timeout;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
authDispatch({ type: "DISALLOW_CHECK_VERIFICATION" });
|
||||
}, 10000);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}, []);
|
||||
|
||||
if (!authState.allowCheckVerification) return <Navigate to={"/login"} />;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center normal-case">
|
||||
<div className="">
|
||||
<h1 className="text-4xl block text-center">Account Created successfully. Please check your email to verify your account</h1>
|
||||
<p className="text-center">You'll be redirected to login page shortly</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from "react";
|
||||
import { Outlet } from "react-router";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import Icon from "@/components/Icons";
|
||||
import { SignUpContextProvider } from "./signUpContext";
|
||||
|
||||
const PageWrapper = () => {
|
||||
return (
|
||||
<SignUpContextProvider>
|
||||
<div>
|
||||
<header className="absolute top-0 left-0 pt-4 md:pl-16 pl-6">
|
||||
<Link to="/">
|
||||
<Icon
|
||||
type="logo"
|
||||
fill="fill-[#101828]"
|
||||
/>
|
||||
</Link>
|
||||
</header>
|
||||
<div className="min-h-screen flex justify-center w-full">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
</SignUpContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PageWrapper;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react'
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useEffect } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
|
||||
const PrivacyAndPolicyModal = ({ isOpen, closeModal }) => {
|
||||
const [privacy, setPrivacy] = useState("");
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
async function fetchPrivacyPolicy() {
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
const sdk = new MkdSDK();
|
||||
sdk.setTable("cms");
|
||||
try {
|
||||
const result = await callCustomAPI("cms", "post", { payload: { content_key: "privacy_policy" }, limit: 1000, page: 1 }, "PAGINATE");
|
||||
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setPrivacy(result.list.find((stg) => stg.content_key == "privacy_policy")?.content_value);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Cannot get Privacy policy",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchPrivacyPolicy();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={`${isOpen ? "flex" : "hidden"} fixed inset-0 items-center justify-center`}></div>
|
||||
|
||||
<Transition
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
onClose={closeModal}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900 flex justify-between items-center"
|
||||
>
|
||||
{" "}
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="py-2 border hover:bg-gray-200 active:bg-gray-300 duration-100 px-3 text-2xl font-normal rounded-full flex justify-end"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<article
|
||||
className="sun-editor-editable text-sm max-h-[600px] overflow-y-auto my-8"
|
||||
dangerouslySetInnerHTML={{ __html: privacy }}
|
||||
></article>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PrivacyAndPolicyModal
|
||||
@@ -0,0 +1,247 @@
|
||||
import React from "react";
|
||||
import { Navigate, useNavigate } from "react-router";
|
||||
import { useSignUpContext } from "./signUpContext";
|
||||
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
import { AuthContext } from "@/authContext";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { Link } from "react-router-dom";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import { useRef } from "react";
|
||||
import { isSameDay } from "@/utils/date-time-utils";
|
||||
import moment from "moment/moment";
|
||||
import TermsAndConditionsModal from "./TermsAndConditionsModal";
|
||||
import DatePickerV2 from "@/components/frontend/DatePickerV2";
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import PrivacyAndPolicyModal from "./PrivacyAndPolicyModal";
|
||||
|
||||
export default function SignUpDetailsForm() {
|
||||
const navigate = useNavigate();
|
||||
const { signUpData } = useSignUpContext();
|
||||
const role = signUpData.role;
|
||||
const { dispatch: authDispatch } = React.useContext(AuthContext);
|
||||
const [showPassword, setShowPassword] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const sdk = new MkdSDK();
|
||||
const [modalOpen, setModalOpen] = React.useState(false);
|
||||
const [privacyOpen, setPrivacyModalOpen] = React.useState(false);
|
||||
const initialDate = useRef(new Date());
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
}
|
||||
function closePrivacyModal() {
|
||||
setPrivacyModalOpen(false);
|
||||
}
|
||||
|
||||
const schema = yup.object({
|
||||
firstName: yup.string(),
|
||||
lastName: yup.string(),
|
||||
dob: yup.date(),
|
||||
password: yup.string()
|
||||
});
|
||||
|
||||
const {
|
||||
register,
|
||||
setError,
|
||||
handleSubmit,
|
||||
trigger,
|
||||
watch,
|
||||
setValue,
|
||||
control,
|
||||
formState: { errors, dirtyFields },
|
||||
} = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
firstName: signUpData.firstName,
|
||||
lastName: signUpData.lastName,
|
||||
dob: initialDate.current,
|
||||
password: signUpData.password,
|
||||
},
|
||||
criteriaMode: "all",
|
||||
});
|
||||
|
||||
const data = watch();
|
||||
|
||||
async function onSubmit() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await sdk.register(signUpData.email, data.password, role);
|
||||
if (!result.error) {
|
||||
localStorage.setItem("token", result.token);
|
||||
|
||||
// register device
|
||||
sdk.setTable("device");
|
||||
await sdk.callRestAPI({ active: 1, user_id: result.user_id, last_login_time: new Date().toISOString().split("T")[0], uid: localStorage.getItem("device-uid") }, "POST");
|
||||
|
||||
await callCustomAPI(
|
||||
"edit-self",
|
||||
"post",
|
||||
{
|
||||
user: {
|
||||
first_name: data.firstName,
|
||||
last_name: data.lastName,
|
||||
},
|
||||
profile: {
|
||||
dob: isSameDay(data.dob, initialDate.current) ? undefined : moment(data.dob).format("yyyy-MM-DD"),
|
||||
},
|
||||
},
|
||||
"",
|
||||
result.token,
|
||||
);
|
||||
|
||||
localStorage.removeItem("token");
|
||||
|
||||
authDispatch({ type: "ALLOW_CHECK_VERIFICATION" });
|
||||
navigate("/check-verification");
|
||||
localStorage.setItem("first_login", result.user_id);
|
||||
setLoading(false);
|
||||
} else {
|
||||
setLoading(false);
|
||||
if (result.validation) {
|
||||
const keys = Object.keys(result.validation);
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
const field = keys[i];
|
||||
setError(field, {
|
||||
type: "manual",
|
||||
message: result.validation[field],
|
||||
});
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
setLoading(false);
|
||||
setError("firstName", {
|
||||
type: "manual",
|
||||
message: err.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!signUpData.email) return <Navigate to={`/signup`} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex flex-col items-center justify-center bg-white md:w-1/2">
|
||||
<form
|
||||
className="flex w-full max-w-md flex-col px-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<h1 className="mb-8 text-center text-5xl font-bold">Finish Signing Up</h1>
|
||||
<div className="mb-8">
|
||||
<input
|
||||
type="text"
|
||||
{...register("firstName")}
|
||||
className="w-full resize-none rounded-md border bg-transparent py-2 px-4 focus:outline-none active:outline-none"
|
||||
placeholder="First name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-red-500 text-xs italic mt-2 block">{errors.firstName?.message}</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-8">
|
||||
<input
|
||||
type="text"
|
||||
{...register("lastName")}
|
||||
className="w-full resize-none rounded-md border bg-transparent py-2 px-4 focus:outline-none active:outline-none"
|
||||
placeholder="Last name"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<p className="text-red-500 text-xs italic mt-2 block">{errors.lastName?.message}</p>
|
||||
</div>
|
||||
|
||||
<DatePickerV2
|
||||
control={control}
|
||||
name="dob"
|
||||
min={new Date("1950-01-01")}
|
||||
max={initialDate.current}
|
||||
setValue={(v) => setValue("dob", v)}
|
||||
/>
|
||||
<div className={`${errors.password?.message && dirtyFields.password ? "border rounded-md border-[#C42945]" : "borde"} relative mb-4 flex justify-between rounded-md bg-transparent`}>
|
||||
<input
|
||||
autoComplete={showPassword ? "off" : "new-password"}
|
||||
type={showPassword ? "text" : "password"}
|
||||
{...register("password", {
|
||||
onChange: () => {
|
||||
trigger("password");
|
||||
},
|
||||
})}
|
||||
className="flex-grow rounded-md border p-2 px-4 focus:outline-none active:outline-none "
|
||||
placeholder="Password"
|
||||
/>{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-1 top-[20%]"
|
||||
>
|
||||
{" "}
|
||||
{showPassword ? (
|
||||
<img
|
||||
src="/show.png"
|
||||
alt=""
|
||||
className="mr-2 w-6"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
src="/invisible.png"
|
||||
alt=""
|
||||
className="mr-2 w-6"
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
<p className="mb-4 text-sm normal-case text-gray-500">
|
||||
Select and agree to {" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>setModalOpen(true)}
|
||||
className="underline"
|
||||
// target={"_blank"}
|
||||
>
|
||||
{" "} Terms and Conditions
|
||||
</button>
|
||||
{" "}
|
||||
to continue.
|
||||
{" "}
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>setPrivacyModalOpen(true)}
|
||||
className="underline"
|
||||
>
|
||||
Privacy Policy
|
||||
</button>
|
||||
</p>
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
type="submit"
|
||||
className={`disabled:cursor-not-allowed login-btn-gradient rounded tracking-wide text-white outline-none focus:outline-none ${loading ? "py-1" : "py-2"}`}
|
||||
// disabled={!recaptchaValidated}
|
||||
>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
</form>
|
||||
</section>
|
||||
<section
|
||||
style={{ backgroundImage: `url(${role == "host" ? "/host-sign-up.jpg" : "/sign-up-bg.jpg"})`, backgroundSize: "cover", backgroundRepeat: "no-repeat", backgroundPosition: "center" }}
|
||||
className="hidden w-1/2 md:block"
|
||||
>
|
||||
</section>
|
||||
<TermsAndConditionsModal
|
||||
isOpen={modalOpen}
|
||||
closeModal={closeModal}
|
||||
/>
|
||||
<PrivacyAndPolicyModal
|
||||
isOpen={privacyOpen}
|
||||
closeModal={closePrivacyModal}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { useState } from "react";
|
||||
import { Link, Navigate, useNavigate } from "react-router-dom";
|
||||
|
||||
import { yupResolver } from "@hookform/resolvers/yup";
|
||||
import { useForm } from "react-hook-form";
|
||||
import * as yup from "yup";
|
||||
import { useSignUpContext } from "./signUpContext";
|
||||
import { callCustomAPI, oauthLoginApi } from "@/utils/callCustomAPI";
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import TLDs from "@/assets/json/email-tlds.json";
|
||||
|
||||
const SignUpForm = () => {
|
||||
const navigate = useNavigate();
|
||||
const { signUpData, dispatch } = useSignUpContext();
|
||||
const role = signUpData.role;
|
||||
const schema = yup.object({
|
||||
email: yup
|
||||
.string(),
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
setError,
|
||||
formState: { errors },
|
||||
} = useForm({
|
||||
resolver: yupResolver(schema),
|
||||
defaultValues: {
|
||||
email: signUpData.email,
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = async (data) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await callCustomAPI("email-exist", "post", { email: data.email }, "");
|
||||
|
||||
if (result.error || result.exist) throw new Error("User already exists");
|
||||
|
||||
dispatch({ type: "SET_EMAIL", payload: data.email });
|
||||
navigate("/signup/details" + "?role=" + role);
|
||||
} catch (err) {
|
||||
setError("email", { type: "manual", message: err.message });
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const handleGoogleLogin = async () => {
|
||||
const result = await oauthLoginApi("google", role);
|
||||
window.open(result.data, "_self");
|
||||
};
|
||||
|
||||
const handleFacebookLogin = async () => {
|
||||
const result = await oauthLoginApi("facebook", role);
|
||||
window.open(result.data, "_self");
|
||||
};
|
||||
|
||||
const handleAppleLogin = async () => {
|
||||
const result = await oauthLoginApi("apple", role);
|
||||
window.open(result.data, "_self");
|
||||
};
|
||||
|
||||
|
||||
if (!signUpData.role) return <Navigate to={"/signup/select-role"} />;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="flex w-full flex-col items-center justify-center bg-white md:w-1/2">
|
||||
<form
|
||||
className="flex w-full max-w-md flex-col px-6"
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
autoComplete="off"
|
||||
>
|
||||
<h1 className="mb-8 text-center text-3xl font-semibold md:text-5xl md:font-bold">{role == "host" ? "Become a host" : "Sign up"}</h1>
|
||||
<input
|
||||
autoComplete="off"
|
||||
{...register("email")}
|
||||
type="text"
|
||||
className="mb-8 resize-none rounded-sm border-2 bg-transparent p-2 px-4 focus:outline-none active:outline-none"
|
||||
placeholder="Email"
|
||||
/>
|
||||
{Object.entries(errors).length > 0 ? (
|
||||
<p className="error-vibrate my-3 rounded-md border border-[#C42945] bg-white py-2 px-3 text-center text-sm normal-case text-[#C42945]">{Object.values(errors)[0].message}</p>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
<LoadingButton
|
||||
loading={loading}
|
||||
type="submit"
|
||||
className={`login-btn-gradient rounded tracking-wide text-white outline-none focus:outline-none ${loading ? "py-1" : "py-2"}`}
|
||||
>
|
||||
Continue
|
||||
</LoadingButton>
|
||||
|
||||
</form>
|
||||
<div className="hr my-6 text-center">OR</div>
|
||||
<div className="oauth flex w-full max-w-md flex-col gap-4 px-6 text-[#344054]">
|
||||
<button
|
||||
onClick={() => handleGoogleLogin()}
|
||||
className="flex items-center justify-center gap-2 border-2 py-[10px]"
|
||||
>
|
||||
<img
|
||||
src="/google-icon.png"
|
||||
className="h-[18px] w-[18px]"
|
||||
/>
|
||||
<span>Sign Up With Google</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleFacebookLogin()}
|
||||
className="flex items-center justify-center gap-2 border-2 py-[10px]"
|
||||
>
|
||||
<img
|
||||
src="/facebook-icon.png"
|
||||
className="h-[16px] w-[16px]"
|
||||
/>
|
||||
<span>Sign Up With Facebook</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleAppleLogin()}
|
||||
className="flex items-center justify-center gap-2 border-2 py-[10px]"
|
||||
>
|
||||
<img
|
||||
src="/apple-icon.png"
|
||||
className="h-[16px] w-[16px]"
|
||||
/>
|
||||
<span>Sign Up With Apple</span>
|
||||
</button>
|
||||
<div>
|
||||
<h3 className="text-center text-sm normal-case text-gray-800">
|
||||
Already have an account?{" "}
|
||||
<Link
|
||||
to={"/login" + "?role=" + role}
|
||||
className="my-text-gradient mb-8 self-end text-sm font-semibold"
|
||||
>
|
||||
Log In
|
||||
</Link>{" "}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
style={{ backgroundImage: `url(${role == "host" ? "/jumbotron1.jpg" : "/sign-up-bg.jpg"})`, backgroundSize: "cover", backgroundRepeat: "no-repeat", backgroundPosition: "center" }}
|
||||
className="hidden w-1/2 md:block bg-contain"
|
||||
></section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignUpForm;
|
||||
@@ -0,0 +1,57 @@
|
||||
import NextIcon from "@/components/frontend/icons/NextIcon";
|
||||
import React from "react";
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
import { useSignUpContext } from "./signUpContext";
|
||||
|
||||
export default function SignUpSelectRole() {
|
||||
const { dispatch } = useSignUpContext();
|
||||
const navigate = useNavigate();
|
||||
|
||||
function selectHost() {
|
||||
dispatch({ type: "SET_ROLE", payload: "host" });
|
||||
navigate("/signup");
|
||||
}
|
||||
|
||||
function selectCustomer() {
|
||||
dispatch({ type: "SET_ROLE", payload: "customer" });
|
||||
navigate("/signup");
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full flex items-center justify-center normal-case">
|
||||
<div className="max-w-3xl mx-auto w-full text-center mb-40">
|
||||
<h1 className="text-5xl font-semibold mb-4">Sign Up</h1>
|
||||
<p>Select an option below</p>
|
||||
<br />
|
||||
<hr />
|
||||
<br />
|
||||
<div className="flex flex-col gap-12 items-center">
|
||||
<button
|
||||
className="py-4 px-4 shadow-sm w-full max-w-sm hover:shadow-lg duration-200 border rounded-xl hover:ring-2 ring-[#0d9895] focus:outline-none focus:ring-2"
|
||||
onClick={selectHost}
|
||||
>
|
||||
<span className="span flex justify-between items-center mb-4">
|
||||
<span className="font-semibold text-2xl">Sign up as host</span>
|
||||
<span className="">
|
||||
<NextIcon />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<button
|
||||
className="py-4 px-4 shadow-sm w-full max-w-sm hover:shadow-lg duration-200 border rounded-xl hover:ring-2 ring-[#0d9895] focus:outline-none focus:ring-2"
|
||||
onClick={selectCustomer}
|
||||
>
|
||||
<span className="span flex justify-between items-center mb-4">
|
||||
<span className="font-semibold text-2xl">Sign up as customer</span>
|
||||
<span className="">
|
||||
<NextIcon />
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { LoadingButton } from "@/components/frontend";
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { useEffect } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useState } from "react";
|
||||
import { Fragment } from "react";
|
||||
|
||||
export default function TermsAndConditionsModal({ isOpen, closeModal, setIsAgreed }) {
|
||||
const [termsAndConditions, setTermsAndCondition] = useState("");
|
||||
const [agreed, setAgreed] = useState(false);
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
async function fetchTermsAndConditions() {
|
||||
try {
|
||||
const result = await callCustomAPI("cms", "post", { where: [`content_key = 'terms_and_conditions'`], limit: 1, page: 1 }, "PAGINATE");
|
||||
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setTermsAndCondition(result.list[0].content_value);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Cannot get Terms and Conditions",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTermsAndConditions();
|
||||
}, []);
|
||||
return (
|
||||
<>
|
||||
<div className={`${isOpen ? "flex" : "hidden"} fixed inset-0 items-center justify-center`}></div>
|
||||
|
||||
<Transition
|
||||
appear
|
||||
show={isOpen}
|
||||
as={Fragment}
|
||||
>
|
||||
<Dialog
|
||||
as="div"
|
||||
className="relative z-10"
|
||||
onClose={closeModal}
|
||||
>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-25" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4 text-center">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="w-full max-w-6xl transform overflow-hidden rounded-2xl bg-white p-6 text-left align-middle shadow-xl transition-all">
|
||||
<Dialog.Title
|
||||
as="h3"
|
||||
className="text-lg font-medium leading-6 text-gray-900 flex justify-between items-center"
|
||||
>
|
||||
{" "}
|
||||
{" "}
|
||||
<button
|
||||
type="button"
|
||||
onClick={closeModal}
|
||||
className="py-2 border hover:bg-gray-200 active:bg-gray-300 duration-100 px-3 text-2xl font-normal rounded-full flex justify-end"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</Dialog.Title>
|
||||
<div className="mt-2">
|
||||
<article
|
||||
className="sun-editor-editable text-sm max-h-[600px] overflow-y-auto my-8"
|
||||
dangerouslySetInnerHTML={{ __html: termsAndConditions }}
|
||||
></article>
|
||||
</div>
|
||||
<div className="checkbox-container">
|
||||
<input
|
||||
type={"checkbox"}
|
||||
name="i-agree"
|
||||
id="i-agree"
|
||||
checked={agreed}
|
||||
onChange={() => {setAgreed((prev) => !prev); setIsAgreed((prev) => !prev); closeModal()}}
|
||||
/>
|
||||
<label
|
||||
htmlFor="i-agree"
|
||||
className="items-center cursor-pointer remove-select"
|
||||
>
|
||||
Yeah, I agree to everything
|
||||
</label>
|
||||
</div>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, { useContext, useState } from "react";
|
||||
import { useEffect } from "react";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import { GlobalContext, showToast } from "@/globalContext";
|
||||
|
||||
const VerifyEmailPage = () => {
|
||||
const [searchParams] = useSearchParams();
|
||||
const [pageText, setPageText] = useState("Verifying your email...");
|
||||
const navigate = useNavigate();
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
let sdk = new MkdSDK();
|
||||
|
||||
async function verifyEmail() {
|
||||
const token = searchParams.get("token");
|
||||
try {
|
||||
const result = await sdk.verifyEmail(token);
|
||||
|
||||
if (searchParams.get("is_manual") != "true") {
|
||||
// only send signup confirmation email if email verification link was not triggered manually
|
||||
const user = await callCustomAPI("get-user", "post", { id: result.user_id }, "");
|
||||
const tmpl = await sdk.getEmailTemplate("signup-confirmation");
|
||||
const body = tmpl.html?.replace(new RegExp("{{{first_name}}}", "g"), user.first_name).replace(new RegExp("{{{last_name}}}", "g"), user.last_name);
|
||||
|
||||
await sdk.sendEmail(user.email, tmpl.subject, body);
|
||||
}
|
||||
|
||||
if (!result.error) {
|
||||
showToast(globalDispatch, "Email verified", 3000, "success");
|
||||
}
|
||||
|
||||
setPageText("Your account has been verified, You will be redirected to login page shortly");
|
||||
|
||||
setTimeout(() => {
|
||||
navigate(`/login`);
|
||||
}, 4000);
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
verifyEmail();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<h1 className="text-5xl">{pageText}</h1>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyEmailPage;
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
import React, { createContext, useContext, useReducer } from "react";
|
||||
|
||||
const initialSignUpData = {
|
||||
email: "",
|
||||
firstName: "",
|
||||
lastName: "",
|
||||
dob: "",
|
||||
password: "",
|
||||
role: "",
|
||||
};
|
||||
|
||||
const reducer = (state, action) => {
|
||||
switch (action.type) {
|
||||
case "SET_EMAIL":
|
||||
return { ...state, email: action.payload };
|
||||
case "SET_ROLE":
|
||||
return { ...state, role: action.payload };
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
|
||||
// create context here
|
||||
const signUpContext = createContext({});
|
||||
|
||||
// wrap this component around App.tsx to get access to userData in all components
|
||||
const SignUpContextProvider = ({ children }) => {
|
||||
const [signUpData, dispatch] = useReducer(reducer, initialSignUpData);
|
||||
|
||||
return <signUpContext.Provider value={{ signUpData, dispatch }}>{children}</signUpContext.Provider>;
|
||||
};
|
||||
|
||||
// use this custom hook to get the data in any component in component tree
|
||||
const useSignUpContext = () => useContext(signUpContext);
|
||||
export { useSignUpContext, SignUpContextProvider };
|
||||
@@ -0,0 +1,48 @@
|
||||
import { GlobalContext } from "@/globalContext";
|
||||
import { callCustomAPI } from "@/utils/callCustomAPI";
|
||||
import MkdSDK from "@/utils/MkdSDK";
|
||||
import React, { useState } from "react";
|
||||
import { useContext } from "react";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export default function TermsAndConditionsPage() {
|
||||
const [content, setContent] = useState("");
|
||||
const { dispatch: globalDispatch } = useContext(GlobalContext);
|
||||
|
||||
async function fetchTermsAndConditions() {
|
||||
globalDispatch({ type: "START_LOADING" });
|
||||
const sdk = new MkdSDK();
|
||||
sdk.setTable("cms");
|
||||
try {
|
||||
const result = await callCustomAPI("cms", "post", { where: [`content_key = 'terms_and_conditions'`], limit: 1, page: 1 }, "PAGINATE");
|
||||
|
||||
if (Array.isArray(result.list) && result.list.length > 0) {
|
||||
setContent(result.list[0].content_value);
|
||||
}
|
||||
} catch (err) {
|
||||
globalDispatch({
|
||||
type: "SHOW_ERROR",
|
||||
payload: {
|
||||
heading: "Cannot get Cancellation policy",
|
||||
message: err.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
globalDispatch({ type: "STOP_LOADING" });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchTermsAndConditions();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="mt-[120px] min-h-screen normal-case text-sm">
|
||||
<div className="container mx-auto 2xl:px-32 px-4">
|
||||
<article
|
||||
className="sun-editor-editable"
|
||||
dangerouslySetInnerHTML={{ __html: content }}
|
||||
></article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user