initial commit

This commit is contained in:
undefined
2025-01-24 20:05:48 +01:00
commit db55c10f43
484 changed files with 118165 additions and 0 deletions
@@ -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 dont 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>
);
}
+14
View File
@@ -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;
+550
View File
@@ -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>
);
}
+14
View File
@@ -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>
);
}
+33
View File
@@ -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>
);
}
+112
View File
@@ -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;
+626
View File
@@ -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;
+106
View File
@@ -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 cant find your answers were here to help. <br />
</p>
<Link
to="/contact-us"
className="underline"
>
Contact us
</Link>
</section>
</>
);
};
export default FaqPage;
+298
View File
@@ -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;
+574
View File
@@ -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}&section=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=&section=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}&section=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;
+428
View File
@@ -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>
);
}
+40
View File
@@ -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;
+25
View File
@@ -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;
+120
View File
@@ -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>
);
}
+184
View File
@@ -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;
+10
View File
@@ -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>
);
}
+31
View File
@@ -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 };
+141
View File
@@ -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>
);
}
+994
View File
@@ -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 cant 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 cant 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"
>
&#x2715;
</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;
+405
View File
@@ -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",
"🖕"
]
+48
View File
@@ -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>
);
}
+476
View File
@@ -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)}
>
&#x2715;
</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)}
>
&#x2715;
</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)}
>
&#x2715;
</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"
>
&#x2715;
</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;
+464
View File
@@ -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>
);
}
+27
View File
@@ -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"
>
&#x2715;
</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}
/>
</>
);
}
+150
View File
@@ -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"
>
&#x2715;
</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
+35
View File
@@ -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>
);
}