new features + bug fixes
This commit is contained in:
parent
177e54445c
commit
a5f0faf242
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 82 KiB |
@ -104,7 +104,7 @@ const Breadcrumb: React.FunctionComponent<Breadcrumb> = ({ data, wrapperClass, c
|
||||
|
||||
return (
|
||||
<div className={`${wrapperClass}`}>
|
||||
<div className={`${className}`}>
|
||||
<div className={`${className} ltr:[direction:ltr]`}>
|
||||
{withIcon && <SignsPostSolid className="inline-block w-5 h-5 lg:w-5 lg:h-5 ml-4 fill-market-title-light" />}
|
||||
{finalPath.map((x, i) => (
|
||||
<span key={i} className="inline-flex items-center capitalize shrink-0 relative">
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DayPicker } from 'react-day-picker/persian';
|
||||
import { DayPicker as EngDayPicker, getDefaultClassNames } from 'react-day-picker';
|
||||
import "react-day-picker/style.css";
|
||||
@ -30,10 +30,16 @@ interface Props {
|
||||
weeksClass?: string;
|
||||
mode?: any;
|
||||
animate?: boolean;
|
||||
label?: string;
|
||||
value?: DayPickerValueFormat;
|
||||
startMonth?: Date;
|
||||
endMonth?: Date;
|
||||
disabled?: Date[];
|
||||
datesToShow: Date[];
|
||||
noClear?: boolean;
|
||||
navLayout?: "around" | "after";
|
||||
onChange: (dates: Date[]) => void;
|
||||
themeStyles: React.CSSProperties;
|
||||
themeStyles?: React.CSSProperties;
|
||||
}
|
||||
|
||||
const DayPickerCalendar = ({
|
||||
@ -54,7 +60,13 @@ const DayPickerCalendar = ({
|
||||
weeksClass,
|
||||
mode = "single",
|
||||
animate = false,
|
||||
label,
|
||||
value = undefined,
|
||||
startMonth,
|
||||
endMonth,
|
||||
datesToShow,
|
||||
disabled,
|
||||
noClear,
|
||||
navLayout,
|
||||
onChange,
|
||||
themeStyles,
|
||||
@ -67,11 +79,14 @@ const DayPickerCalendar = ({
|
||||
|
||||
// variables
|
||||
const { locale } = useGetRouter();
|
||||
const isFa = locale === 'fa';
|
||||
const translate = useTranslate();
|
||||
const isFa = locale === 'fa';
|
||||
const defaultClassNames = getDefaultClassNames();
|
||||
const startDate = datesToShow[0];
|
||||
const endDate = datesToShow[datesToShow.length - 1];
|
||||
|
||||
const modifiers = {
|
||||
weekend: { dayOfWeek: isFa ? [4, 5] : [0, 6] } // Sunday = 0, Saturday = 6
|
||||
weekend: { dayOfWeek: isFa ? [4, 5] : [0, 6] }, // Sunday = 0, Saturday = 6
|
||||
};
|
||||
const modifiersStyles = {
|
||||
weekend: { color: "red" }
|
||||
@ -79,24 +94,24 @@ const DayPickerCalendar = ({
|
||||
|
||||
// methods
|
||||
const inputLabelMaker = (date: Date[]) => {
|
||||
let label: string = translate("dashboard-billboard-orders-date-range-title");
|
||||
let labelText: any = label ?? translate("dashboard-billboard-orders-date-range-title");
|
||||
if (date.length > 0) {
|
||||
switch (mode) {
|
||||
case "single":
|
||||
label = mtd(date[0]);
|
||||
labelText = mtd(date[0]);
|
||||
break;
|
||||
case "range":
|
||||
label = `${mtd(date[0])} - ${mtd(date[1])}`
|
||||
labelText = `${mtd(date[0])} - ${mtd(date[1])}`
|
||||
break;
|
||||
case "multiple":
|
||||
label = `${mtd(date[0])} - ${mtd(date.slice(-1))}`
|
||||
labelText = `${mtd(date[0])} - ${mtd(date.slice(-1))}`
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return label;
|
||||
return labelText;
|
||||
}
|
||||
const covertToDateArray = (date: any) => {
|
||||
let dateOutput: Date[] = [];
|
||||
@ -132,6 +147,10 @@ const DayPickerCalendar = ({
|
||||
const sharedProps = {
|
||||
mode: mode,
|
||||
selected,
|
||||
disabled,
|
||||
startMonth,
|
||||
endMonth,
|
||||
animate,
|
||||
onSelect: handleDateSelection,
|
||||
classNames: {
|
||||
day: `${defaultClassNames.day} select-none rounded-full hover:lg:bg-neutral-100 ${dayClass}`,
|
||||
@ -149,6 +168,11 @@ const DayPickerCalendar = ({
|
||||
...rest
|
||||
};
|
||||
|
||||
// effects
|
||||
useEffect(() => {
|
||||
if (value !== selected) setSelected(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div className={`block relative ${wrapperClass}`}>
|
||||
<span
|
||||
@ -156,7 +180,7 @@ const DayPickerCalendar = ({
|
||||
className={`flex items-center gap-4 w-fit px-4 py-2 ring-1 ring-gray-100 rounded-xl lg:cursor-pointer ${buttonClass}`}
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
>
|
||||
{selected ?
|
||||
{(selected && !noClear) ?
|
||||
<XmarkSolid className="inline-block size-5 text-current fill-secondary-light shrink-0" onClick={resetCalendar} />
|
||||
:
|
||||
<CalendarCheckSolid className="inline-block size-5 text-current fill-secondary-light shrink-0" />
|
||||
@ -169,7 +193,7 @@ const DayPickerCalendar = ({
|
||||
open={isOpen}
|
||||
onClose={() => setIsOpen(false)}
|
||||
title={translate("dpc-title")}
|
||||
style={themeStyles}
|
||||
style={themeStyles ?? null}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-fit lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar overflow-visible flex items-center justify-center"
|
||||
headerClass="!h-12"
|
||||
|
||||
@ -367,19 +367,19 @@ const Gallery: React.FunctionComponent<GalleryProps> = ({
|
||||
<div className="hidden lg:flex absolute inset-0 items-center justify-between px-10 pointer-events-none z-[120]">
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRTL ? handlePrev : handleNext}
|
||||
onClick={handlePrev}
|
||||
className="p-4 bg-white/10 rounded-full text-white pointer-events-auto hover:bg-white/20 transition-all active:scale-95 disabled:opacity-0"
|
||||
disabled={isRTL ? picIndex === 0 : picIndex === items.length - 1}
|
||||
disabled={picIndex === 0}
|
||||
>
|
||||
<ArrowRight className="size-6 fill-white" />
|
||||
{isRTL ? <ArrowRight className="size-6 fill-white" /> : <ArrowLeft className="size-6 fill-white" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={isRTL ? handleNext : handlePrev}
|
||||
onClick={handleNext}
|
||||
className="p-4 bg-white/10 rounded-full text-white pointer-events-auto hover:bg-white/20 transition-all active:scale-95 disabled:opacity-0"
|
||||
disabled={isRTL ? picIndex === items.length - 1 : picIndex === 0}
|
||||
disabled={picIndex === items.length - 1}
|
||||
>
|
||||
<ArrowLeft className="size-6 fill-white" />
|
||||
{isRTL ? <ArrowLeft className="size-6 fill-white" /> : <ArrowRight className="size-6 fill-white" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -47,23 +47,28 @@ const Schedule: React.FC<ScheduleProps> = ({
|
||||
|
||||
const translate = useTranslate();
|
||||
const daysOptions = [
|
||||
...weekDays,
|
||||
...weekDays,
|
||||
"everyday",
|
||||
"working-days",
|
||||
"weekend"
|
||||
];
|
||||
|
||||
|
||||
const startHour = dayData.workingHours.from.slice(0, dayData.workingHours.from.indexOf(":"));
|
||||
const startMinute = dayData.workingHours.from.slice(dayData.workingHours.from.indexOf(":") + 1);
|
||||
const endHour = dayData.workingHours.to.slice(0, dayData.workingHours.to.indexOf(":"));
|
||||
const endMinute = dayData.workingHours.to.slice(dayData.workingHours.to.indexOf(":") + 1);
|
||||
|
||||
// states
|
||||
const [weekDay, setWeekDay] = useState<string>(dayData.dayOfTheWeek);
|
||||
const [working, setWorking] = useState<Time>({
|
||||
startHour: dayData.workingHours.from.slice(0, dayData.workingHours.from.indexOf(":")),
|
||||
startMinute: dayData.workingHours.from.slice(dayData.workingHours.from.indexOf(":") + 1),
|
||||
endHour: dayData.workingHours.to.slice(0, dayData.workingHours.to.indexOf(":")),
|
||||
endMinute: dayData.workingHours.to.slice(dayData.workingHours.to.indexOf(":") + 1)
|
||||
startHour: startHour,
|
||||
startMinute: startMinute,
|
||||
endHour: endHour,
|
||||
endMinute: endMinute
|
||||
});
|
||||
const [isClosed, setIsClosed] = useState(dayData.isClosed);
|
||||
const [popupOpen, setPopupOpen] = useState(open);
|
||||
|
||||
|
||||
// variables
|
||||
|
||||
// methods
|
||||
@ -71,7 +76,7 @@ const Schedule: React.FC<ScheduleProps> = ({
|
||||
setPopupOpen(false);
|
||||
onClose();
|
||||
}
|
||||
const handleWorkingTimeSlection = (workingTime: Time) => {
|
||||
const handleWorkingTimeSelection = (workingTime: Time) => {
|
||||
setWorking(workingTime);
|
||||
}
|
||||
const handleIsClosed = (closed: boolean) => {
|
||||
@ -82,7 +87,7 @@ const Schedule: React.FC<ScheduleProps> = ({
|
||||
}
|
||||
|
||||
const handleDaySchedule = () => {
|
||||
let finalSchedule: ScheduleFormat[] = [];
|
||||
let finalSchedule: ScheduleFormat[] = [];
|
||||
(getWeekDaysList(weekDay.toLowerCase().replace("-", " ")) as string[]).map(x => {
|
||||
finalSchedule.push(
|
||||
{
|
||||
@ -102,7 +107,7 @@ const Schedule: React.FC<ScheduleProps> = ({
|
||||
useEffect(() => {
|
||||
open !== popupOpen && setPopupOpen(open);
|
||||
}, [open]);
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={true}
|
||||
@ -137,21 +142,21 @@ const Schedule: React.FC<ScheduleProps> = ({
|
||||
{translate("dashboard-billboard-edit-hours-select-working-hours")}
|
||||
</Label>
|
||||
<div>
|
||||
<TimePicker
|
||||
id="select-working-hours"
|
||||
onChange={handleWorkingTimeSlection}
|
||||
<TimePicker
|
||||
id="select-working-hours"
|
||||
onChange={handleWorkingTimeSelection}
|
||||
wrapperClass={``}
|
||||
initialTime={{
|
||||
startHour: Number(working.startHour),
|
||||
startMinute: Number(working.startMinute),
|
||||
endHour: Number(working.endHour),
|
||||
endMinute: Number(working.endMinute)
|
||||
startHour: Number(startHour),
|
||||
startMinute: Number(startMinute),
|
||||
endHour: Number(endHour),
|
||||
endMinute: Number(endMinute)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<CheckBox
|
||||
forId="billboard-dashboard-is-closed-check"
|
||||
<CheckBox
|
||||
forId="billboard-dashboard-is-closed-check"
|
||||
name="billboard-dashboard-is-closed-check"
|
||||
title={translate("is-closed")}
|
||||
value="closed"
|
||||
|
||||
@ -2701,3 +2701,21 @@ export const CompressSolid: React.FC<IconProps> = ({ className, onClick }) => {
|
||||
</svg>;
|
||||
return Icon;
|
||||
}
|
||||
export const TicketLight: React.FC<IconProps> = ({ className, onClick }) => {
|
||||
const Icon = <svg onClick={(e) => onClick && onClick(e)} className={`${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
|
||||
<path d="M128 192C128 174.3 142.3 160 160 160H416C433.7 160 448 174.3 448 192V320C448 337.7 433.7 352 416 352H160C142.3 352 128 337.7 128 320V192zM160 320H416V192H160V320zM576 128V208C549.5 208 528 229.5 528 256C528 282.5 549.5 304 576 304V384C576 419.3 547.3 448 512 448H64C28.65 448 0 419.3 0 384V304C26.51 304 48 282.5 48 256C48 229.5 26.51 208 0 208V128C0 92.65 28.65 64 64 64H512C547.3 64 576 92.65 576 128zM32 182.7C60.25 195 80 223.2 80 256C80 288.8 60.25 316.1 32 329.3V384C32 401.7 46.33 416 64 416H512C529.7 416 544 401.7 544 384V329.3C515.7 316.1 496 288.8 496 256C496 223.2 515.7 195 544 182.7V128C544 110.3 529.7 96 512 96H64C46.33 96 32 110.3 32 128V182.7z" />
|
||||
</svg>;
|
||||
return Icon;
|
||||
}
|
||||
export const CartPlusSolid: React.FC<IconProps> = ({ className, onClick }) => {
|
||||
const Icon = <svg onClick={(e) => onClick && onClick(e)} className={`${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
|
||||
<path d="M0 24C0 10.7 10.7 0 24 0H69.5c22 0 41.5 12.8 50.6 32h411c26.3 0 45.5 25 38.6 50.4l-41 152.3c-8.5 31.4-37 53.3-69.5 53.3H170.7l5.4 28.5c2.2 11.3 12.1 19.5 23.6 19.5H488c13.3 0 24 10.7 24 24s-10.7 24-24 24H199.7c-34.6 0-64.3-24.6-70.7-58.5L77.4 54.5c-.7-3.8-4-6.5-7.9-6.5H24C10.7 48 0 37.3 0 24zM128 464a48 48 0 1 1 96 0 48 48 0 1 1 -96 0zm336-48a48 48 0 1 1 0 96 48 48 0 1 1 0-96zM252 160c0 11 9 20 20 20h44v44c0 11 9 20 20 20s20-9 20-20V180h44c11 0 20-9 20-20s-9-20-20-20H356V96c0-11-9-20-20-20s-20 9-20 20v44H272c-11 0-20 9-20 20z" />
|
||||
</svg>;
|
||||
return Icon;
|
||||
}
|
||||
export const CartLight: React.FC<IconProps> = ({ className, onClick }) => {
|
||||
const Icon = <svg onClick={(e) => onClick && onClick(e)} className={`${className}`} xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512">
|
||||
<path d="M16 0C7.2 0 0 7.2 0 16s7.2 16 16 16H53.9c7.6 0 14.2 5.3 15.7 12.8l58.9 288c6.1 29.8 32.3 51.2 62.7 51.2H496c8.8 0 16-7.2 16-16s-7.2-16-16-16H191.2c-15.2 0-28.3-10.7-31.4-25.6L152 288H466.5c29.4 0 55-20 62.1-48.5L570.6 71.8c5-20.2-10.2-39.8-31-39.8H99.1C92.5 13 74.4 0 53.9 0H16zm90.1 64H539.5L497.6 231.8C494 246 481.2 256 466.5 256H145.4L106.1 64zM168 456a24 24 0 1 1 48 0 24 24 0 1 1 -48 0zm80 0a56 56 0 1 0 -112 0 56 56 0 1 0 112 0zm200-24a24 24 0 1 1 0 48 24 24 0 1 1 0-48zm0 80a56 56 0 1 0 0-112 56 56 0 1 0 0 112z" />
|
||||
</svg>;
|
||||
return Icon;
|
||||
}
|
||||
|
||||
119
src/common/components/input/text-area.tsx
Normal file
119
src/common/components/input/text-area.tsx
Normal file
@ -0,0 +1,119 @@
|
||||
import { XMark } from "components/icons";
|
||||
import { useState, useEffect, useRef, useCallback } from "react"
|
||||
import { debounce, LetterCounter, stripHtmlInput } from "services/general/general";
|
||||
|
||||
interface TextArea extends React.TextareaHTMLAttributes <HTMLTextAreaElement> {
|
||||
readonly?: boolean;
|
||||
required?: boolean;
|
||||
clear?: boolean;
|
||||
value?: string | number | undefined;
|
||||
placeholder?: string;
|
||||
id?: string;
|
||||
name?: string | undefined;
|
||||
wrapperClass?: string;
|
||||
className?: string;
|
||||
clearClass? : string;
|
||||
clearIcon? : string;
|
||||
unitClass? : string;
|
||||
maxCharClass? : string;
|
||||
onInput?: (text: any) => void;
|
||||
onLoad?: (ref: any) => void;
|
||||
onBlur?: () => void;
|
||||
autoComplete?: string;
|
||||
autoFocus?: boolean;
|
||||
stripeHtml?: boolean;
|
||||
unit?: string | number;
|
||||
maxChar?: number;
|
||||
inputDelay?: number; // milliseconds
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TextArea: React.FunctionComponent<TextArea> = ({
|
||||
wrapperClass,
|
||||
className,
|
||||
clear = false,
|
||||
stripeHtml = false,
|
||||
clearClass,
|
||||
clearIcon,
|
||||
unitClass,
|
||||
maxCharClass,
|
||||
readonly,
|
||||
required,
|
||||
autoComplete,
|
||||
maxChar,
|
||||
autoFocus,
|
||||
value = '',
|
||||
placeholder,
|
||||
name,
|
||||
id,
|
||||
onInput,
|
||||
onLoad,
|
||||
onBlur,
|
||||
unit,
|
||||
inputDelay = 0,
|
||||
disabled = false,
|
||||
...props
|
||||
}) => {
|
||||
|
||||
const [inputValue, setInputValue] = useState(value);
|
||||
const [charCount, setCharCount] = useState(maxChar);
|
||||
|
||||
const textBox: any = useRef(null);
|
||||
|
||||
const debouncedOnInput = useCallback(
|
||||
debounce((value: string) => {
|
||||
onInput && (stripeHtml ? onInput(stripHtmlInput(value)) : onInput(value));
|
||||
}, inputDelay),
|
||||
[onInput, inputDelay, stripeHtml]
|
||||
);
|
||||
|
||||
const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value;
|
||||
setInputValue(value);
|
||||
if (maxChar) {
|
||||
setCharCount(maxChar - value.length);
|
||||
}
|
||||
debouncedOnInput(value);
|
||||
};
|
||||
|
||||
const handleClear = () => {
|
||||
setInputValue("");
|
||||
onInput && onInput("");
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setInputValue(value);
|
||||
maxChar && setCharCount(maxChar - LetterCounter(value));
|
||||
}, [value])
|
||||
|
||||
useEffect(() => {
|
||||
autoFocus && textBox.current.focus();
|
||||
onLoad && onLoad(textBox.current);
|
||||
}, [autoFocus])
|
||||
|
||||
return (
|
||||
<div className={`flex items-center relative ${wrapperClass}`}>
|
||||
<textarea
|
||||
id={id}
|
||||
ref={textBox}
|
||||
readOnly={readonly}
|
||||
required={required}
|
||||
value={inputValue}
|
||||
name={name}
|
||||
placeholder={placeholder}
|
||||
autoComplete={autoComplete}
|
||||
onChange={handleInput}
|
||||
autoFocus={autoFocus}
|
||||
className={`${className} ${maxChar ? "rtl:pl-14 ltr:pr-14" : ""} ${disabled ? "bg-neutral-100 cursor-not-allowed" : ""}`}
|
||||
maxLength={maxChar}
|
||||
onBlur={() => onBlur && onBlur()}
|
||||
disabled={disabled}
|
||||
{...props}
|
||||
/>
|
||||
{maxChar && <span className={`absolute ltr:right-2 rtl:left-2 px-3 pt-[2px] ${maxChar - LetterCounter(inputValue) < 10 ? "bg-rose-200 text-error-text" : "bg-market-input text-market-title-light"} rounded text-sm shrink-0 ${maxCharClass}`}>{charCount}</span>}
|
||||
{(clear && inputValue !== "") && <XMark className={`inline-block absolute lg:cursor-pointer ${clearClass}`} onClick={handleClear} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TextArea
|
||||
|
||||
@ -65,6 +65,7 @@ const TimePicker: React.FunctionComponent<TimePickerProps> = ({
|
||||
endHour: null,
|
||||
endMinute: null
|
||||
});
|
||||
const initialData = useRef(initialTime);
|
||||
|
||||
// methods
|
||||
const handleInput = (value: number, name: string) => {
|
||||
@ -135,6 +136,19 @@ const TimePicker: React.FunctionComponent<TimePickerProps> = ({
|
||||
}
|
||||
|
||||
// useEffect
|
||||
useEffect(() => {
|
||||
if (initialData.current.startHour !== initialTime.startHour && (timeLabel.startHour !== "--" && timeLabel.endHour !== "--")) {
|
||||
setTime(initialTime);
|
||||
setTimeLabel({
|
||||
startHour: initialTime.startHour === -1 ? "--" : (initialTime.startHour < 10) ? String(initialTime.startHour).padStart(2, '0') : String(initialTime.startHour),
|
||||
startMinute: initialTime.startMinute === -1 ? "--" : (initialTime.startMinute < 10) ? String(initialTime.startMinute).padStart(2, '0') : String(initialTime.startMinute),
|
||||
endHour: initialTime.endHour === -1 ? "--" : (initialTime.endHour < 10) ? String(initialTime.endHour).padStart(2, '0') : String(initialTime.endHour),
|
||||
endMinute: initialTime.endMinute === -1 ? "--" : (initialTime.endMinute < 10) ? String(initialTime.endMinute).padStart(2, '0') : String(initialTime.endMinute)
|
||||
});
|
||||
}
|
||||
initialData.current = initialTime;
|
||||
}, [initialTime])
|
||||
|
||||
useEffect(() => {
|
||||
onChange(timeLabel);
|
||||
}, [time])
|
||||
|
||||
@ -5,30 +5,40 @@ import React from 'react';
|
||||
interface StarRatingProps {
|
||||
rating: number;
|
||||
starsCount?: number,
|
||||
mode?: "single" | "multiple";
|
||||
className?: string,
|
||||
starClass?: string,
|
||||
singleWrapperClass?: string,
|
||||
singleRateClass?: string,
|
||||
}
|
||||
|
||||
const StarRating = ({ rating, starsCount = 5, className, starClass }:StarRatingProps) => {
|
||||
const StarRating = ({ rating, starsCount = 5, mode = "multiple", className, starClass, singleWrapperClass, singleRateClass }: StarRatingProps) => {
|
||||
const percentage = Math.trunc((rating - Math.trunc(rating)) * 100);
|
||||
|
||||
return (
|
||||
<div className={`flex items-center space-x-1 rtl:space-x-reverse ${className}`}>
|
||||
{Array.from(Array(starsCount).keys()).map((x, i) => (
|
||||
(i < Math.trunc(rating)) ?
|
||||
<StarSolid key={i} className={`fill-current ${starClass}`} />
|
||||
:
|
||||
// percentage > 0 ? <StarHalfStrokeSolid className={`mr-1 fill-current ${starClass}`} /> : <StarRegular className={`mr-1 fill-current ${starClass}`} />
|
||||
i - Math.trunc(rating) >= 1 ?
|
||||
<StarRegular key={i} className={`fill-current ${starClass}`} />
|
||||
:
|
||||
<div key={i} className="inline-block w-full relative">
|
||||
<StarRegular className={`fill-current ${starClass}`} />
|
||||
<div style={{ "--width": `${percentage > 50 ? 55 : percentage}%` } as React.CSSProperties} className="inline-block w-[var(--width)] absolute top-0 ltr:left-0 rtl:right-0 overflow-hidden">
|
||||
<StarSolid className={`fill-current ${starClass}`} />
|
||||
<div className={`flex items-center gap-1 ${className}`}>
|
||||
{mode === "multiple" ?
|
||||
Array.from(Array(starsCount).keys()).map((x, i) => (
|
||||
(i < Math.trunc(rating)) ?
|
||||
<StarSolid key={i} className={`fill-current ${starClass}`} />
|
||||
:
|
||||
// percentage > 0 ? <StarHalfStrokeSolid className={`mr-1 fill-current ${starClass}`} /> : <StarRegular className={`mr-1 fill-current ${starClass}`} />
|
||||
i - Math.trunc(rating) >= 1 ?
|
||||
<StarRegular key={i} className={`fill-current ${starClass}`} />
|
||||
:
|
||||
<div key={i} className="inline-block w-full relative">
|
||||
<StarRegular className={`fill-current ${starClass}`} />
|
||||
<div style={{ "--width": `${percentage > 50 ? 55 : percentage}%` } as React.CSSProperties} className="inline-block w-[var(--width)] absolute top-0 ltr:left-0 rtl:right-0 overflow-hidden">
|
||||
<StarSolid className={`fill-current ${starClass}`} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))
|
||||
:
|
||||
<div className={`flex items-center gap-1 w-full relative ${singleWrapperClass}`}>
|
||||
<span className={`text-sm mt-1 ${singleRateClass}`}>{rating}</span>
|
||||
<StarSolid className={`fill-current ${starClass}`} />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -6,199 +6,199 @@ import { ChevronLeftSolid, ChevronRightSolid } from 'components/icons';
|
||||
import Autoplay from 'embla-carousel-autoplay'
|
||||
|
||||
interface SliderProps {
|
||||
slides: any;
|
||||
autoplay?: boolean;
|
||||
options?: EmblaOptionsType;
|
||||
onClick?: () => void;
|
||||
onScroll?: (index: number) => void;
|
||||
currentIndex?: number;
|
||||
childComponent: (x:any, i: number) => any;
|
||||
space: number,
|
||||
direction?: "ltr" | "rtl";
|
||||
loop?: boolean;
|
||||
autoplayDelay?: number;
|
||||
buttonIcons?: {
|
||||
prev: any;
|
||||
next: any;
|
||||
};
|
||||
slidesToShow: number[];
|
||||
slideSizes?: number[],
|
||||
slideType?: "percentage" | "size";
|
||||
sliderClass?: string;
|
||||
viewPortClass?: string;
|
||||
containerClass?: string;
|
||||
slideClass?: string;
|
||||
prevClass?: string;
|
||||
nextClass?: string;
|
||||
dotClass?: string;
|
||||
dragFree?: boolean;
|
||||
controlsContainerClass?: string;
|
||||
buttonsWrapperClass?: string;
|
||||
dotsWrapperClass?: string;
|
||||
buttons?: boolean;
|
||||
dots?: boolean;
|
||||
startIndex?: number;
|
||||
align?: "start" | "center" | "end";
|
||||
slidesToScroll?: number;
|
||||
slides: any;
|
||||
autoplay?: boolean;
|
||||
options?: EmblaOptionsType;
|
||||
onClick?: () => void;
|
||||
onScroll?: (index: number) => void;
|
||||
currentIndex?: number;
|
||||
childComponent: (x: any, i: number) => any;
|
||||
space: number,
|
||||
direction?: "ltr" | "rtl";
|
||||
loop?: boolean;
|
||||
autoplayDelay?: number;
|
||||
buttonIcons?: {
|
||||
prev: any;
|
||||
next: any;
|
||||
};
|
||||
slidesToShow: number[];
|
||||
slideSizes?: number[],
|
||||
slideType?: "percentage" | "size";
|
||||
sliderClass?: string;
|
||||
viewPortClass?: string;
|
||||
containerClass?: string;
|
||||
slideClass?: string;
|
||||
prevClass?: string;
|
||||
nextClass?: string;
|
||||
dotClass?: string;
|
||||
dragFree?: boolean;
|
||||
controlsContainerClass?: string;
|
||||
buttonsWrapperClass?: string;
|
||||
dotsWrapperClass?: string;
|
||||
buttons?: boolean;
|
||||
dots?: boolean;
|
||||
startIndex?: number;
|
||||
align?: "start" | "center" | "end";
|
||||
slidesToScroll?: number;
|
||||
}
|
||||
|
||||
const Slider: React.FC<SliderProps> = ({
|
||||
slides,
|
||||
autoplay = true,
|
||||
dragFree = true,
|
||||
startIndex = 1,
|
||||
slidesToScroll = 1,
|
||||
align = "start",
|
||||
space = 4,
|
||||
direction = "rtl",
|
||||
loop = false,
|
||||
autoplayDelay = 4000,
|
||||
slidesToShow = [4, 4, 4, 4, 4, 4],
|
||||
slideSizes = [200, 200, 200, 200, 200, 200],
|
||||
slideType = "percentage",
|
||||
sliderClass,
|
||||
viewPortClass,
|
||||
containerClass,
|
||||
slideClass,
|
||||
prevClass,
|
||||
nextClass,
|
||||
dotClass,
|
||||
controlsContainerClass,
|
||||
buttonsWrapperClass,
|
||||
dotsWrapperClass,
|
||||
buttonIcons = { prev: "", next: "" },
|
||||
buttons = true,
|
||||
dots = true,
|
||||
options = {
|
||||
duration: 20,
|
||||
dragFree: dragFree,
|
||||
containScroll: 'keepSnaps',
|
||||
startIndex: startIndex,
|
||||
align: align,
|
||||
slidesToScroll: slidesToScroll,
|
||||
direction: direction,
|
||||
loop: loop,
|
||||
},
|
||||
onClick,
|
||||
onScroll,
|
||||
currentIndex = 0,
|
||||
childComponent,
|
||||
const Slider: React.FC<SliderProps> = ({
|
||||
slides,
|
||||
autoplay = true,
|
||||
dragFree = true,
|
||||
startIndex = 1,
|
||||
slidesToScroll = 1,
|
||||
align = "start",
|
||||
space = 4,
|
||||
direction = "rtl",
|
||||
loop = false,
|
||||
autoplayDelay = 4000,
|
||||
slidesToShow = [4, 4, 4, 4, 4, 4],
|
||||
slideSizes = [200, 200, 200, 200, 200, 200],
|
||||
slideType = "percentage",
|
||||
sliderClass,
|
||||
viewPortClass,
|
||||
containerClass,
|
||||
slideClass,
|
||||
prevClass,
|
||||
nextClass,
|
||||
dotClass,
|
||||
controlsContainerClass,
|
||||
buttonsWrapperClass,
|
||||
dotsWrapperClass,
|
||||
buttonIcons = { prev: "", next: "" },
|
||||
buttons = true,
|
||||
dots = true,
|
||||
options = {
|
||||
duration: 20,
|
||||
dragFree: dragFree,
|
||||
containScroll: 'keepSnaps',
|
||||
startIndex: startIndex,
|
||||
align: align,
|
||||
slidesToScroll: slidesToScroll,
|
||||
direction: direction,
|
||||
loop: loop,
|
||||
},
|
||||
onClick,
|
||||
onScroll,
|
||||
currentIndex = 0,
|
||||
childComponent,
|
||||
}) => {
|
||||
|
||||
// states
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options as any, autoplay ? [Autoplay({ delay: autoplayDelay, stopOnInteraction: false }) as any] : []);
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
|
||||
const [selectedIndex, setSelectedIndex] = useState(currentIndex)
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
|
||||
// states
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel(options as any, autoplay ? [Autoplay({ delay: autoplayDelay, stopOnInteraction: false }) as any] : []);
|
||||
const [prevBtnDisabled, setPrevBtnDisabled] = useState(true);
|
||||
const [nextBtnDisabled, setNextBtnDisabled] = useState(true);
|
||||
const [selectedIndex, setSelectedIndex] = useState(currentIndex)
|
||||
const [scrollSnaps, setScrollSnaps] = useState<number[]>([])
|
||||
|
||||
// methods
|
||||
const scrollPrev = useCallback(
|
||||
() => emblaApi && emblaApi.scrollPrev(),
|
||||
[emblaApi]
|
||||
)
|
||||
const scrollNext = useCallback(
|
||||
() => emblaApi && emblaApi.scrollNext(),
|
||||
[emblaApi]
|
||||
)
|
||||
const scrollTo = useCallback(
|
||||
(index: number) => emblaApi && emblaApi.scrollTo(index),
|
||||
[emblaApi]
|
||||
)
|
||||
// methods
|
||||
const scrollPrev = useCallback(
|
||||
() => emblaApi && emblaApi.scrollPrev(),
|
||||
[emblaApi]
|
||||
)
|
||||
const scrollNext = useCallback(
|
||||
() => emblaApi && emblaApi.scrollNext(),
|
||||
[emblaApi]
|
||||
)
|
||||
const scrollTo = useCallback(
|
||||
(index: number) => emblaApi && emblaApi.scrollTo(index),
|
||||
[emblaApi]
|
||||
)
|
||||
|
||||
const onInit = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setScrollSnaps(emblaApi.scrollSnapList())
|
||||
}, [])
|
||||
const onInit = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setScrollSnaps(emblaApi.scrollSnapList())
|
||||
}, [])
|
||||
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext())
|
||||
}, [])
|
||||
const onSelect = useCallback((emblaApi: EmblaCarouselType) => {
|
||||
setSelectedIndex(emblaApi.selectedScrollSnap())
|
||||
setPrevBtnDisabled(!emblaApi.canScrollPrev())
|
||||
setNextBtnDisabled(!emblaApi.canScrollNext())
|
||||
}, [])
|
||||
|
||||
// effects
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
onInit(emblaApi as any)
|
||||
onSelect(emblaApi as any)
|
||||
emblaApi.on('reInit', onInit as any)
|
||||
emblaApi.on('reInit', onSelect as any)
|
||||
emblaApi.on('select', onSelect as any)
|
||||
}, [emblaApi, onInit, onSelect])
|
||||
|
||||
useEffect(() => {
|
||||
scrollTo(currentIndex);
|
||||
}, [currentIndex])
|
||||
// effects
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return
|
||||
onInit(emblaApi as any)
|
||||
onSelect(emblaApi as any)
|
||||
emblaApi.on('reInit', onInit as any)
|
||||
emblaApi.on('reInit', onSelect as any)
|
||||
emblaApi.on('select', onSelect as any)
|
||||
}, [emblaApi, onInit, onSelect])
|
||||
|
||||
useEffect(() => {
|
||||
onScroll && onScroll(selectedIndex);
|
||||
}, [selectedIndex])
|
||||
useEffect(() => {
|
||||
scrollTo(currentIndex);
|
||||
}, [currentIndex])
|
||||
|
||||
return <div style={{
|
||||
"--slide-spacing": `${space / 4}rem`,
|
||||
"--slides-number": `${100 / slidesToShow[0]}%`,
|
||||
"--slides-number-sm": `${100 / slidesToShow[1]}%`,
|
||||
"--slides-number-md": `${100 / slidesToShow[2]}%`,
|
||||
"--slides-number-lg": `${100 / slidesToShow[3]}%`,
|
||||
"--slides-number-xl": `${100 / slidesToShow[4]}%`,
|
||||
"--slides-number-2xl": `${100 / slidesToShow[5]}%`,
|
||||
"--slides-size-mobile": `${slideSizes[0]}px`,
|
||||
"--slides-size-sm": `${slideSizes[1]}px`,
|
||||
"--slides-size-md": `${slideSizes[2]}px`,
|
||||
"--slides-size-lg": `${slideSizes[3]}px`,
|
||||
"--slides-size-xl": `${slideSizes[4]}px`,
|
||||
"--slides-size-2xl": `${slideSizes[5]}px`,
|
||||
} as React.CSSProperties}
|
||||
className={`relative ${sliderClass}`}
|
||||
>
|
||||
<div className={`overflow-x-hidden p-2 ${viewPortClass}`} ref={emblaRef}>
|
||||
<div className={`[backface-visibility:hidden] flex touch-pan-y ml-[calc(var(--slide-spacing)*-1)] ${containerClass}`}>
|
||||
{slideType === "percentage" && slides.map((x: any, index: number) => (
|
||||
<div className={`relative min-w-0 pl-[var(--slide-spacing)]
|
||||
useEffect(() => {
|
||||
onScroll && onScroll(selectedIndex);
|
||||
}, [selectedIndex])
|
||||
|
||||
return <div style={{
|
||||
"--slide-spacing": `${space / 4}rem`,
|
||||
"--slides-number": `${100 / slidesToShow[0]}%`,
|
||||
"--slides-number-sm": `${100 / slidesToShow[1]}%`,
|
||||
"--slides-number-md": `${100 / slidesToShow[2]}%`,
|
||||
"--slides-number-lg": `${100 / slidesToShow[3]}%`,
|
||||
"--slides-number-xl": `${100 / slidesToShow[4]}%`,
|
||||
"--slides-number-2xl": `${100 / slidesToShow[5]}%`,
|
||||
"--slides-size-mobile": `${slideSizes[0]}px`,
|
||||
"--slides-size-sm": `${slideSizes[1]}px`,
|
||||
"--slides-size-md": `${slideSizes[2]}px`,
|
||||
"--slides-size-lg": `${slideSizes[3]}px`,
|
||||
"--slides-size-xl": `${slideSizes[4]}px`,
|
||||
"--slides-size-2xl": `${slideSizes[5]}px`,
|
||||
} as React.CSSProperties}
|
||||
className={`relative ${sliderClass}`}
|
||||
>
|
||||
<div className={`overflow-x-hidden p-2 ${viewPortClass}`} ref={emblaRef}>
|
||||
<div className={`[backface-visibility:hidden] flex touch-pan-y ml-[calc(var(--slide-spacing)*-1)] ${containerClass}`}>
|
||||
{slideType === "percentage" && slides.map((x: any, index: number) => (
|
||||
<div className={`relative min-w-0 pl-[var(--slide-spacing)]
|
||||
[flex:0_0_var(--slides-number)] sm:[flex:0_0_var(--slides-number-sm)]
|
||||
md:[flex:0_0_var(--slides-number-md)] lg:[flex:0_0_var(--slides-number-lg)]
|
||||
xl:[flex:0_0_var(--slides-number-xl)] 2xl:[flex:0_0_var(--slides-number-2xl)]
|
||||
${slideClass}`} key={index}>
|
||||
{childComponent(x, index)}
|
||||
</div>
|
||||
))}
|
||||
{slideType === "size" && slides.map((x: any, index: number) => (
|
||||
<div className={`relative min-w-0 pl-[var(--slide-spacing)]
|
||||
{childComponent(x, index)}
|
||||
</div>
|
||||
))}
|
||||
{slideType === "size" && slides.map((x: any, index: number) => (
|
||||
<div className={`relative min-w-0 ml-[var(--slide-spacing)]
|
||||
[flex:0_0_var(--slides-size-mobile)] sm:[flex:0_0_var(--slides-size-sm)]
|
||||
md:[flex:0_0_var(--slides-size-md)] lg:[flex:0_0_var(--slides-size-lg)]
|
||||
xl:[flex:0_0_var(--slides-size-xl)] 2xl:[flex:0_0_var(--slides-size-2xl)]
|
||||
${slideClass}`} key={index}>
|
||||
{childComponent(x, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-center mt-6 ${controlsContainerClass}`}>
|
||||
{buttons && <div className={`flex items-center justify-between w-full absolute px-4 py-2 ${buttonsWrapperClass}`}>
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 left-0 ${prevClass}`} onClick={scrollPrev}>
|
||||
{buttonIcons.prev ? buttonIcons.prev : <ChevronLeftSolid className={`reactive-button ${prevBtnDisabled ? "pointer-events-none bg-cta/40" : "bg-cta"} text-primary fill-current w-8 h-8 p-2 rounded-full shadow lg:cursor-pointer`}/>}
|
||||
</div>
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 right-0 ${nextClass}`} onClick={scrollNext}>
|
||||
{buttonIcons.next ? buttonIcons.next : <ChevronRightSolid className={`reactive-button ${nextBtnDisabled ? "pointer-events-none bg-cta/40" : "bg-cta"} text-primary fill-current w-8 h-8 p-2 rounded-full shadow lg:cursor-pointer`} />}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{dots && <div className={`flex items-center justify-center z-10 absolute space-x-2 rtl:space-x-reverse ${dotsWrapperClass}`}>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => scrollTo(index)}
|
||||
aria-label={`slide ${index + 1}`}
|
||||
className={`block h-4 w-4 rounded-full shadow-inner cursor-none lg:cursor-pointer hover:lg:bg-market-border ${index === selectedIndex ? "bg-market-title-light" : "bg-gray-100"}
|
||||
${'slider__dot'.concat(
|
||||
index === selectedIndex ? ' slider__dot--selected' : ''
|
||||
)} ${dotClass}`}
|
||||
/>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
{childComponent(x, index)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`flex items-center justify-center mt-6 ${controlsContainerClass}`}>
|
||||
{buttons && <div className={`flex items-center justify-between w-full absolute px-4 py-2 ${buttonsWrapperClass}`}>
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 left-0 ${prevClass}`} onClick={scrollPrev}>
|
||||
{buttonIcons.prev ? buttonIcons.prev : <ChevronLeftSolid className={`reactive-button ${prevBtnDisabled ? "pointer-events-none bg-cta/40" : "bg-cta"} text-primary fill-current w-8 h-8 p-2 rounded-full shadow lg:cursor-pointer`} />}
|
||||
</div>
|
||||
<div className={`absolute top-1/2 -translate-y-1/2 right-0 ${nextClass}`} onClick={scrollNext}>
|
||||
{buttonIcons.next ? buttonIcons.next : <ChevronRightSolid className={`reactive-button ${nextBtnDisabled ? "pointer-events-none bg-cta/40" : "bg-cta"} text-primary fill-current w-8 h-8 p-2 rounded-full shadow lg:cursor-pointer`} />}
|
||||
</div>
|
||||
</div>}
|
||||
|
||||
{dots && <div className={`flex items-center justify-center z-10 absolute space-x-2 rtl:space-x-reverse ${dotsWrapperClass}`}>
|
||||
{scrollSnaps.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => scrollTo(index)}
|
||||
aria-label={`slide ${index + 1}`}
|
||||
className={`block h-4 w-4 rounded-full shadow-inner cursor-none lg:cursor-pointer hover:lg:bg-market-border ${index === selectedIndex ? "!bg-market-title-light" : "bg-gray-100"}
|
||||
${'slider__dot'.concat(
|
||||
index === selectedIndex ? ' slider__dot--selected' : ''
|
||||
)} ${dotClass}`}
|
||||
/>
|
||||
))}
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
export default Slider
|
||||
@ -39,10 +39,12 @@ export const billboardLayoutDataQuery = (billboardId ) => `
|
||||
height
|
||||
type
|
||||
}
|
||||
vitrine
|
||||
brand_color
|
||||
dashboard_theme
|
||||
dashboard_color
|
||||
product_types
|
||||
default_activity
|
||||
city {
|
||||
name
|
||||
English_name
|
||||
|
||||
@ -7,6 +7,11 @@ import { getInitialCountry } from 'lib/general/general';
|
||||
export interface GlobalState {
|
||||
dictionary: Dictionary[],
|
||||
menu: {
|
||||
billboard: {
|
||||
name: string;
|
||||
id: string;
|
||||
has_children: boolean;
|
||||
}[];
|
||||
market: {
|
||||
name: string;
|
||||
id: string;
|
||||
@ -17,11 +22,6 @@ export interface GlobalState {
|
||||
}
|
||||
}
|
||||
}[];
|
||||
billboard: {
|
||||
name: string;
|
||||
id: string;
|
||||
has_children: boolean;
|
||||
}[];
|
||||
magazine: {
|
||||
name: string;
|
||||
id: string;
|
||||
@ -43,8 +43,8 @@ export interface GlobalState {
|
||||
const initialState = {
|
||||
dictionary: [],
|
||||
menu: {
|
||||
market: [],
|
||||
billboard: [],
|
||||
market: [],
|
||||
magazine: []
|
||||
},
|
||||
measures: "imperial",
|
||||
|
||||
@ -8,8 +8,8 @@ import { fetchPublicData } from './general';
|
||||
export const useGetGlobalData = () => {
|
||||
const menuData = useAppSelector(getGlobalMenu);
|
||||
const data = {
|
||||
market: menuData.market,
|
||||
billboard: menuData.billboard,
|
||||
market: menuData.market,
|
||||
magazine: menuData.magazine,
|
||||
}
|
||||
return data;
|
||||
|
||||
@ -245,6 +245,10 @@ export const htm = (hours) => {
|
||||
return milliseconds;
|
||||
}
|
||||
|
||||
export const nt = (time) => { // normalizedTime => 23:00:00 => 23:00
|
||||
return time.slice(0, time.lastIndexOf(":"));
|
||||
}
|
||||
|
||||
// get smart time
|
||||
export const smartTimeStamp = (dateTime) => {
|
||||
let smartDate = {
|
||||
@ -521,6 +525,9 @@ export const getFolderID = (name) => {
|
||||
case "BillboardNews":
|
||||
folderId = "9d65ff25-f1fe-4680-a34a-77bff92dc8e3";
|
||||
break;
|
||||
case "BillboardServiceType":
|
||||
folderId = "fa415edb-8870-4176-8e01-437f16c3b4b1";
|
||||
break;
|
||||
case "BillboardProducts":
|
||||
folderId = "7c4c9a3e-7843-484a-ab9b-1c0be97289f8";
|
||||
break;
|
||||
|
||||
@ -75,7 +75,7 @@ const LatestBillboards: React.FunctionComponent<LatestBillboards> = ({ billboard
|
||||
<div className="flex flex-col gap-1 mt-1">
|
||||
{/* rating */}
|
||||
<div className="flex items-center mt-1 mb-1 sm:mt-2 sm:mb-1 space-x-1 rtl:space-x-reverse">
|
||||
<StarRating rating={getRating(item).avgRating} className="text-logo-orange -mt-[2px] space-x-[2px]" starClass="size-3 sm:size-[14px] shrink-0" />
|
||||
<StarRating rating={getRating(item).avgRating} className="text-logo-orange -mt-[2px] gap-[2px]" starClass="size-3 sm:size-[14px] shrink-0" />
|
||||
<span className="text-[0.675rem]/4 sm:text-xs font-semibold text-secondary-light">{`(${getRating(item).count} ${translate("vote")})`}</span>
|
||||
</div>
|
||||
<span className="w-full text-[0.675rem]/6 text-gray-500 mb-1 line-clamp-2">{stripHtml(getLocaleTr(item, locale).body)}</span>
|
||||
|
||||
@ -3,10 +3,12 @@ import Image from "components/image/image"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, getWeekDay, hasValue, serverAddress, timeUntilOpening, url, useGetRouter } from 'services/general/general'
|
||||
import StarRating from "components/rating/star-rating"
|
||||
import { BadgeCheckSolid, CircleSolid, ClockSolid, ImageSharpSolid, LocationPinSolid, StoreSlashRegular } from "components/icons"
|
||||
import { BadgeCheckSolid, BoxArchiveSolid, CircleSolid, ClockSolid, ImageSharpSolid, LocationPinSolid, PenSolid, PhoneSolid, StoreSlashRegular } from "components/icons"
|
||||
import Slider from "components/slider/slider"
|
||||
import { Billboard } from "common/types/billboard"
|
||||
import { getHours } from "services/billboard/general"
|
||||
import Link from "components/link/link"
|
||||
import Button from "components/button/button"
|
||||
|
||||
interface BillboardHeaderProps {
|
||||
billboard: Billboard;
|
||||
@ -81,11 +83,11 @@ const Album: React.FunctionComponent<any> = ({ pics, billboard, openGallery }) =
|
||||
align="center"
|
||||
loop={true}
|
||||
direction={locale === "en" ? "ltr" : "rtl"}
|
||||
autoplay={true}
|
||||
autoplay={false}
|
||||
autoplayDelay={6000}
|
||||
childComponent={(x: any, i: number) => <GalleryItem key={x.id} item={x} index={i} openGallery={openGallery} title={itemTitle} />}
|
||||
sliderClass={`hidden lg:block sm:bg-[#fcfcfd] max-sm:w-[calc(100vw_-_2rem)] w-full [rtl:direction:rtl] mx-auto bg-white`}
|
||||
viewPortClass="!p-0"
|
||||
viewPortClass="!p-0 bg-white"
|
||||
containerClass="pt-[5px]"
|
||||
controlsContainerClass="!mt-0"
|
||||
buttonsWrapperClass=""
|
||||
@ -115,18 +117,22 @@ const BillboardHeader: React.FunctionComponent<BillboardHeaderProps> = ({ billbo
|
||||
return { totalRating: totalRating, count: ratings[0].countDistinct.id, serviceQuality: serviceQuality};
|
||||
}, [ratings]);
|
||||
|
||||
const handleCall = () => {
|
||||
|
||||
};
|
||||
|
||||
return (
|
||||
<header
|
||||
style={{
|
||||
"--image-bg": hasValue(billboard.cover_photo) ? `url("${serverAddress()}${billboard.cover_photo?.id}/${billboard.cover_photo?.filename_download}")` : `url("${serverAddress()}${billboard.gallery[0].directus_files_id?.id}/${billboard.gallery[0].directus_files_id?.filename_download}")`,
|
||||
"--brand-color": billboard.brand_color ?? "#516ec2",
|
||||
} as React.CSSProperties}
|
||||
className="block w-full bg-white mb-0 sm:mb-0"
|
||||
className="block w-full bg-gray-50 mb-0 sm:mb-0"
|
||||
>
|
||||
{/* cover photo */}
|
||||
{!billboard.cover_photo && billboard.gallery.length > 2 && <Album pics={billboard.gallery.slice(0, 6)} billboard={billboard} openGallery={openGallery} />}
|
||||
{/* {!billboard.cover_photo && billboard.gallery.length > 2 && <Album pics={billboard.gallery.slice(0, 6)} billboard={billboard} openGallery={openGallery} />} */}
|
||||
<div
|
||||
className={`block ${!billboard.cover_photo && billboard.gallery.length > 2 ? "lg:hidden" : ""} w-full lg:mt-[5px] h-40 sm:h-80 lg:h-[422px]
|
||||
className={`block ${!billboard.cover_photo && billboard.gallery.length > 2 ? "lg:hidden" : ""} w-full lg:mt-[5px] h-52 sm:h-80 lg:h-[422px]
|
||||
${(billboard.cover_photo) || billboard.gallery.length > 0 ?
|
||||
"[background-image:var(--image-bg)] [background-size:cover] [background-position:50%_50%] md:[background-position:50%_55%]"
|
||||
:
|
||||
@ -135,11 +141,11 @@ const BillboardHeader: React.FunctionComponent<BillboardHeaderProps> = ({ billbo
|
||||
>
|
||||
</div>
|
||||
|
||||
<div className={`block w-full border-b max-sm:px-0 max-2xl:px-gi border-t-4 sm:border-t-[5px] ${billboard.brand_color ? "border-t-[var(--brand-color)]" : "border-t-white sm:border-t-[#e5e7eb]"} `}>
|
||||
<div className={`block max-lg:w-[calc(100%_-2rem)] w-full mx-auto bg-white max-sm:px-0 max-2xl:px-gi mb-1 max-lg:border-4 border-gray-50/50 max-lg:-mt-9 max-lg:rounded-[2.5rem] shadow lg:shadow-sm`}>
|
||||
<div className="flex w-full justify-between 2xl:container mx-auto sm:py-1 xl:py-4">
|
||||
<div className="flex flex-col max-sm:w-full sm:flex-row items-center lg:space-x-4 lg:rtl:space-x-reverse 2xl:px-gi">
|
||||
<div className="flex flex-col max-lg:w-full lg:flex-row items-center lg:gap-x-4 2xl:px-gi">
|
||||
{/* logo */}
|
||||
<div className={`block relative size-24 sm:size-32 lg:size-44 bg-white rounded-full ring-4 ring-white mx-auto -mt-12 sm:mt-0 shrink-0 overflow-hidden ${billboard.temporarily_closed ? "grayscale" : ""}`}>
|
||||
<div className={`block relative size-24 sm:size-32 lg:size-44 bg-white rounded-full mx-auto -mt-12 lg:mt-0 shrink-0 overflow-hidden border-4 border-white shadow-sm ${billboard.temporarily_closed ? "grayscale" : ""}`}>
|
||||
{hasValue(billboard.logo) ?
|
||||
<Image
|
||||
src={`${billboard.logo.id}/${billboard.logo.filename_download}`}
|
||||
@ -162,16 +168,16 @@ const BillboardHeader: React.FunctionComponent<BillboardHeaderProps> = ({ billbo
|
||||
}
|
||||
</div>
|
||||
{/* highlight */}
|
||||
<div className="block relative w-full p-4 max-sm:pt-3 mt-0 lg:mt-0">
|
||||
<div className="block relative w-full p-4 max-lg:pt-2 max-lg:pb-3">
|
||||
{/* times recommneded */}
|
||||
{timesRecommended > 0 && <div className="flex items-center w-max py-1 px-2 mb-3 lg:mb-5 rounded-lg bg-[var(--light-brand-color)] text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] space-x-2 sm:space-x-2 rtl:space-x-reverse">
|
||||
<BadgeCheckSolid className="inline-block size-[14px] sm:size-4 fill-current shrink-0" />
|
||||
<span className="flex items-center shrink-0 text-current text-xs sm:text-sm font-extrabold capitalize"><span className="text-[13px]/6 sm:text-sm text-current rtl:ml-1 ltr:mr-1 rtl:-mb-1">{timesRecommended}</span>{`${translate("billboard-times-recommended")}`}</span>
|
||||
</div>}
|
||||
{/* title */}
|
||||
<h1 className="block w-full text-xl/9 xl:text-3xl font-extrabold text-title-color mb-3 sm:mb-4 xl:mb-5 capitalize">{billboardTitle}</h1>
|
||||
<h1 className="block w-full text-lg/8 max-lg:mt-2 xl:text-2xl font-extrabold max-lg:text-center text-secondary-light mb-3 sm:mb-4 xl:mb-5 capitalize">{billboardTitle}</h1>
|
||||
{/* slogan */}
|
||||
<p className="block w-full text-sm xl:text-base sm:font-semibold">
|
||||
<p className="block w-full text-xs xl:text-sm max-lg:text-center">
|
||||
{getLocaleTr(billboard, locale).slogan ?
|
||||
(getLocaleTr(billboard, locale).slogan.length > 50 ? getLocaleTr(billboard, locale).slogan.slice(0, 50) + "..." : getLocaleTr(billboard, locale).slogan)
|
||||
:
|
||||
@ -179,55 +185,72 @@ const BillboardHeader: React.FunctionComponent<BillboardHeaderProps> = ({ billbo
|
||||
}
|
||||
</p>
|
||||
{/* rating */}
|
||||
<div className="flex items-center mt-4 sm:mt-4 space-x-2 rtl:space-x-reverse">
|
||||
<StarRating rating={getRating.totalRating} className="text-primary -mt-[2px]" starClass="size-4 shrink-0" />
|
||||
<span className="text-base font-semibold text-secondary-light">{String(getRating.totalRating).slice(0, 3)}/5</span>
|
||||
<span className="text-sm font-semibold text-secondary-light">{`(${getRating.count} ${translate("vote")})` + " |"}</span>
|
||||
<a href="#reviews" className="max-lg:hidden text-sm text-market-title-light">{`${reviews} ${reviews > 1 ? (locale === "en" ? translate("reviews") : translate("review")) : translate("review")}`}</a>
|
||||
<a href="#reviews-mb" className="lg:hidden text-sm text-market-title-light">{`${reviews} ${reviews > 1 ? (locale === "en" ? translate("reviews") : translate("review")) : translate("review")}`}</a>
|
||||
<div className="flex flex-col max-lg:items-center items-start max-lg:justify-center mt-4 sm:mt-4 gap-2">
|
||||
<StarRating rating={getRating.totalRating} className="text-[var(--brand-color)]" starClass="size-4 shrink-0" />
|
||||
<div className="flex items-center max-lg:justify-center gap-2 pt-1">
|
||||
<span className="text-sm lg:text-base font-semibold text-secondary-light">{getRating.totalRating === 0 ? 0 : String(getRating.totalRating.toFixed(1))}</span>
|
||||
<span className="text-sm font-semibold text-secondary-light">{`(${getRating.count} ${translate("vote")})`}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/* location */}
|
||||
<div className="flex items-center mt-2 sm:mt-3 space-x-2 rtl:space-x-reverse">
|
||||
<LocationPinSolid className="inline-block size-6 fill-current text-market-title-light shrink-0 bg-market-input p-1 rounded-lg" />
|
||||
<span className="text-sm">{`${locale === "en" ? billboard.city.English_name : billboard.city.name}, ${locale === "en" ? billboard.city.state.English_name : billboard.city.state.name}`}</span>
|
||||
<div className="flex items-center max-lg:justify-center mt-3 sm:mt-3 gap-2">
|
||||
<LocationPinSolid className="inline-block size-5 lg:size-6 fill-current text-secondary-light shrink-0 bg-market-input p-1 rounded-lg" />
|
||||
<span className="text-xs lg:text-sm">{`${locale === "en" ? billboard.city.English_name : billboard.city.name}, ${locale === "en" ? billboard.city.state.English_name : billboard.city.state.name}`}</span>
|
||||
</div>
|
||||
{/* working hours */}
|
||||
<div className="flex items-center mt-4 space-x-2 rtl:space-x-reverse">
|
||||
<span className={`flex items-center text-base font-semibold space-x-2 rtl:space-x-reverse ${isOpen ? "bg-success-bg text-success-text" : "bg-error-bg text-error-text"} py-[1px] px-2 rounded-lg`}>
|
||||
<CircleSolid className={`inline-block size-3 fill-current ${isOpen ? "text-success-text" : "text-error-text"} shrink-0`} />
|
||||
<div className="flex items-center max-lg:justify-center mt-4 gap-2">
|
||||
<span className={`flex items-center text-sm lg:text-base font-semibold gap-2 ${isOpen ? "bg-success-bg text-success-text" : "bg-error-bg text-error-text"} py-[1px] px-2 rounded-lg`}>
|
||||
<CircleSolid className={`inline-block size-[10px] lg:size-3 fill-current ${isOpen ? "text-success-text" : "text-error-text"} shrink-0`} />
|
||||
<span className="shrink-0 text-current capitalize">{isOpen ? translate("open") : translate("closed")}</span>
|
||||
</span>
|
||||
{!isOpen && !billboard.temporarily_closed &&
|
||||
<span className="shrink-0 text-current text-xs bg-warning-bg text-warning-text rounded-lg py-1 px-2">
|
||||
{hours.daysTillNextOpenDay > 1 ?
|
||||
locale === "en" ?
|
||||
`${translate("billbopard-opens-in")} ${translate(getWeekDay(hours.nextOpenDay).toLowerCase())}`
|
||||
`${translate("billbopard-opens-in")} ${translate(getWeekDay(hours.nextOpenDay).toLowerCase())}`
|
||||
:
|
||||
`${translate(getWeekDay(hours.nextOpenDay).toLowerCase())} ${translate("billbopard-opens-in")}`
|
||||
:
|
||||
`${translate(getWeekDay(hours.nextOpenDay).toLowerCase())} ${translate("billbopard-opens-in")}`
|
||||
:
|
||||
locale === "en" ?
|
||||
`${translate("billboard-remaining-till-open")} ${remainingTime} ${timeToOpen.hours === 0 ? translate("minute") : translate("hour")}s`
|
||||
:
|
||||
`${remainingTime} ${timeToOpen.hours === 0 ? translate("minute") : translate("hour")} ${translate("billboard-remaining-till-open")}`
|
||||
locale === "en" ?
|
||||
`${translate("billboard-remaining-till-open")} ${remainingTime} ${timeToOpen.hours === 0 ? translate("minute") : translate("hour")}s`
|
||||
:
|
||||
`${remainingTime} ${timeToOpen.hours === 0 ? translate("minute") : translate("hour")} ${translate("billboard-remaining-till-open")}`
|
||||
}
|
||||
</span>
|
||||
}
|
||||
<span className="text-base">{"|"}</span>
|
||||
<span className="text-sm [direction:ltr] text-gray-500 font-semibold">{hours.hours[hours.today].from && `${hours.hours[hours.today].from.slice(0, 5)} - ${hours.hours[hours.today].to.slice(0, 5) }`}</span>
|
||||
<a href="#working-hours" className="hidden lg:flex items-center space-x-2 rtl:space-x-reverse py-1 px-2 rounded-lg bg-market-input text-market-title-light">
|
||||
<span className="text-xs lg:text-sm [direction:ltr] text-gray-500 font-semibold">{hours.hours[hours.today].from && `${hours.hours[hours.today].from.slice(0, 5)} - ${hours.hours[hours.today].to.slice(0, 5) }`}</span>
|
||||
<a href="#working-hours" className="hidden lg:flex items-center space-x-2 rtl:space-x-reverse py-1 px-2 rounded-lg bg-[var(--light-brand-color)] text-[var(--brand-color)]">
|
||||
<ClockSolid className={`inline-block ${hours.isOpen ? "size-3" : "size-4 sm:size-3"} fill-current shrink-0`} />
|
||||
<span className={`${hours.isOpen ? "inline-block" : "hidden sm:inline-block"} text-xs text-current`}>{translate("billboard-see-working-hours")}</span>
|
||||
</a>
|
||||
<a href="#work-hours" className="flex lg:hidden items-center space-x-2 rtl:space-x-reverse py-1 px-2 rounded-lg bg-market-input text-market-title-light">
|
||||
{/* <a href="#work-hours" className="flex lg:hidden items-center space-x-2 rtl:space-x-reverse py-1 px-1 rounded-lg bg-[var(--light-brand-color)] text-[var(--brand-color)]">
|
||||
<ClockSolid className={`inline-block ${hours.isOpen ? "size-3" : "size-4 sm:size-3"} fill-current shrink-0`} />
|
||||
<span className={`${hours.isOpen ? "inline-block" : "hidden sm:inline-block"} text-xs text-current`}>{translate("billboard-see-working-hours")}</span>
|
||||
</a>
|
||||
</a> */}
|
||||
</div>
|
||||
{/* temporarily closed */}
|
||||
{billboard.temporarily_closed && <div className="flex items-center w-max bg-warning-bg text-warning-text py-2 px-gi rounded-lg mt-8 space-x-4 rtl:space-x-reverse">
|
||||
<StoreSlashRegular className={`inline-block size-8 fill-current shrink-0`} />
|
||||
<span className="shrink-0 text-current font-semibold text-base">{translate("billboard-temporarily-closed")}</span>
|
||||
</div>}
|
||||
|
||||
{/* CTAs */}
|
||||
{/* <div className="grid grid-cols-2 md:grid-cols-1 gap-4 relative w-full py-3 mt-3 md:pb-0 md:w-64 xl:w-80 shrink-0">
|
||||
<Link
|
||||
href={`/dashboard/billboards/edit`}
|
||||
className="reactive-button rounded-xl bg-[var(--brand-color)] text-white text-center text-sm py-2 px-2 shadow-none capitalize"
|
||||
>
|
||||
<PenSolid className="inline-block size-4 fill-current rtl:ml-3 ltr:mr-3" />
|
||||
{translate("edit")}
|
||||
</Link>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("call")}
|
||||
className="rounded-xl bg-white ring-1 ring-[var(--brand-color)] text-[var(--brand-color)] text-sm py-2 px-2 shadow-none capitalize"
|
||||
leftIcon={<PhoneSolid className="inline-block size-4 fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={handleCall}
|
||||
/>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
35
src/common/templates/billboard/page/about.tsx
Normal file
35
src/common/templates/billboard/page/about.tsx
Normal file
@ -0,0 +1,35 @@
|
||||
import React from "react"
|
||||
import Link from "components/link/link"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, stripHtml, useGetRouter } from 'services/general/general'
|
||||
import { PlusSolid } from "components/icons"
|
||||
import { Billboard } from "common/types/billboard"
|
||||
import Accordion from "components/accordion/accordion"
|
||||
import Parser from "components/parser/parser"
|
||||
import BillboardGallery from "./gallery"
|
||||
|
||||
interface BillboardAboutProps {
|
||||
billboard: Billboard;
|
||||
}
|
||||
|
||||
|
||||
const BillboardAbout: React.FunctionComponent<BillboardAboutProps> = ({ billboard }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
const billboardDescription = getLocaleTr(billboard, locale).body;
|
||||
const descriptionLength = stripHtml(billboardDescription);
|
||||
|
||||
// methods
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center w-full px-4 pt-4 pb-2 bg-white rounded-lg">
|
||||
<BillboardGallery billboard={billboard} />
|
||||
<span className="text-xl/9 font-extrabold text-center text-secondary-light pt-2 pb-5">{getLocaleTr(billboard, locale).title}</span>
|
||||
<Parser text={billboardDescription} className="[&_*]:!text-xs/6 !text-center !min-h-0" limit={descriptionLength.length > 150} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default BillboardAbout
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useLayoutEffect, useMemo, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { addVisitedPage, getLocaleTr, getVisitedPages, hasValue, oneDay, url, useGetRouter } from 'services/general/general'
|
||||
import { BasketShoppingSolid, BellSolid, ClockSolid, PhoneSolid, RssSolid, SignsPostSolid } from "components/icons"
|
||||
import { BasketShoppingSolid, BellLight, BellSolid, CartLight, CartSolid, CircleExclamationSolid, CircleQuestionSolid, ClockSolid, PhoneSolid, RssSolid, SignsPostSolid, StoreLight, StoreSolid, TicketLight, TicketSolid } from "components/icons"
|
||||
import { Billboard, BillboardReview } from "common/types/billboard"
|
||||
import TopNav from "./top-nav"
|
||||
import Gallery from "components/gallery/gallery"
|
||||
@ -17,19 +17,19 @@ import { getGlobalMenu } from "common/redux/slices/global"
|
||||
import { userData } from "common/redux/slices/user"
|
||||
import Link from "components/link/link"
|
||||
import { getBillboardCatData } from "services/billboard/general"
|
||||
import { restBulkUpdateRequest } from "services/queries/directus/billboard"
|
||||
import { safeJson } from "lib/general/safe-response-json"
|
||||
import ThemeColor from "common/templates/dashboard/billboards/billboard-page/theme-color"
|
||||
import BillboardAbout from "./about"
|
||||
|
||||
interface BillboardLayoutProps {
|
||||
billboard: Billboard;
|
||||
ratings: BillboardReview[];
|
||||
aggregatedRatings: any;
|
||||
themeStyles: React.CSSProperties;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
const BillboardLayout: React.FunctionComponent<BillboardLayoutProps> = ({ billboard, ratings, aggregatedRatings, children }) => {
|
||||
const BillboardLayout: React.FunctionComponent<BillboardLayoutProps> = ({ billboard, ratings, aggregatedRatings, themeStyles, children }) => {
|
||||
|
||||
// states
|
||||
const [showGallery, setShowGallery] = useState(false);
|
||||
@ -43,25 +43,48 @@ const BillboardLayout: React.FunctionComponent<BillboardLayoutProps> = ({ billbo
|
||||
const billboardCatData: any = useAppSelector(getGlobalMenu).billboard;
|
||||
const category = getLocaleTr(billboard.billboard_categories[0].billboard_categories_id, locale).name;
|
||||
const catId = billboard.billboard_categories[0].billboard_categories_id.id;
|
||||
const sectionIconClass = "inline-block size-4 fill-current text-[var(--brand-color)] rtl:ml-2 ltr:mr-2";
|
||||
const tabsIconClass = "inline-block size-4 sm:size-5 fill-current rtl:ml-2 ltr:mr-2 -mt-[2px]";
|
||||
const sectionIconClass = "inline-block size-4 fill-current text-[var(--brand-color)] me-3";
|
||||
const tabsIconClass = "inline-block size-4 sm:size-5 fill-current me-2 lg:me-3 -mt-[2px]";
|
||||
const catIconClass = "inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 !size-12 sm:!size-16 xl:!size-24 !fill-gray-200";
|
||||
const user = useAppSelector(userData);
|
||||
const currecntCatIcon = getBillboardCatData({ catId: Number(catId), iconClass: catIconClass });
|
||||
const tabs = [
|
||||
{ id: 1, idTag: "info", title: translate('billboard-tabs-info'), icon: <SignsPostSolid className={tabsIconClass} />, url: `/billboard/${billboard.id}/${url(billboardTitle)}` },
|
||||
{ id: 2, idTag: "products", title: translate('billboard-tabs-products'), icon: <BasketShoppingSolid className={tabsIconClass} />, url: `/billboard/${billboard.id}/${url(billboardTitle)}/products` },
|
||||
{ id: 3, idTag: "updates", title: translate('billboard-tabs-updates'), icon: <BellSolid className={tabsIconClass} />, url: `/billboard/${billboard.id}/${url(billboardTitle)}/updates` },
|
||||
];
|
||||
|
||||
const theme_color = ThemeColor({ brandColor: billboard?.brand_color, dashboardTheme: billboard?.dashboard_theme, dashboardColor: billboard?.dashboard_color });
|
||||
const productsTabLabel = () => {
|
||||
let label = "";
|
||||
if (billboard.product_types.length > 1) {
|
||||
label = `${translate("billboard-tabs-products")} / ${translate("services")}`;
|
||||
} else {
|
||||
if (billboard.product_types[0] === "reservation") label = translate("services");
|
||||
if (billboard.product_types[0] === "shop") label = translate("billboard-tabs-products");
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
const themeStyles = {
|
||||
"--brand-color": theme_color.full,
|
||||
"--medium-brand-color": theme_color.medium,
|
||||
"--light-brand-color": theme_color.light,
|
||||
"--super-light-brand-color": theme_color.superLight,
|
||||
} as React.CSSProperties;
|
||||
const tabs = [
|
||||
{
|
||||
id: 1,
|
||||
idTag: "home",
|
||||
title: translate('billboard-tabs-home'),
|
||||
icon: <StoreSolid className={tabsIconClass} />,
|
||||
emptyIcon: <StoreLight className={tabsIconClass} />,
|
||||
url: `/billboard/${billboard.id}/${url(billboardTitle)}`
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
idTag: "products",
|
||||
title: productsTabLabel(),
|
||||
icon: (billboard.product_types[0] === "reservation") ? <TicketSolid className = { tabsIconClass } /> : <CartSolid className={tabsIconClass} />,
|
||||
emptyIcon: (billboard.product_types[0] === "reservation") ? <TicketLight className={tabsIconClass} /> : <CartLight className={tabsIconClass} />,
|
||||
url: `/billboard/${billboard.id}/${url(billboardTitle)}/products`
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
idTag: "updates",
|
||||
title: translate('billboard-tabs-updates'),
|
||||
icon: <BellSolid className={tabsIconClass} />,
|
||||
emptyIcon: <BellLight className={tabsIconClass} />,
|
||||
url: `/billboard/${billboard.id}/${url(billboardTitle)}/updates`
|
||||
},
|
||||
];
|
||||
|
||||
const userViews = () => {
|
||||
let localUserViews: string[] = hasValue(billboard.visits) ? billboard.visits.map((x: number) => String(x)) : [];
|
||||
@ -138,7 +161,8 @@ const BillboardLayout: React.FunctionComponent<BillboardLayoutProps> = ({ billbo
|
||||
<TopNav
|
||||
title={billboardTitle}
|
||||
logo={billboard.logo}
|
||||
rating={getRating.totalRating}
|
||||
totalRating={getRating.totalRating}
|
||||
ratingVotesCount={getRating.count}
|
||||
/>
|
||||
<Gallery
|
||||
open={showGallery}
|
||||
@ -158,46 +182,56 @@ const BillboardLayout: React.FunctionComponent<BillboardLayoutProps> = ({ billbo
|
||||
category={category}
|
||||
/>
|
||||
<div className="grid grid-cols-12 w-full 2xl:container lg:px-gi mx-auto lg:mt-8">
|
||||
<div className="col-span-12 lg:col-span-8 rounded-lg lg:row-span-2">
|
||||
<div className="col-span-12 xl:col-span-8 rounded-lg lg:row-span-2">
|
||||
{/* breadcrumb */}
|
||||
<Breadcrumb
|
||||
className="flex items-center bg-white lg:rounded-t-lg border-b border-gray-200 my-1 w-full px-gi py-1 lg:py-3 lg:mt-0 xl:justify-start"
|
||||
className="flex items-center justify-center bg-white max-lg:shadow rounded-t-[2.5rem] lg:rounded-t-lg border-t border-gray-100/75 w-full mt-2 px-gi py-2 lg:py-3 lg:mt-0 xl:justify-start"
|
||||
textClass="lg:text-sm lg:px-1 hover:!text-[var(--brand-color)]"
|
||||
arrowClass="!text-[var(--brand-color)]"
|
||||
data={billboardCatData}
|
||||
urlParam={category}
|
||||
/>
|
||||
<div className="block w-full border-b mb-1 bg-white rounded-b-lg">
|
||||
|
||||
{/* tabs */}
|
||||
<div className="block w-full lg:border-b border-t border-t-gray-100/75 mb-1 bg-white lg:rounded-b-lg max-sm:sticky top-[62px] z-30 max-lg:shadow-bot">
|
||||
<div className="grid grid-cols-3 px-4">
|
||||
{tabs.map(x => (
|
||||
<Link
|
||||
key={x.id}
|
||||
id={x.idTag}
|
||||
href={x.url}
|
||||
className={`text-center py-3 md:py-4 px-2 bg-white uppercase text-sm sm:text-base font-semibold border-b-[3px] ${currentTab === x.id ? "!text-[var(--brand-color)] [border-color:var(--brand-color)]" : "border-white text-secondary-light"} select-none`}
|
||||
className={`text-center py-3 md:py-4 px-2 bg-white uppercase text-sm sm:text-base font-semibold border-b-[3px] ${currentTab === x.id ? "!text-[var(--brand-color)] border-[var(--brand-color)]" : "border-white text-secondary-light"} select-none`}
|
||||
shallow
|
||||
scroll={false}
|
||||
>
|
||||
{x.icon}
|
||||
{currentTab === x.id ? x.icon : x.emptyIcon}
|
||||
{x.title}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* children */}
|
||||
{children}
|
||||
</div>
|
||||
<div className="col-span-12 lg:col-span-4 hidden lg:block p-gi pt-0 rounded-lg">
|
||||
<BillboardSection id="contact" title={translate("billboard-contact")} color={billboard.brand_color} icon={<PhoneSolid className={sectionIconClass} />} className="">
|
||||
<BillboardContact billboard={billboard} />
|
||||
|
||||
{/* side column */}
|
||||
<div className="col-span-12 xl:col-span-4 hidden lg:block p-gi pt-0 rounded-lg">
|
||||
<BillboardSection id="about" title={translate("billboard-page-description")} color={billboard.brand_color} icon={<CircleQuestionSolid className={sectionIconClass} />} className="">
|
||||
<BillboardAbout billboard={billboard} />
|
||||
</BillboardSection>
|
||||
<BillboardSection id="subscription" title={translate("billboard-susbscribe-to-busniess")} color={billboard.brand_color} icon={<RssSolid className={sectionIconClass} />} className="mt-4">
|
||||
<BillboardSubscription billboard={billboard} user={user} />
|
||||
</BillboardSection>
|
||||
<BillboardSection id="contact" title={translate("billboard-contact")} color={billboard.brand_color} icon={<PhoneSolid className={sectionIconClass} />} className="mt-4">
|
||||
<BillboardContact billboard={billboard} />
|
||||
</BillboardSection>
|
||||
<BillboardSection id="working-hours" title={translate("billboard-open-hours")} color={billboard.brand_color} icon={<ClockSolid className={sectionIconClass} />} className="mt-4">
|
||||
<BillboardWorkingHours billboard={billboard} />
|
||||
</BillboardSection>
|
||||
</div>
|
||||
</div>
|
||||
<BillboardMenu billboardId={billboard.id} billboardTitle={billboardTitle} onChange={setCurrentTab} billboardPic={billboard.logo} currecntCatIcon={currecntCatIcon.icon} className="lg:hidden" />
|
||||
{/* <BillboardMenu billboardId={billboard.id} billboardTitle={billboardTitle} productsLabel={productsTabLabel()} productTypes={billboard.product_types} onChange={setCurrentTab} billboardPic={billboard.logo} currecntCatIcon={currecntCatIcon.icon} className="lg:hidden" /> */}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
@ -19,6 +19,7 @@ const BillboardGallery: React.FunctionComponent<BillboardGalleryProps> = ({ bill
|
||||
// variables
|
||||
const { locale } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
const picsCount = 4;
|
||||
|
||||
// methods
|
||||
const openGallery = (index: number) => {
|
||||
@ -35,7 +36,7 @@ const BillboardGallery: React.FunctionComponent<BillboardGalleryProps> = ({ bill
|
||||
style={{
|
||||
"--brand-color": billboard.brand_color ?? "#516ec2",
|
||||
} as React.CSSProperties}
|
||||
className="grid grid-cols-3 md:grid-cols-4 gap-1 w-full md:w-[600px]"
|
||||
className="flex items-center justify-center w-full pb-6"
|
||||
>
|
||||
<Gallery
|
||||
open={showGallery}
|
||||
@ -45,8 +46,8 @@ const BillboardGallery: React.FunctionComponent<BillboardGalleryProps> = ({ bill
|
||||
onClose={(i) => closeGallery(i)}
|
||||
id="billboard-gallery"
|
||||
/>
|
||||
{billboard.gallery.slice(0, 6).map((x, i) => (
|
||||
<div key={i} className={`reactive-button block relative size-full rounded-lg lg:rounded-2xl mx-auto shrink-0 overflow-hidden`}>
|
||||
{billboard.gallery.slice(0, picsCount).map((x, i) => (
|
||||
<div key={i} className={`reactive-button block relative ${i % 2 === 0 ? "rotate-3" : "-rotate-3"} size-20 sm:size-24 rounded-lg lg:rounded-2xl shrink-0 ring-[6px] ring-white overflow-hidden lg:hover:scale-105 transition-all`}>
|
||||
{hasValue(x) ?
|
||||
<Image
|
||||
src={`${x.directus_files_id.id}/${x.directus_files_id.filename_download}`}
|
||||
@ -68,7 +69,7 @@ const BillboardGallery: React.FunctionComponent<BillboardGalleryProps> = ({ bill
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
{billboard.gallery.length > 6 && i === 5 && <span className="flex items-center justify-center z-10 size-full absolute top-0 left-0 bg-black/60 text-white text-2xl font-semibold [direction:ltr] pointer-events-none">{`+ ${billboard.gallery.length - 6}`}</span>}
|
||||
{billboard.gallery.length > picsCount && i === picsCount - 1 && <span className="flex items-center justify-center z-10 size-full absolute top-0 left-0 bg-black/30 text-white text-2xl font-semibold [direction:ltr] pointer-events-none">{`+ ${billboard.gallery.length - picsCount}`}</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { hasValue, url, useGetRouter } from 'services/general/general'
|
||||
import { BellLight, BellSolid, BoxOpenLight, BoxOpenSolid, CaretLeftSolid, CircleQuestionSolid, ClockLight, ClockSolid, HouseChimneySolid, PhoneLight, PhoneSolid, ReplySolid, StoreLight, StoreRegular, StoreSolid } from "components/icons"
|
||||
import { BellLight, BellSolid, BoxOpenLight, BoxOpenSolid, CaretLeftSolid, CircleQuestionSolid, ClockLight, ClockSolid, HouseChimneySolid, PhoneLight, PhoneSolid, ReplySolid, StoreLight, StoreRegular, StoreSolid, TicketLight, TicketSolid } from "components/icons"
|
||||
import Link from "components/link/link";
|
||||
import Image from "components/image/image";
|
||||
|
||||
@ -18,6 +18,8 @@ interface BillboardMenuProps {
|
||||
type: string;
|
||||
};
|
||||
currecntCatIcon: React.ReactNode;
|
||||
productsLabel: string;
|
||||
productTypes: ["realestate" | "vehicle" | "shop" | "reservation"];
|
||||
className?: string;
|
||||
onChange: (id: number) => void;
|
||||
}
|
||||
@ -55,6 +57,12 @@ const filterIcon = (icon: any) => {
|
||||
case "StoreSolid":
|
||||
finalIcon = <StoreSolid className={menuIconClass} />
|
||||
break;
|
||||
case "TicketLight":
|
||||
finalIcon = <TicketLight className={menuIconClass} />
|
||||
break;
|
||||
case "TicketSolid":
|
||||
finalIcon = <TicketSolid className={menuIconClass} />
|
||||
break;
|
||||
case "ClockLight":
|
||||
finalIcon = <ClockLight className={menuIconClass} />
|
||||
break;
|
||||
@ -105,38 +113,16 @@ const MenuItem: React.FunctionComponent<MenuItemProps> = ({ id, label, noTitle =
|
||||
shallow
|
||||
scroll={false}
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}${link}`}
|
||||
className={`flex flex-col items-center justify-center ${id === 2 ? "space-y-1" : "space-y-2"} py-3 px-1 ${isActive ? "text-[var(--brand-color)]" : "text-secondary-light"} ${className}`}
|
||||
className={`flex flex-col items-center justify-center ${id === 1 ? "space-y-1" : "space-y-2"} py-3 px-1 ${isActive ? "text-[var(--brand-color)]" : "text-secondary-light"} ${className}`}
|
||||
onClick={() => onClick(id)}
|
||||
>
|
||||
{id === 2 ?
|
||||
<div className="inline-block rounded-full bg-white relative -mt-9 shadow-top-strong border-[5px] border-white">
|
||||
{hasValue(billboardPic) ?
|
||||
<Image
|
||||
src={billboardPic.id}
|
||||
alt={billboardTitle}
|
||||
width={billboardPic.width}
|
||||
height={billboardPic.height}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[50, 50, 50, 50]}
|
||||
className="block size-14 lg:size-9 rounded-full object-cover"
|
||||
figureClass={`block size-14 lg:size-9 bg-white rounded-full bg-secondary shrink-0`}
|
||||
/>
|
||||
:
|
||||
<span className="block size-full aspect-1/1 md:aspect-1/1 rounded-lg p-2 text-current">
|
||||
<CircleQuestionSolid className={`inline-block size-10 xl:size-3 fill-current`} />
|
||||
</span>
|
||||
}
|
||||
{/* {user.inbox.new_messages > 0 && <NotificationSign wrapperClass="absolute right-1 -bottom-1" />} */}
|
||||
</div>
|
||||
:
|
||||
filterIcon(isActive ? activeIcon : icon)
|
||||
}
|
||||
{filterIcon(isActive ? activeIcon : icon)}
|
||||
<span className={`inline-block text-xs sm:text-sm text-current capitalize font-semibold shrink-0`}>{label}</span>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
const BillboardMenu: React.FunctionComponent<BillboardMenuProps> = ({ billboardId, billboardTitle, className, onChange, billboardPic, currecntCatIcon }) => {
|
||||
const BillboardMenu: React.FunctionComponent<BillboardMenuProps> = ({ billboardId, billboardTitle, productsLabel, productTypes, className, onChange, billboardPic, currecntCatIcon }) => {
|
||||
|
||||
// states
|
||||
const [currentActive, setCurrentActive] = useState(0);
|
||||
@ -145,27 +131,23 @@ const BillboardMenu: React.FunctionComponent<BillboardMenuProps> = ({ billboardI
|
||||
const translate = useTranslate();
|
||||
const { locale, asPath, router } = useGetRouter();
|
||||
const menuData = [
|
||||
{ label: translate("billboard-tabs-products"), icon: "BoxOpenLight", activeIcon: "BoxOpenSolid", link: "/products#products" },
|
||||
{ label: translate("billboard-tabs-home"), icon: "StoreLight", activeIcon: "StoreSolid", link: "#info" },
|
||||
{ label: productsLabel, icon: productTypes[0] === "reservation" ? "TicketLight" : "BoxOpenLight", activeIcon: productTypes[0] === "reservation" ? "TicketSolid" : "BoxOpenSolid", link: "/products#products" },
|
||||
{ label: translate("billboard-tabs-updates"), icon: "BellLight", activeIcon: "BellSolid", link: "/updates" },
|
||||
{ label: translate("contact"), icon: "PhoneLight", activeIcon: "PhoneSolid", link: "#contact" },
|
||||
{ label: translate("hours"), icon: "ClockLight", activeIcon: "ClockSolid", link: "#work-hours" },
|
||||
{ label: translate("billboard-tabs-info"), icon: "StoreLight", activeIcon: "StoreSolid", link: "#info" },
|
||||
];
|
||||
|
||||
// methods
|
||||
const getTab = () => {
|
||||
let activeTab = 2;
|
||||
if (asPath.includes("/products")) activeTab = 0;
|
||||
if (asPath.includes("/updates")) activeTab = 1;
|
||||
if (asPath.includes("#info")) activeTab = 2;
|
||||
if (asPath.includes("#work-hours")) activeTab = 3;
|
||||
if (asPath.includes("#contact")) activeTab = 4;
|
||||
if (asPath.includes("/products")) activeTab = 1;
|
||||
if (asPath.includes("/updates")) activeTab = 2;
|
||||
if (asPath.includes("#info")) activeTab = 0;
|
||||
return activeTab;
|
||||
}
|
||||
const onClick = (id: number) => {
|
||||
id === 2 && onChange(1);
|
||||
id === 1 && onChange(1);
|
||||
id === 2 && onChange(3);
|
||||
id === 3 && onChange(2);
|
||||
id === 4 && onChange(3);
|
||||
setCurrentActive(id);
|
||||
}
|
||||
const onBack = () => {
|
||||
@ -179,7 +161,7 @@ const BillboardMenu: React.FunctionComponent<BillboardMenuProps> = ({ billboardI
|
||||
return (
|
||||
<div className="block w-full sticky bottom-0 z-20">
|
||||
{asPath.includes("?product=") && <ReplySolid className="reactive-button inline-block lg:hidden absolute size-11 rtl:left-3 ltr:right-3 ltr:[transform:rotateY(180deg)] bottom-24 fill-current p-3 rounded-full bg-white text-[var(--brand-color)] shadow-strong z-[25]" onClick={onBack} />}
|
||||
<div className={`grid grid-cols-5 items-center w-full bg-white py-0 px-4 shadow-top-strong ${className}`}>
|
||||
<div className={`grid grid-cols-3 items-center w-full bg-white py-0 px-4 shadow-top-strong ${className}`}>
|
||||
{menuData.map((x, i) => (
|
||||
<MenuItem
|
||||
key={x.label + i}
|
||||
|
||||
@ -52,13 +52,14 @@ const ProductBreadcrumbDesktop: React.FunctionComponent<ProductBreadcrumbDesktop
|
||||
className="inline-block text-xs lg:text-xs text-current font-semibold capitalize py-1 px-3 rounded bg-[var(--light-brand-color)]"
|
||||
>
|
||||
{getLocaleTr(parentCat, locale).name}
|
||||
</Link>}
|
||||
</Link>
|
||||
}
|
||||
{productCategory.parent.length > 0 && <span className="text-current">/</span>}
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}/products/?cat=${productCategory.parent.length > 0 ?
|
||||
productCategory.parent[0] + "-" + productCategory.local_id
|
||||
:
|
||||
productCategory.id
|
||||
productCategory.local_id
|
||||
}`}
|
||||
scroll={false}
|
||||
shallow
|
||||
|
||||
@ -1,116 +1,35 @@
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { basePath, getLocaleTr, useGetRouter } from 'services/general/general'
|
||||
import { CaretLeftSolid, CircleExclamationSolid, PhoneSolid } from "components/icons"
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, BillboardServiceTypes, ProductVariantGalleries, ProductVariations } from "common/types/billboard"
|
||||
import Parser from "components/parser/parser"
|
||||
import useSWR from "swr"
|
||||
import RelatedProducts from "./related"
|
||||
import ColorSelection from "../products/colors"
|
||||
import SizeSelection from "../products/sizes"
|
||||
import VariantSelection from "../products/variants"
|
||||
import ProductGallery from "./product-gallery"
|
||||
import ProductBreadcrumbDesktop from "./product-breadcrumb-desktop"
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, BillboardServiceType, ProductVariantGalleries } from "common/types/billboard"
|
||||
import ProductBreadcrumbMobile from "./product-breadcrumb-mobile"
|
||||
import ProductHeader from "./product-header"
|
||||
import dynamic from "next/dynamic"
|
||||
import ProductPlaceHolder from "../products/placeholder"
|
||||
import { getVariantSize, variantsOrder } from "services/billboard/general"
|
||||
import { Color, Flavor, Material, Packaging, Scent, Size, Texture } from "common/types/general"
|
||||
import { sizesOrder } from "components/general/form-tools"
|
||||
import { fetchDataRequest } from "services/queries/directus/billboard"
|
||||
import { safeJson } from "lib/general/safe-response-json"
|
||||
import { PriceDataProps } from "pages/api/billboard/product-price-data"
|
||||
const ProductOrder = dynamic(() => import("./product-order"), { ssr: false })
|
||||
const ProductReservation = dynamic(() => import("./product-reservation"), { ssr: false })
|
||||
import PurchasableProductsDetails from "./purchasable/purchasable-product-details"
|
||||
import ReservationProductsDetails from "./reservations/reservation-product-details"
|
||||
|
||||
|
||||
interface BillboardProductDetailsProps {
|
||||
billboard: Billboard;
|
||||
product: BillboardProduct;
|
||||
productRatings: any;
|
||||
serviceTypes: BillboardServiceTypes[];
|
||||
serviceTypes: BillboardServiceType[];
|
||||
productCats: BillboardProductsCategory[];
|
||||
thumbs: ProductVariantGalleries[];
|
||||
}
|
||||
interface SectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
noTitle?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
interface ProductFeatureProps {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
className?: string;
|
||||
labelClass?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
interface VariantsDataProps {
|
||||
thumbs: { all: ProductVariantGalleries[], available: ProductVariantGalleries[] },
|
||||
color: { all: Color[], available: Color[] },
|
||||
size: { all: string[], available: string[] },
|
||||
}
|
||||
// ────────────────────────────────────────────────
|
||||
// Helper types
|
||||
// ────────────────────────────────────────────────
|
||||
|
||||
type SizeOption = string; // e.g. "S", "M", "1000 gr", "XL", etc.
|
||||
|
||||
interface VariantFilters {
|
||||
thumbId?: string;
|
||||
colorId?: number | string;
|
||||
size?: string;
|
||||
}
|
||||
|
||||
const Section: React.FunctionComponent<SectionProps> = ({ id, title, noTitle = false, children, className }) => {
|
||||
return <div className={`block ${className}`}>
|
||||
<span id={id} className="absolute -top-20"></span>
|
||||
{!noTitle &&
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-4">
|
||||
{title}
|
||||
</span>
|
||||
}
|
||||
{children}
|
||||
</div>
|
||||
}
|
||||
|
||||
const ProductFeature: React.FunctionComponent<ProductFeatureProps> = ({ label, value, icon, className, labelClass, valueClass }) => {
|
||||
return <div className={`flex space-x-1 rtl:space-x-reverse text-secondary-light mb-2 ${className}`}>
|
||||
{icon}
|
||||
<span className={`inline-block text-sm sm:text-sm text-secondary-light capitalize text-current shrink-0 ${labelClass}`}>{label} :</span>
|
||||
<span className={`inline-block text-sm/6 sm:text-sm/6 text-secondary-light capitalize text-current ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const BillboardProductDetails: React.FunctionComponent<BillboardProductDetailsProps> = ({ billboard, product, productRatings, serviceTypes, productCats, thumbs }) => {
|
||||
|
||||
// states
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariations | null>(null);
|
||||
const [mainAttribute, setMainAttribute] = useState<"color" | "flavor">("color");
|
||||
const [priceData, setPriceData] = useState<PriceDataProps[]>([]);
|
||||
const [variantsData, setVariantsData] = useState<VariantsDataProps>({
|
||||
thumbs: { all: [], available: [] },
|
||||
color: { all: [], available: [] },
|
||||
size: { all: [], available: [] },
|
||||
});
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, query, router } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
|
||||
const item: BillboardProduct = product;
|
||||
const categories: BillboardProductsCategory[] = productCats;
|
||||
const featureIconClass = "inline-block size-3 xl:size-3 fill-[var(--brand-color)] mt-1 rtl:ml-1 ltr:mr-1 ltr:rotate-180";
|
||||
|
||||
const { data: stockData, error: stockDataFetchError } = useSWR(item ? [`billboard/product-stock-data`, [item.id], false] : null, ([apiRoute, data]) => fetchDataRequest({ apiRoute, data }));
|
||||
|
||||
stockDataFetchError && console.log(stockDataFetchError);
|
||||
|
||||
const ratings = productRatings[0];
|
||||
const serviceTypesList: BillboardServiceTypes[] = serviceTypes;
|
||||
|
||||
// methods
|
||||
const getPriceData = async (products: string[], bId: string) => {
|
||||
@ -137,287 +56,7 @@ const BillboardProductDetails: React.FunctionComponent<BillboardProductDetailsPr
|
||||
await getPriceData(products, bId);
|
||||
}
|
||||
|
||||
// variant methods
|
||||
|
||||
function getSizeKey(v: ProductVariations): string {
|
||||
if (v.size_type === "predefined") {
|
||||
// Use the ID of the predefined size object (including when it's "none")
|
||||
if (v.size?.id != null) {
|
||||
return `predef_${String(v.size.id)}`;
|
||||
}
|
||||
// fallback if somehow size is missing (shouldn't happen)
|
||||
return "predef_unknown";
|
||||
}
|
||||
|
||||
if (v.size_type === "numeric" && v.size_number != null) {
|
||||
const unitId = v.size_unit?.id ?? 0; // 0 = no unit / unknown
|
||||
return `num_${v.size_number}_${unitId}`;
|
||||
}
|
||||
|
||||
// truly no size information at all (rare fallback)
|
||||
return "no-size";
|
||||
}
|
||||
// ────────────────────────────────────────────────
|
||||
// Data normalization
|
||||
// ────────────────────────────────────────────────
|
||||
const normalizeVariants = (variations: ProductVariations[]) => {
|
||||
const thumbMap = new Map<string, ProductVariantGalleries>();
|
||||
const colorSet = new Set<string | number>();
|
||||
const sizeKeyToDisplay = new Map<string, string>();
|
||||
const sizeKeys = new Set<string>();
|
||||
|
||||
variations.forEach((v) => {
|
||||
if (v.pics?.[0]?.variant_galleries_id) {
|
||||
const gid = v.pics[0].variant_galleries_id.id;
|
||||
thumbMap.set(gid, v.pics[0].variant_galleries_id);
|
||||
}
|
||||
|
||||
if (v.color?.id) colorSet.add(v.color.id);
|
||||
|
||||
const key = getSizeKey(v);
|
||||
sizeKeys.add(key);
|
||||
|
||||
// Cache localized display once per unique size key
|
||||
if (!sizeKeyToDisplay.has(key)) {
|
||||
const display = getVariantSize(v, locale) || "-";
|
||||
sizeKeyToDisplay.set(key, display);
|
||||
}
|
||||
});
|
||||
|
||||
// Sorted display strings for the UI buttons
|
||||
const allSizesDisplay = Array.from(sizeKeys)
|
||||
.map(key => sizeKeyToDisplay.get(key) || "-")
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const na = Number.parseFloat(a);
|
||||
const nb = Number.parseFloat(b);
|
||||
if (!isNaN(na) && !isNaN(nb)) return na - nb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return {
|
||||
allThumbs: Array.from(thumbMap.values()),
|
||||
allColors: Array.from(colorSet)
|
||||
.map(id => variations.find(v => v.color?.id === id)?.color)
|
||||
.filter(Boolean) as Color[],
|
||||
allSizes: allSizesDisplay,
|
||||
sizeKeyToDisplay, // we'll use this map in computeAvailable
|
||||
};
|
||||
};
|
||||
|
||||
// variants availability check function
|
||||
const handleVariantSelection = (type: "thumb" | "color" | "size", value: string | number) => {
|
||||
if (!item?.variations?.length) return;
|
||||
|
||||
const current = selectedVariant;
|
||||
if (!current) return;
|
||||
|
||||
let targetThumbId = current.pics?.[0]?.variant_galleries_id?.id;
|
||||
let targetColor = current.color;
|
||||
let targetSizeKey = getSizeKey(current);
|
||||
|
||||
// Apply the new user choice
|
||||
if (type === "thumb") {
|
||||
targetThumbId = String(value);
|
||||
}
|
||||
if (type === "color") {
|
||||
targetColor = item.variations.find(v => v.color?.id === value)?.color ?? targetColor;
|
||||
}
|
||||
if (type === "size") {
|
||||
const matchingVariant = item.variations.find(vv =>
|
||||
getVariantSize(vv, locale) === String(value)
|
||||
);
|
||||
if (matchingVariant) targetSizeKey = getSizeKey(matchingVariant);
|
||||
}
|
||||
|
||||
let candidate: ProductVariations | undefined;
|
||||
|
||||
// Priority 1: Exact match with stock
|
||||
candidate = item.variations.find(v => {
|
||||
const tMatch = targetThumbId ? v.pics?.[0]?.variant_galleries_id?.id === targetThumbId : true;
|
||||
const cMatch = targetColor?.id ? v.color?.id === targetColor.id : true;
|
||||
const sMatch = getSizeKey(v) === targetSizeKey;
|
||||
return tMatch && cMatch && sMatch && !v.out_of_stock;
|
||||
});
|
||||
|
||||
// Priority 2: For color change — strongly prefer new color + any valid size (fallback to current size only if possible)
|
||||
if (!candidate && type === "color") {
|
||||
// First try: new color + current size
|
||||
candidate = item.variations.find(v => {
|
||||
return (
|
||||
v.color?.id === targetColor?.id &&
|
||||
getSizeKey(v) === targetSizeKey &&
|
||||
!v.out_of_stock
|
||||
);
|
||||
});
|
||||
|
||||
// If not → new color + ANY size (pick the first available)
|
||||
if (!candidate) {
|
||||
candidate = item.variations.find(v => {
|
||||
return v.color?.id === targetColor?.id && !v.out_of_stock;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: For size change — prefer new size + any color
|
||||
if (!candidate && type === "size") {
|
||||
candidate = item.variations.find(v => {
|
||||
return getSizeKey(v) === targetSizeKey && !v.out_of_stock;
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 4: General fallbacks
|
||||
if (!candidate) {
|
||||
// Keep thumb if possible
|
||||
if (targetThumbId) {
|
||||
candidate = item.variations.find(v =>
|
||||
v.pics?.[0]?.variant_galleries_id?.id === targetThumbId && !v.out_of_stock
|
||||
);
|
||||
}
|
||||
|
||||
// Keep color
|
||||
if (!candidate && targetColor?.id) {
|
||||
candidate = item.variations.find(v =>
|
||||
v.color?.id === targetColor.id && !v.out_of_stock
|
||||
);
|
||||
}
|
||||
|
||||
// Anything
|
||||
if (!candidate) {
|
||||
candidate = item.variations.find(v => !v.out_of_stock);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate && candidate.id !== selectedVariant?.id) {
|
||||
// console.log("Setting new variant on", type, "selection:", {
|
||||
// newColor: candidate.color?.english_name || candidate.color?.persian_name,
|
||||
// newSize: getVariantSize(candidate, locale),
|
||||
// newThumb: candidate.pics?.[0]?.variant_galleries_id?.id
|
||||
// });
|
||||
setSelectedVariant({ ...candidate }); // spread for new reference
|
||||
}
|
||||
};
|
||||
|
||||
// compute avilable
|
||||
const computeAvailable = (selected: ProductVariations | null): VariantsDataProps => {
|
||||
if (!item?.variations?.length || !selected) {
|
||||
return {
|
||||
thumbs: { all: [], available: [] },
|
||||
color: { all: [], available: [] },
|
||||
size: { all: [], available: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const base = normalizeVariants(item.variations);
|
||||
|
||||
const currentThumbId = selected.pics?.[0]?.variant_galleries_id?.id;
|
||||
const currentColorId = selected.color?.id;
|
||||
const currentSizeKey = getSizeKey(selected);
|
||||
|
||||
// Loose: everything in stock
|
||||
const loose = {
|
||||
thumbs: new Set<string>(),
|
||||
colors: new Set<string | number>(),
|
||||
sizeKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
// Strict per constraint pair
|
||||
const sizesForCurrentColor = new Set<string>(); // sizes valid for current color (ignore thumb for size restriction)
|
||||
const colorsForCurrentSize = new Set<string | number>(); // colors valid for current size
|
||||
const thumbsForCurrentColorAndSize = new Set<string>();
|
||||
|
||||
item.variations.forEach(v => {
|
||||
if (v.out_of_stock) return;
|
||||
|
||||
const t = v.pics?.[0]?.variant_galleries_id?.id;
|
||||
const c = v.color?.id;
|
||||
const sk = getSizeKey(v);
|
||||
|
||||
loose.thumbs.add(t || '');
|
||||
loose.colors.add(c || '');
|
||||
loose.sizeKeys.add(sk);
|
||||
|
||||
const colorMatches = !currentColorId || c === currentColorId;
|
||||
const sizeMatches = sk === currentSizeKey;
|
||||
const thumbMatches = !currentThumbId || t === currentThumbId;
|
||||
|
||||
// Key fix: sizes restricted by **color only** (most important for your case)
|
||||
if (colorMatches) {
|
||||
sizesForCurrentColor.add(sk);
|
||||
}
|
||||
|
||||
// Colors restricted by current size
|
||||
if (sizeMatches) {
|
||||
colorsForCurrentSize.add(c);
|
||||
}
|
||||
|
||||
// Thumbs restricted by color + size
|
||||
if (colorMatches && sizeMatches) {
|
||||
thumbsForCurrentColorAndSize.add(t || '');
|
||||
}
|
||||
});
|
||||
|
||||
// Use strict where meaningful, else loose
|
||||
// For sizes: prioritize color restriction (since size is the most visible constraint here)
|
||||
const finalSizes = sizesForCurrentColor.size > 0 ? sizesForCurrentColor : loose.sizeKeys;
|
||||
|
||||
// For colors: restrict by current size
|
||||
const finalColors = colorsForCurrentSize.size > 0 ? colorsForCurrentSize : loose.colors;
|
||||
|
||||
// For thumbs: keep all visible (your preference), or restrict if you want
|
||||
const finalThumbs = base.allThumbs; // always show all thumbs, as per your code
|
||||
|
||||
const availableSizeDisplays = Array.from(finalSizes)
|
||||
.map(key => base.sizeKeyToDisplay.get(key))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// console.log({
|
||||
// selectedColor: selected.color?.english_name || selected.color?.persian_name,
|
||||
// currentSize: getVariantSize(selected, locale),
|
||||
// availableSizes: availableSizeDisplays,
|
||||
// allSizes: base.allSizes,
|
||||
// sizesForCurrentColor: Array.from(sizesForCurrentColor).map(k => base.sizeKeyToDisplay.get(k)),
|
||||
// });
|
||||
return {
|
||||
thumbs: {
|
||||
all: base.allThumbs,
|
||||
available: finalThumbs.filter(th => th.id), // all thumbs always "available" visually
|
||||
},
|
||||
color: {
|
||||
all: base.allColors,
|
||||
available: base.allColors.filter(c => finalColors.has(c.id)),
|
||||
},
|
||||
size: {
|
||||
all: base.allSizes,
|
||||
available: availableSizeDisplays,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
if (!item?.variations?.length) return;
|
||||
|
||||
const main = item.variations.find(v => v.main_variant) || item.variations[0];
|
||||
setSelectedVariant(main);
|
||||
setMainAttribute("color"); // or detect from category if needed
|
||||
|
||||
// Initial data
|
||||
const base = normalizeVariants(item.variations);
|
||||
setVariantsData({
|
||||
thumbs: { all: base.allThumbs, available: base.allThumbs },
|
||||
color: { all: base.allColors, available: base.allColors },
|
||||
size: { all: base.allSizes, available: base.allSizes },
|
||||
});
|
||||
}, [item]);
|
||||
|
||||
// keep variant data in sync
|
||||
useEffect(() => {
|
||||
if (selectedVariant && item) {
|
||||
setVariantsData(computeAvailable(selectedVariant));
|
||||
}
|
||||
}, [selectedVariant, item]);
|
||||
|
||||
// fetch product price data
|
||||
useEffect(() => {
|
||||
if (item && priceData.length === 0) {
|
||||
@ -433,136 +72,14 @@ const BillboardProductDetails: React.FunctionComponent<BillboardProductDetailsPr
|
||||
} as React.CSSProperties}
|
||||
className="block w-full"
|
||||
>
|
||||
{item && selectedVariant && stockData && priceData.length > 0 && categories ?
|
||||
{(item && priceData.length > 0 && categories) ?
|
||||
<>
|
||||
{/* Breadcrumbs */}
|
||||
{item.category && <ProductBreadcrumbMobile productCategory={item.category} parentCat={categories.filter(x => x.local_id === item.category.parent[0])[0]} billboardId={billboard.id} billboardTitle={billboardTitle} />}
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-4 rtl:space-x-reverse items-center lg:items-start w-full bg-white rounded-md shrink-0 justify-center lg:px-2 lg:py-8">
|
||||
<ProductGallery
|
||||
productTitle={getLocaleTr(item, locale).title}
|
||||
galleryItems={!item.variations || !item.variations.some(x => x.pics.length > 0) ? [] : (selectedVariant.pics[0].variant_galleries_id.gallery.length > 0 ? selectedVariant.pics[0].variant_galleries_id.gallery : [])}
|
||||
/>
|
||||
<div className="bg-white block w-full lg:w-1/2 px-6 lg:px-4 pt-4 pb-6 lg:pt-0 border-t-[4px] border-gray-50 lg:border-t-0 border-b lg:border-b-0 border-b-gray-100">
|
||||
{item.category && <ProductBreadcrumbDesktop productCategory={item.category} parentCat={categories.filter(x => x.local_id === item.category.parent[0])[0]} billboardId={billboard.id} billboardTitle={billboardTitle} />}
|
||||
<ProductHeader
|
||||
productTitle={getLocaleTr(item, locale).title}
|
||||
isRatingsAvailable={productRatings ? true : false}
|
||||
ratings={ratings}
|
||||
productDetails={{
|
||||
type: item.type,
|
||||
price: selectedVariant.price ?? priceData[0].price,
|
||||
discount_price: selectedVariant.discounted_price ?? priceData[0].discounted_price,
|
||||
priceSign: priceData[0].currency.sign,
|
||||
weight: selectedVariant.weight ? selectedVariant.weight : null,
|
||||
weight_unit: selectedVariant.weight ? getLocaleTr(selectedVariant.weight_unit, locale).name : "",
|
||||
volume: selectedVariant.volume ? selectedVariant.volume : null,
|
||||
volume_unit: selectedVariant.volume ? getLocaleTr(selectedVariant.volume_unit, locale).name : "",
|
||||
brand: item.brand,
|
||||
stock_count: stockData[0].totalAvailableStock,
|
||||
reservationPrice: serviceTypes ? serviceTypesList[0]?.session_data[0].price : -1,
|
||||
reservationDiscountedPrice: serviceTypes ? serviceTypesList[0]?.session_data[0].discount_price : -1
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* ---- product variations start ---- */}
|
||||
{/* thumbs */}
|
||||
{thumbs.length > 1 && selectedVariant && (
|
||||
<VariantSelection
|
||||
productTitle={getLocaleTr(item, locale).title}
|
||||
variants={variantsData.thumbs.all} // always show all
|
||||
allVariants={item.variations}
|
||||
mainAtrr={mainAttribute}
|
||||
selected={selectedVariant.pics[0]?.variant_galleries_id?.id ?? ""}
|
||||
style={selectedVariant.style === "custom" ? getLocaleTr(selectedVariant, locale).style_text ?? "" : ""}
|
||||
onSelect={(id) => handleVariantSelection("thumb", id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* colors */}
|
||||
{variantsData.color.all.length > 0 && <ColorSelection
|
||||
all={variantsData.color.all}
|
||||
available={variantsData.color.available}
|
||||
selected={selectedVariant.color ?? { id: "", persian_name: "", english_name: "", hex_code: "" }}
|
||||
onSelect={(id) => handleVariantSelection("color", id)}
|
||||
/>}
|
||||
|
||||
{/* sizes */}
|
||||
<SizeSelection
|
||||
sizes={variantsData.size.all}
|
||||
availableSizes={variantsData.size.available}
|
||||
defaultSize={getVariantSize(selectedVariant, locale)}
|
||||
onSelect={(size) => handleVariantSelection("size", size)}
|
||||
/>
|
||||
{/* ---- product variations end ---- */}
|
||||
|
||||
|
||||
{/* product desccription */}
|
||||
{getLocaleTr(item, locale).description && <Section id="product-description" title={translate("billboard-product-description")} className="pt-4 mt-6 border-t border-dashed border-gray-200/75">
|
||||
<Parser limit limitLength={800} text={getLocaleTr(item, locale).description} className="[&_*]:lg:!text-sm/7" />
|
||||
</Section>}
|
||||
|
||||
{/* product features */}
|
||||
<Section id="product-features" title={translate("billboard-product-features")} className="pt-4">
|
||||
{item.brand && <ProductFeature label={translate("brand")} value={`${locale === "en" ? item.brand.english_name : item.brand.persian_name}`} icon={<CaretLeftSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{item.code && <ProductFeature label={translate("code")} value={`${item.code}`} icon={<CaretLeftSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{selectedVariant.width && selectedVariant.height && selectedVariant.length && <ProductFeature label={translate("dimensions")} value={`${selectedVariant.length} x ${selectedVariant.width} x ${selectedVariant.height}`} valueClass="[direction:ltr]" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{item.type === "reservation" && getLocaleTr(item, locale).venue_name && <ProductFeature label={translate("billboard-products-reservation-venue-name")} value={`${getLocaleTr(item, locale).venue_name}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{item.type === "reservation" && getLocaleTr(item, locale).venue_address && <ProductFeature label={translate("billboard-products-reservation-venue-address")} value={`${getLocaleTr(item, locale).venue_address}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{item.type === "reservation" && getLocaleTr(item, locale).custom_property && getLocaleTr(item, locale).custom_property.map((x: any) => (
|
||||
<ProductFeature key={x.label} label={x.label} value={`${x.value}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />
|
||||
))}
|
||||
</Section>
|
||||
|
||||
{/* event rules */}
|
||||
{getLocaleTr(item, locale).event_rules && <Section id="event-rules" title={translate("billboard-product-reservation-rules-title")} className="mt-6 pt-4 pb-2 border-t border-dashed border-gray-200/75">
|
||||
<div className="block w-full space-y-2">
|
||||
{getLocaleTr(item, locale).event_rules.map((x: any, i: number) => (
|
||||
<div key={i} className="block w-full text-sm/6 text-secondary-light">
|
||||
<CircleExclamationSolid className={`inline-block size-3 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />
|
||||
{x.rule}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Section>}
|
||||
|
||||
{/* purchase order */}
|
||||
{item.type === "purchasable" && <Section id="product-order" title={translate("billboard-product-order")} className="pt-4 mt-4 border-t border-dashed border-gray-200/75">
|
||||
<ProductOrder
|
||||
productTitle={getLocaleTr(item, locale).title}
|
||||
billboardData={billboard}
|
||||
billboardTranslations={billboard.translations}
|
||||
item={item}
|
||||
selectedVariant={selectedVariant}
|
||||
/>
|
||||
</Section>}
|
||||
|
||||
{/* reservation */}
|
||||
{item.type === "reservation" && serviceTypes &&
|
||||
<ProductReservation
|
||||
productTitle={getLocaleTr(item, locale).title}
|
||||
productId={item.id}
|
||||
billboardId={billboard.id}
|
||||
venue_name={getLocaleTr(item, locale).venue_name}
|
||||
priceUnit={item.price_currency}
|
||||
session_details={item.session_details}
|
||||
thumb={item.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery[0].directus_files_id}
|
||||
startDate={item.reservation_start_date}
|
||||
endDate={item.reservation_end_date}
|
||||
billboardColor={billboard.brand_color}
|
||||
seatsData={billboard.seats_data}
|
||||
/>
|
||||
}
|
||||
|
||||
{/* gallery */}
|
||||
{item.type === "gallery" && <>
|
||||
<a href={`tel:${billboard.phone_number}`} className="reactive-button block w-full capitalize py-3 px-4 mt-10 rounded-lg bg-[var(--brand-color)] text-white font-semibold text-sm/6 text-center">
|
||||
<PhoneSolid className={`inline-block size-4 xl:size-4 shrink-0 rtl:ml-3 ltr:mr-3 fill-current`} />
|
||||
{translate("billboard-page-gallery-type-cta")}
|
||||
</a>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
{(item.type === "purchasable" && item.category) && <RelatedProducts cat={item.category.id} pCat={item.category.parent.length > 0 ? String(item.category.parent[0]) : "-1"} billboardId={billboard.id} billboardTitle={billboardTitle} />}
|
||||
|
||||
{/* Product Details */}
|
||||
{item.type === "purchasable" && <PurchasableProductsDetails billboard={billboard} product={item} priceData={priceData} ratings={ratings} serviceTypes={serviceTypes} categories={categories} thumbs={thumbs} />}
|
||||
{item.type === "reservation" && <ReservationProductsDetails billboard={billboard} product={item} priceData={priceData} ratings={ratings} serviceTypes={serviceTypes} categories={categories} thumbs={thumbs} />}
|
||||
</>
|
||||
:
|
||||
<ProductPlaceHolder />
|
||||
|
||||
@ -0,0 +1,33 @@
|
||||
import React from "react"
|
||||
|
||||
interface ProductFeatureProps {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: React.ReactNode;
|
||||
className?: string;
|
||||
labelClass?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
export const featureIconClass = "inline-block size-3 xl:size-3 fill-[var(--brand-color)] mt-1 rtl:ml-1 ltr:mr-1 ltr:rotate-180";
|
||||
|
||||
const ProductFeature: React.FunctionComponent<ProductFeatureProps> = ({ label, value, icon, className, labelClass, valueClass }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div className={`flex space-x-1 rtl:space-x-reverse text-secondary-light mb-2 ${className}`}>
|
||||
{icon}
|
||||
<span className={`inline-block text-sm sm:text-sm text-secondary-light capitalize text-current shrink-0 ${labelClass}`}>{label} :</span>
|
||||
<span className={`inline-block text-sm/6 sm:text-sm/6 text-secondary-light capitalize text-current ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ProductFeature
|
||||
@ -229,7 +229,7 @@ const ProductOrder: React.FunctionComponent<ProductOrderProps> = ({ productTitle
|
||||
<ul className="list-none block w-full space-y-1">
|
||||
{policiesData.map(x => (
|
||||
<li key={x.id} className="flex items-start w-full py-1">
|
||||
<span className="flex items-center text-xs/6 font-semibold bg-warning-bg text-warning-text px-2 py-[1px] rounded-lg shrink-0">
|
||||
<span className="flex items-center text-xs/6 font-semibold bg-warning-bg text-warning-text px-2 py-[1px] rounded-lg shrink-0 capitalize">
|
||||
{x.icon}
|
||||
{`${x.label} :`}
|
||||
</span>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,35 @@
|
||||
import React from "react"
|
||||
|
||||
interface ProductSectionProps {
|
||||
id: string;
|
||||
title: string;
|
||||
noTitle?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
|
||||
const ProductSection: React.FunctionComponent<ProductSectionProps> = ({ id, title, noTitle = false, children, className }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div className={`block ${className}`}>
|
||||
<span id={id} className="absolute -top-20"></span>
|
||||
{!noTitle &&
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-4">
|
||||
{title}
|
||||
</span>
|
||||
}
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ProductSection
|
||||
@ -5,7 +5,7 @@ import { PercentageSolid } from "components/icons";
|
||||
import StarRating from "components/rating/star-rating";
|
||||
import InnerLoading from "components/loading/inner-loading";
|
||||
|
||||
interface ProductHeaderProps {
|
||||
interface ProductPurchasableHeaderProps {
|
||||
productTitle: string;
|
||||
isRatingsAvailable: boolean;
|
||||
ratings: any;
|
||||
@ -24,13 +24,11 @@ interface ProductHeaderProps {
|
||||
english_name: string;
|
||||
}
|
||||
stock_count: number;
|
||||
reservationPrice: number;
|
||||
reservationDiscountedPrice: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const ProductHeader: React.FunctionComponent<ProductHeaderProps> = ({ productTitle, isRatingsAvailable = false, ratings, productDetails }) => {
|
||||
const ProductPurchasableHeader: React.FunctionComponent<ProductPurchasableHeaderProps> = ({ productTitle, isRatingsAvailable = false, ratings, productDetails }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
@ -55,11 +53,10 @@ const ProductHeader: React.FunctionComponent<ProductHeaderProps> = ({ productTit
|
||||
{/* availablility */}
|
||||
{productDetails.type === "purchasable" && <span className={`inline-block py-[2px] ltr:py-1 px-2 rounded capitalize ${productDetails.stock_count === -1 || productDetails.stock_count > 0 ? "bg-green-700 text-white" : "bg-error-bg text-error-text"} text-sm ltr:text-xs`}>{productDetails.stock_count === -1 ? translate("available") : (productDetails.stock_count > 0 ? translate("available") : translate("not-available")) }</span>}
|
||||
{/* discount percentage */}
|
||||
{((productDetails.discount_price && productDetails.discount_price !== -1) || (productDetails.reservationDiscountedPrice && productDetails.reservationDiscountedPrice !== -1)) && <div className="flex productDetailss-center w-max text-white bg-primary rounded py-1 px-2">
|
||||
{(productDetails.discount_price && productDetails.discount_price !== -1) && <div className="flex productDetailss-center w-max text-white bg-primary rounded py-1 px-2">
|
||||
<PercentageSolid className="inline-block size-3 rtl:xl:size-4 ltr:xl:size-3 fill-current rtl:ml-[2px] ltr:mr-[2px]" />
|
||||
<span className="inline-block text-sm ltr:text-xs text-current font-semibold capitalize">
|
||||
{productDetails.type === "purchasable" && `${Math.ceil(((productDetails.price - productDetails.discount_price) / productDetails.price) * 100)} ${translate("discount")}`}
|
||||
{productDetails.type === "reservation" && `${Math.ceil(((productDetails.reservationPrice - productDetails.reservationDiscountedPrice) / productDetails.reservationPrice) * 100)} ${translate("discount")}`}
|
||||
{`${Math.ceil(((productDetails.discount_price - productDetails.price) / productDetails.price) * 100)} ${translate("discount")}`}
|
||||
</span>
|
||||
</div>}
|
||||
</div>
|
||||
@ -76,11 +73,9 @@ const ProductHeader: React.FunctionComponent<ProductHeaderProps> = ({ productTit
|
||||
{/* price */}
|
||||
<div className="flex productDetailss-center w-max text-secondary-light mt-4">
|
||||
{["purchasable", "external", "gallery"].includes(productDetails.type) && <span className="text-2xl sm:text-2xl rtl:ml-1 ltr:mr-1 text-current font-extrabold">{`${productDetails.priceSign}${productDetails.discount_price !== -1 ? productDetails.discount_price : productDetails.price }`}</span>}
|
||||
{productDetails.type === "reservation" && <span className="text-2xl sm:text-2xl rtl:ml-1 ltr:mr-1 text-current font-extrabold">{`${productDetails.priceSign}${productDetails.reservationDiscountedPrice ?? productDetails.reservationPrice}`}</span>}
|
||||
{["purchasable", "external", "gallery"].includes(productDetails.type) && productDetails.discount_price !== -1 && <span className="text-lg text-cool-gray line-through font-semibold rtl:mr-1 ltr:ml-1">{productDetails.priceSign + productDetails.price}</span>}
|
||||
{productDetails.type === "reservation" && productDetails.reservationDiscountedPrice && <span className="text-lg text-cool-gray line-through font-semibold rtl:mr-1 ltr:ml-1">{productDetails.priceSign + productDetails.reservationPrice}</span>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ProductHeader
|
||||
export default ProductPurchasableHeader
|
||||
@ -0,0 +1,455 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, useGetRouter } from "services/general/general";
|
||||
import useSWR from "swr";
|
||||
import { fetchDataRequest } from "services/queries/directus/billboard";
|
||||
import ProductGallery from "../product-gallery";
|
||||
import ProductBreadcrumbDesktop from "../product-breadcrumb-desktop";
|
||||
import VariantSelection from "../../products/variants";
|
||||
import ColorSelection from "../../products/colors";
|
||||
import SizeSelection from "../../products/sizes";
|
||||
import ProductSection from "../product-section";
|
||||
import { CaretLeftSolid, PhoneSolid } from "components/icons";
|
||||
import ProductOrder from "../product-order";
|
||||
import ProductFeature, { featureIconClass } from "../product-feature";
|
||||
import Parser from "components/parser/parser";
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, BillboardServiceType, ProductVariantGalleries, ProductVariations } from "common/types/billboard";
|
||||
import { Color } from "common/types/general";
|
||||
import { getVariantSize } from "services/billboard/general";
|
||||
import { PriceDataProps } from "pages/api/billboard/product-price-data";
|
||||
import ProductPurchasableHeader from "./product-purchasable-header";
|
||||
import RelatedProducts from "../related";
|
||||
import ProductPlaceHolder from "../../products/placeholder";
|
||||
|
||||
interface PurchasableProductsDetailsProps {
|
||||
billboard: Billboard;
|
||||
product: BillboardProduct;
|
||||
priceData: PriceDataProps[];
|
||||
ratings: any;
|
||||
categories: BillboardProductsCategory[];
|
||||
thumbs: ProductVariantGalleries[];
|
||||
serviceTypes: BillboardServiceType[];
|
||||
}
|
||||
interface VariantsDataProps {
|
||||
thumbs: { all: ProductVariantGalleries[], available: ProductVariantGalleries[] },
|
||||
color: { all: Color[], available: Color[] },
|
||||
size: { all: string[], available: string[] },
|
||||
}
|
||||
|
||||
|
||||
const PurchasableProductsDetails: React.FunctionComponent<PurchasableProductsDetailsProps> = ({ billboard, product, priceData, ratings, categories, thumbs, serviceTypes }) => {
|
||||
|
||||
|
||||
// states
|
||||
const [selectedVariant, setSelectedVariant] = useState<ProductVariations | null>(null);
|
||||
const [mainAttribute, setMainAttribute] = useState<"color" | "flavor">("color");
|
||||
const [variantsData, setVariantsData] = useState<VariantsDataProps>({
|
||||
thumbs: { all: [], available: [] },
|
||||
color: { all: [], available: [] },
|
||||
size: { all: [], available: [] },
|
||||
});
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
|
||||
const { data: stockData, error: stockDataFetchError } = useSWR(product ? [`billboard/product-stock-data`, [product.id], false] : null, ([apiRoute, data]) => fetchDataRequest({ apiRoute, data }));
|
||||
|
||||
stockDataFetchError && console.log(stockDataFetchError);
|
||||
|
||||
|
||||
// methods
|
||||
function getSizeKey(v: ProductVariations): string {
|
||||
if (v.size_type === "predefined") {
|
||||
// Use the ID of the predefined size object (including when it's "none")
|
||||
if (v.size?.id != null) {
|
||||
return `predef_${String(v.size.id)}`;
|
||||
}
|
||||
// fallback if somehow size is missing (shouldn't happen)
|
||||
return "predef_unknown";
|
||||
}
|
||||
|
||||
if (v.size_type === "numeric" && v.size_number != null) {
|
||||
const unitId = v.size_unit?.id ?? 0; // 0 = no unit / unknown
|
||||
return `num_${v.size_number}_${unitId}`;
|
||||
}
|
||||
|
||||
// truly no size information at all (rare fallback)
|
||||
return "no-size";
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────
|
||||
// Data normalization
|
||||
// ────────────────────────────────────────────────
|
||||
const normalizeVariants = (variations: ProductVariations[]) => {
|
||||
const thumbMap = new Map<string, ProductVariantGalleries>();
|
||||
const colorSet = new Set<string | number>();
|
||||
const sizeKeyToDisplay = new Map<string, string>();
|
||||
const sizeKeys = new Set<string>();
|
||||
|
||||
variations.forEach((v) => {
|
||||
if (v.pics?.[0]?.variant_galleries_id) {
|
||||
const gid = v.pics[0].variant_galleries_id.id;
|
||||
thumbMap.set(gid, v.pics[0].variant_galleries_id);
|
||||
}
|
||||
|
||||
if (v.color?.id) colorSet.add(v.color.id);
|
||||
|
||||
const key = getSizeKey(v);
|
||||
sizeKeys.add(key);
|
||||
|
||||
// Cache localized display once per unique size key
|
||||
if (!sizeKeyToDisplay.has(key)) {
|
||||
const display = getVariantSize(v, locale) || "-";
|
||||
sizeKeyToDisplay.set(key, display);
|
||||
}
|
||||
});
|
||||
|
||||
// Sorted display strings for the UI buttons
|
||||
const allSizesDisplay = Array.from(sizeKeys)
|
||||
.map(key => sizeKeyToDisplay.get(key) || "-")
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => {
|
||||
const na = Number.parseFloat(a);
|
||||
const nb = Number.parseFloat(b);
|
||||
if (!isNaN(na) && !isNaN(nb)) return na - nb;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
return {
|
||||
allThumbs: Array.from(thumbMap.values()),
|
||||
allColors: Array.from(colorSet)
|
||||
.map(id => variations.find(v => v.color?.id === id)?.color)
|
||||
.filter(Boolean) as Color[],
|
||||
allSizes: allSizesDisplay,
|
||||
sizeKeyToDisplay, // we'll use this map in computeAvailable
|
||||
};
|
||||
};
|
||||
|
||||
// variants availability check function
|
||||
const handleVariantSelection = (type: "thumb" | "color" | "size", value: string | number) => {
|
||||
if (!product?.variations?.length) return;
|
||||
|
||||
const current = selectedVariant;
|
||||
if (!current) return;
|
||||
|
||||
let targetThumbId = current.pics?.[0]?.variant_galleries_id?.id;
|
||||
let targetColor = current.color;
|
||||
let targetSizeKey = getSizeKey(current);
|
||||
|
||||
// Apply the new user choice
|
||||
if (type === "thumb") {
|
||||
targetThumbId = String(value);
|
||||
}
|
||||
if (type === "color") {
|
||||
targetColor = product.variations.find(v => v.color?.id === value)?.color ?? targetColor;
|
||||
}
|
||||
if (type === "size") {
|
||||
const matchingVariant = product.variations.find(vv =>
|
||||
getVariantSize(vv, locale) === String(value)
|
||||
);
|
||||
if (matchingVariant) targetSizeKey = getSizeKey(matchingVariant);
|
||||
}
|
||||
|
||||
let candidate: ProductVariations | undefined;
|
||||
|
||||
// Priority 1: Exact match with stock
|
||||
candidate = product.variations.find(v => {
|
||||
const tMatch = targetThumbId ? v.pics?.[0]?.variant_galleries_id?.id === targetThumbId : true;
|
||||
const cMatch = targetColor?.id ? v.color?.id === targetColor.id : true;
|
||||
const sMatch = getSizeKey(v) === targetSizeKey;
|
||||
return tMatch && cMatch && sMatch && !v.out_of_stock;
|
||||
});
|
||||
|
||||
// Priority 2: For color change — strongly prefer new color + any valid size (fallback to current size only if possible)
|
||||
if (!candidate && type === "color") {
|
||||
// First try: new color + current size
|
||||
candidate = product.variations.find(v => {
|
||||
return (
|
||||
v.color?.id === targetColor?.id &&
|
||||
getSizeKey(v) === targetSizeKey &&
|
||||
!v.out_of_stock
|
||||
);
|
||||
});
|
||||
|
||||
// If not → new color + ANY size (pick the first available)
|
||||
if (!candidate) {
|
||||
candidate = product.variations.find(v => {
|
||||
return v.color?.id === targetColor?.id && !v.out_of_stock;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 3: For size change — prefer new size + any color
|
||||
if (!candidate && type === "size") {
|
||||
candidate = product.variations.find(v => {
|
||||
return getSizeKey(v) === targetSizeKey && !v.out_of_stock;
|
||||
});
|
||||
}
|
||||
|
||||
// Priority 4: General fallbacks
|
||||
if (!candidate) {
|
||||
// Keep thumb if possible
|
||||
if (targetThumbId) {
|
||||
candidate = product.variations.find(v =>
|
||||
v.pics?.[0]?.variant_galleries_id?.id === targetThumbId && !v.out_of_stock
|
||||
);
|
||||
}
|
||||
|
||||
// Keep color
|
||||
if (!candidate && targetColor?.id) {
|
||||
candidate = product.variations.find(v =>
|
||||
v.color?.id === targetColor.id && !v.out_of_stock
|
||||
);
|
||||
}
|
||||
|
||||
// Anything
|
||||
if (!candidate) {
|
||||
candidate = product.variations.find(v => !v.out_of_stock);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidate && candidate.id !== selectedVariant?.id) {
|
||||
// console.log("Setting new variant on", type, "selection:", {
|
||||
// newColor: candidate.color?.english_name || candidate.color?.persian_name,
|
||||
// newSize: getVariantSize(candidate, locale),
|
||||
// newThumb: candidate.pics?.[0]?.variant_galleries_id?.id
|
||||
// });
|
||||
setSelectedVariant({ ...candidate }); // spread for new reference
|
||||
}
|
||||
};
|
||||
|
||||
// compute avilable
|
||||
const computeAvailable = (selected: ProductVariations | null): VariantsDataProps => {
|
||||
if (!product?.variations?.length || !selected) {
|
||||
return {
|
||||
thumbs: { all: [], available: [] },
|
||||
color: { all: [], available: [] },
|
||||
size: { all: [], available: [] },
|
||||
};
|
||||
}
|
||||
|
||||
const base = normalizeVariants(product.variations);
|
||||
|
||||
const currentThumbId = selected.pics?.[0]?.variant_galleries_id?.id;
|
||||
const currentColorId = selected.color?.id;
|
||||
const currentSizeKey = getSizeKey(selected);
|
||||
|
||||
// Loose: everything in stock
|
||||
const loose = {
|
||||
thumbs: new Set<string>(),
|
||||
colors: new Set<string | number>(),
|
||||
sizeKeys: new Set<string>(),
|
||||
};
|
||||
|
||||
// Strict per constraint pair
|
||||
const sizesForCurrentColor = new Set<string>(); // sizes valid for current color (ignore thumb for size restriction)
|
||||
const colorsForCurrentSize = new Set<string | number>(); // colors valid for current size
|
||||
const thumbsForCurrentColorAndSize = new Set<string>();
|
||||
|
||||
product.variations.forEach(v => {
|
||||
if (v.out_of_stock) return;
|
||||
|
||||
const t = v.pics?.[0]?.variant_galleries_id?.id;
|
||||
const c = v.color?.id;
|
||||
const sk = getSizeKey(v);
|
||||
|
||||
loose.thumbs.add(t || '');
|
||||
loose.colors.add(c || '');
|
||||
loose.sizeKeys.add(sk);
|
||||
|
||||
const colorMatches = !currentColorId || c === currentColorId;
|
||||
const sizeMatches = sk === currentSizeKey;
|
||||
const thumbMatches = !currentThumbId || t === currentThumbId;
|
||||
|
||||
// Key fix: sizes restricted by **color only** (most important for your case)
|
||||
if (colorMatches) {
|
||||
sizesForCurrentColor.add(sk);
|
||||
}
|
||||
|
||||
// Colors restricted by current size
|
||||
if (sizeMatches) {
|
||||
colorsForCurrentSize.add(c);
|
||||
}
|
||||
|
||||
// Thumbs restricted by color + size
|
||||
if (colorMatches && sizeMatches) {
|
||||
thumbsForCurrentColorAndSize.add(t || '');
|
||||
}
|
||||
});
|
||||
|
||||
// Use strict where meaningful, else loose
|
||||
// For sizes: prioritize color restriction (since size is the most visible constraint here)
|
||||
const finalSizes = sizesForCurrentColor.size > 0 ? sizesForCurrentColor : loose.sizeKeys;
|
||||
|
||||
// For colors: restrict by current size
|
||||
const finalColors = colorsForCurrentSize.size > 0 ? colorsForCurrentSize : loose.colors;
|
||||
|
||||
// For thumbs: keep all visible (your preference), or restrict if you want
|
||||
const finalThumbs = base.allThumbs; // always show all thumbs, as per your code
|
||||
|
||||
const availableSizeDisplays = Array.from(finalSizes)
|
||||
.map(key => base.sizeKeyToDisplay.get(key))
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
// console.log({
|
||||
// selectedColor: selected.color?.english_name || selected.color?.persian_name,
|
||||
// currentSize: getVariantSize(selected, locale),
|
||||
// availableSizes: availableSizeDisplays,
|
||||
// allSizes: base.allSizes,
|
||||
// sizesForCurrentColor: Array.from(sizesForCurrentColor).map(k => base.sizeKeyToDisplay.get(k)),
|
||||
// });
|
||||
return {
|
||||
thumbs: {
|
||||
all: base.allThumbs,
|
||||
available: finalThumbs.filter(th => th.id), // all thumbs always "available" visually
|
||||
},
|
||||
color: {
|
||||
all: base.allColors,
|
||||
available: base.allColors.filter(c => finalColors.has(c.id)),
|
||||
},
|
||||
size: {
|
||||
all: base.allSizes,
|
||||
available: availableSizeDisplays,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
if (!product?.variations?.length) return;
|
||||
|
||||
const main = product.variations.find(v => v.main_variant) || product.variations[0];
|
||||
setSelectedVariant(main);
|
||||
setMainAttribute("color"); // or detect from category if needed
|
||||
|
||||
// Initial data
|
||||
const base = normalizeVariants(product.variations);
|
||||
setVariantsData({
|
||||
thumbs: { all: base.allThumbs, available: base.allThumbs },
|
||||
color: { all: base.allColors, available: base.allColors },
|
||||
size: { all: base.allSizes, available: base.allSizes },
|
||||
});
|
||||
}, [product]);
|
||||
|
||||
// keep variant data in sync
|
||||
useEffect(() => {
|
||||
if (selectedVariant && product) {
|
||||
setVariantsData(computeAvailable(selectedVariant));
|
||||
}
|
||||
}, [selectedVariant, product]);
|
||||
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(selectedVariant && stockData) ?
|
||||
<>
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-4 rtl:space-x-reverse items-center lg:items-start w-full bg-white rounded-md shrink-0 justify-center lg:px-2 lg:py-8">
|
||||
<ProductGallery
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
galleryItems={!product.variations || !product.variations.some(x => x.pics.length > 0) ? [] : (selectedVariant.pics[0].variant_galleries_id.gallery.length > 0 ? selectedVariant.pics[0].variant_galleries_id.gallery : [])}
|
||||
/>
|
||||
<div className="bg-white block w-full lg:w-1/2 px-6 lg:px-4 pt-4 pb-6 lg:pt-0 border-t-[4px] border-gray-50 lg:border-t-0 border-b lg:border-b-0 border-b-gray-100">
|
||||
{product.category && <ProductBreadcrumbDesktop productCategory={product.category} parentCat={categories.filter(x => x.local_id === product.category.parent[0])[0]} billboardId={billboard.id} billboardTitle={billboardTitle} />}
|
||||
<ProductPurchasableHeader
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
isRatingsAvailable={ratings ? true : false}
|
||||
ratings={ratings}
|
||||
productDetails={{
|
||||
type: product.type,
|
||||
price: selectedVariant.price ?? priceData[0].price,
|
||||
discount_price: selectedVariant.discounted_price ?? priceData[0].discounted_price,
|
||||
priceSign: priceData[0].currency.sign,
|
||||
weight: selectedVariant.weight ? selectedVariant.weight : null,
|
||||
weight_unit: selectedVariant.weight ? getLocaleTr(selectedVariant.weight_unit, locale).name : "",
|
||||
volume: selectedVariant.volume ? selectedVariant.volume : null,
|
||||
volume_unit: selectedVariant.volume ? getLocaleTr(selectedVariant.volume_unit, locale).name : "",
|
||||
brand: product.brand,
|
||||
stock_count: stockData[0].totalAvailableStock,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
||||
{/* ---- product variations start ---- */}
|
||||
{/* thumbs */}
|
||||
{thumbs.length > 1 && selectedVariant && (
|
||||
<VariantSelection
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
variants={variantsData.thumbs.all} // always show all
|
||||
allVariants={product.variations}
|
||||
mainAtrr={mainAttribute}
|
||||
selected={selectedVariant.pics[0]?.variant_galleries_id?.id ?? ""}
|
||||
style={selectedVariant.style === "custom" ? getLocaleTr(selectedVariant, locale).style_text ?? "" : ""}
|
||||
onSelect={(id) => handleVariantSelection("thumb", id)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* colors */}
|
||||
{variantsData.color.all.length > 0 && <ColorSelection
|
||||
allVariants={product.variations}
|
||||
all={variantsData.color.all}
|
||||
available={variantsData.color.available}
|
||||
selected={selectedVariant.color ?? { id: "", persian_name: "", english_name: "", hex_code: "" }}
|
||||
onSelect={(id) => handleVariantSelection("color", id)}
|
||||
/>}
|
||||
|
||||
{/* sizes */}
|
||||
<SizeSelection
|
||||
sizes={variantsData.size.all}
|
||||
availableSizes={variantsData.size.available}
|
||||
defaultSize={getVariantSize(selectedVariant, locale)}
|
||||
onSelect={(size) => handleVariantSelection("size", size)}
|
||||
/>
|
||||
{/* ---- product variations end ---- */}
|
||||
|
||||
|
||||
{/* product desccription */}
|
||||
{getLocaleTr(product, locale).description && <ProductSection id="product-description" title={translate("billboard-product-description")} className="pt-4 mt-6 border-t border-dashed border-gray-200/75">
|
||||
<Parser limit limitLength={800} text={getLocaleTr(product, locale).description} className="[&_*]:lg:!text-sm/7" />
|
||||
</ProductSection>}
|
||||
|
||||
{/* product features */}
|
||||
<ProductSection id="product-features" title={translate("billboard-product-features")} className="pt-4">
|
||||
{product.brand && <ProductFeature label={translate("brand")} value={`${locale === "en" ? product.brand.english_name : product.brand.persian_name}`} icon={<CaretLeftSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{product.code && <ProductFeature label={translate("code")} value={`${product.code}`} icon={<CaretLeftSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{selectedVariant.width && selectedVariant.height && selectedVariant.length && <ProductFeature label={translate("dimensions")} value={`${selectedVariant.length} x ${selectedVariant.width} x ${selectedVariant.height}`} valueClass="[direction:ltr]" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{product.type === "reservation" && getLocaleTr(product, locale).venue_name && <ProductFeature label={translate("billboard-products-reservation-venue-name")} value={`${getLocaleTr(product, locale).venue_name}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{product.type === "reservation" && getLocaleTr(product, locale).venue_address && <ProductFeature label={translate("billboard-products-reservation-venue-address")} value={`${getLocaleTr(product, locale).venue_address}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{product.type === "reservation" && getLocaleTr(product, locale).custom_property && getLocaleTr(product, locale).custom_property.map((x: any) => (
|
||||
<ProductFeature key={x.label} label={x.label} value={`${x.value}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />
|
||||
))}
|
||||
</ProductSection>
|
||||
|
||||
{/* purchase order */}
|
||||
{product.type === "purchasable" && <ProductSection id="product-order" title={translate("billboard-product-order")} className="pt-4 mt-4 border-t border-dashed border-gray-200/75">
|
||||
<ProductOrder
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
billboardData={billboard}
|
||||
billboardTranslations={billboard.translations}
|
||||
item={product}
|
||||
selectedVariant={selectedVariant}
|
||||
/>
|
||||
</ProductSection>}
|
||||
|
||||
{/* gallery */}
|
||||
{product.type === "gallery" && <>
|
||||
<a href={`tel:${billboard.phone_number}`} className="reactive-button block w-full capitalize py-3 px-4 mt-10 rounded-lg bg-[var(--brand-color)] text-white font-semibold text-sm/6 text-center">
|
||||
<PhoneSolid className={`inline-block size-4 xl:size-4 shrink-0 rtl:ml-3 ltr:mr-3 fill-current`} />
|
||||
{translate("billboard-page-gallery-type-cta")}
|
||||
</a>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
{product.category && <RelatedProducts cat={product.category.id} pCat={product.category.parent.length > 0 ? String(product.category.parent[0]) : "-1"} billboardId={billboard.id} billboardTitle={billboardTitle} />}
|
||||
</>
|
||||
:
|
||||
<ProductPlaceHolder />
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default PurchasableProductsDetails
|
||||
@ -0,0 +1,58 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import DayPickerCalendar from "components/calendars/day-picker-calendar";
|
||||
import { slotsProps } from "./services/reservation-services-with-providers";
|
||||
|
||||
interface CalendarDatePickerProps {
|
||||
selectedDate: Date;
|
||||
showCalendar: boolean;
|
||||
dates: {
|
||||
date: Date;
|
||||
weekday: string;
|
||||
sessions_data?: slotsProps[];
|
||||
closed: boolean;
|
||||
}[];
|
||||
onSelect: (date: Date) => void;
|
||||
}
|
||||
|
||||
const CalendarDatePicker: React.FunctionComponent<CalendarDatePickerProps> = ({ selectedDate, showCalendar, dates, onSelect }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const datesToShow = dates.map(x => x.date);
|
||||
const disabledDates = dates.filter(x => x.closed).map(x => x.date);
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center w-full justify-between mt-8 mb-4 lg:mb-6">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold">{translate("billboard-product-reservation-available-dates")}</span>
|
||||
</div>
|
||||
<DayPickerCalendar
|
||||
id="order-dates"
|
||||
mode="single"
|
||||
value={selectedDate}
|
||||
datesToShow={datesToShow}
|
||||
label={showCalendar ? translate("billboard-product-reservation-close-calendar") : translate("billboard-product-reservation-open-calendar")}
|
||||
disabled={disabledDates}
|
||||
noClear
|
||||
navLayout="around"
|
||||
onChange={(dates) => onSelect(dates[0])}
|
||||
labelClass="!bg-[var(--light-brand-color)] !text-[var(--brand-color)]"
|
||||
todayClass="!text-[var(--brand-color)]"
|
||||
chevronClass="!fill-[var(--brand-color)]"
|
||||
selectedClass="bg-market-title-light !text-white hover:!bg-market-title-light"
|
||||
rangeStartClass="!bg-[var(--brand-color)] hover:!bg-[var(--brand-color)]"
|
||||
rangeMiddleClass="!bg-[var(--light-brand-color)] !text-[var(--brand-color)]"
|
||||
rangeEndClass="!bg-[var(--brand-color)] hover:!bg-[var(--brand-color)]"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default CalendarDatePicker
|
||||
@ -0,0 +1,73 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getMonth, mtd, useGetRouter } from "services/general/general";
|
||||
import { RealTimeDatesProps } from "./product-reservation";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
|
||||
interface DateSelectionProps {
|
||||
selectedDate: Date;
|
||||
today: Date;
|
||||
tomorrow: Date;
|
||||
showCalendar: boolean;
|
||||
currentDateRange: RealTimeDatesProps[];
|
||||
serviceProvidersList: ServiceProvider[];
|
||||
selectedProvider: number;
|
||||
onSelect: (date: Date, i: number) => void;
|
||||
}
|
||||
|
||||
|
||||
const DateSelection: React.FunctionComponent<DateSelectionProps> = ({ selectedDate, today, tomorrow, showCalendar, currentDateRange, serviceProvidersList, selectedProvider, onSelect }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
const providerspecialDates = serviceProvidersList[selectedProvider]?.billboards[0].special_dates;
|
||||
const providerWorkingDays = serviceProvidersList[selectedProvider]?.billboards[0].working_days;
|
||||
|
||||
// methods
|
||||
const isSelectedDate = (item: any) => {
|
||||
return mtd(selectedDate.getTime()) === mtd(item.date.getTime());
|
||||
}
|
||||
const providerNotAvialable = (item: any) => {
|
||||
if (providerWorkingDays) {
|
||||
return providerWorkingDays?.some(y => y.weekday.toLowerCase() === item.weekday && y.not_working);
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div className={`${showCalendar ? "hidden" : "flex"} items-center w-full max-w-[462px] space-x-2 rtl:space-x-reverse px-1 py-4 mt-4 flierland-scrollbar overflow-x-scroll`}>
|
||||
{currentDateRange.map((x: any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`reactive-button flex flex-col items-center select-none w-1/4 relative rounded-md px-2 py-3 lg:py-4 shrink-0 lg:cursor-pointer ring-1
|
||||
${isSelectedDate(x) ? "slider-selected-date bg-[var(--light-brand-color)] ring-2 ring-[var(--brand-color)]" : "bg-white ring-gray-200"}
|
||||
${(x.closed) ? "!bg-error-bg !ring-error-bg pointer-events-none" : ""}
|
||||
${providerNotAvialable(x) ? "!bg-warning-bg/80 !ring-warning-text/15" : ""}`}
|
||||
onClick={() => onSelect(x.date, i)}
|
||||
>
|
||||
{(x.closed) &&
|
||||
<span className={`inline-block text-xs absolute -top-[2px] px-4 py-[2px] rounded-lg -translate-y-1/2 bg-error-bg text-error-text`}>{translate("closed")}</span>
|
||||
}
|
||||
<span className={`inline-block mt-[2px] lg:mt-1 text-xs ${isSelectedDate(x) ? "text-[var(--brand-color)]" : (providerNotAvialable(x) ? "text-warning-text" : "text-secondary-light")}`}>{x.weekday.slice(0, 3)}</span>
|
||||
<span className={`inline-block mt-[2px] lg:mt-1 text-2xl lg:text-3xl ${isSelectedDate(x) ? "text-[var(--brand-color)]" : (providerNotAvialable(x) ? "text-warning-text" : "text-secondary-light")} font-bold`}>{new Date(x.date).getDate()}</span>
|
||||
<span className={`inline-block mt-[1px] lg:mt-1 text-xs ${isSelectedDate(x) ? "text-[var(--brand-color)]" : (providerNotAvialable(x) ? "text-warning-text" : "text-secondary-light")} `}>{getMonth(new Date(x.date).getMonth()).slice(0, 3)}</span>
|
||||
{[mtd(today.getTime()), mtd(tomorrow.getTime())].includes(mtd(x.date.getTime())) &&
|
||||
<span className={`inline-block text-xs absolute bottom-0 px-4 py-[2px] rounded-lg capitalize translate-y-1/2
|
||||
${isSelectedDate(x) ? "bg-[var(--brand-color)] text-white" : "bg-[var(--light-brand-color)] text-secondary-light"}
|
||||
${(x.closed) ? "bg-error-text text-white" : ""}`}>
|
||||
{mtd(today.getTime()) === mtd(x.date.getTime()) && translate("today")}
|
||||
{mtd(tomorrow.getTime()) === mtd(x.date.getTime()) && translate("tomorrow")}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default DateSelection
|
||||
@ -0,0 +1,66 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { hasValue, useGetRouter } from "services/general/general";
|
||||
import { PercentageSolid } from "components/icons";
|
||||
import StarRating from "components/rating/star-rating";
|
||||
import InnerLoading from "components/loading/inner-loading";
|
||||
|
||||
interface ProductReservationHeaderProps {
|
||||
productTitle: string;
|
||||
isRatingsAvailable: boolean;
|
||||
ratings: any;
|
||||
productDetails: {
|
||||
type: "reservation" | "purchasable" | "external" | "gallery" | "ad";
|
||||
price: number;
|
||||
discount_price: number;
|
||||
priceSign: string;
|
||||
reservationPrice: number;
|
||||
reservationDiscountedPrice: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
const ProductReservationHeader: React.FunctionComponent<ProductReservationHeaderProps> = ({ productTitle, isRatingsAvailable = false, ratings, productDetails }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const { locale, router } = useGetRouter();
|
||||
|
||||
// methods
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 className="block text-lg/[2.125rem] font-extrabold xl:text-xl/9 lg:font-extrabold text-secondary-light mb-4 capitalize">{productTitle}</h2>
|
||||
<div className="flex productDetailss-center w-full mt-4 space-x-2 rtl:space-x-reverse">
|
||||
{/* discount percentage */}
|
||||
{((productDetails.discount_price && productDetails.discount_price !== -1) || (productDetails.reservationDiscountedPrice && productDetails.reservationDiscountedPrice !== -1)) && <div className="flex productDetailss-center w-max text-white bg-primary rounded py-1 px-2">
|
||||
<PercentageSolid className="inline-block size-3 rtl:xl:size-4 ltr:xl:size-3 fill-current rtl:ml-[2px] ltr:mr-[2px]" />
|
||||
<span className="inline-block text-sm ltr:text-xs text-current font-semibold capitalize">
|
||||
`${Math.ceil(((productDetails.reservationPrice - productDetails.reservationDiscountedPrice) / productDetails.reservationPrice) * 100)} ${translate("discount")}`
|
||||
</span>
|
||||
</div>}
|
||||
</div>
|
||||
{/* rating */}
|
||||
{isRatingsAvailable ?
|
||||
<div className="flex productDetailss-center space-x-2 rtl:space-x-reverse mt-4 text-[var(--brand-color)]">
|
||||
<StarRating rating={ratings.avg.product_quality} className="text-current -mt-[2px] -mr-1" starClass="size-4 shrink-0" />
|
||||
<span className="text-base font-semibold text-current">{ratings.avg.product_quality ? String(ratings.avg.product_quality).slice(0, 3) : 0}/5 |</span>
|
||||
<span className="text-xs/5 font-semibold text-cool-gray">{`(${ratings.countDistinct.id} ${translate("vote")})`}</span>
|
||||
</div>
|
||||
:
|
||||
<InnerLoading loadingText={""} width={"200"} height={"100"} />
|
||||
}
|
||||
{/* price */}
|
||||
<div className="flex productDetailss-center w-max text-secondary-light mt-4">
|
||||
<span className="text-2xl sm:text-2xl rtl:ml-1 ltr:mr-1 text-current font-extrabold">{`${productDetails.priceSign}${productDetails.reservationDiscountedPrice ?? productDetails.reservationPrice}`}</span>
|
||||
{productDetails.reservationDiscountedPrice && <span className="text-lg text-cool-gray line-through font-semibold rtl:mr-1 ltr:ml-1">{productDetails.priceSign + productDetails.reservationPrice}</span>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ProductReservationHeader
|
||||
@ -0,0 +1,381 @@
|
||||
import React, { useEffect, useMemo, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { fetchPublicData, mtd, safeClone, useGetRouter } from "services/general/general";
|
||||
import { CreditCardSolid } from "components/icons";
|
||||
import Button from "components/button/button";
|
||||
import useSWR from "swr";
|
||||
import InnerLoading from "components/loading/inner-loading";
|
||||
import { BillboardReservation, BillboardServiceType } from "common/types/billboard";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
import { Currencies } from "common/types/general";
|
||||
import ServiceProviders from "./service-providers";
|
||||
import ServiceTypes from "./service-types";
|
||||
import DateSelection from "./date-selection";
|
||||
import TimeSelection from "./time-selection";
|
||||
import SeatSelection from "./seat-selection";
|
||||
import ReservationPrice from "./reservation-price";
|
||||
import ReservationDetails from "./reservation-details";
|
||||
import CalendarDatePicker from "./calendar-date-picker";
|
||||
import { reservationsQuery, serviceProvidersQuery, serviceTypesQuery } from "./reservations-queries";
|
||||
import { getReservationDates } from "./services/reservation-dates";
|
||||
import { getProvidersSharedData } from "./services/providers-shared-data";
|
||||
import { getWithProviderDates } from "./services/with-provider-dates";
|
||||
import { getWithProviderTimeSlots } from "./services/with-provider-time-slots";
|
||||
import { slotsProps } from "./services/reservation-services-with-providers";
|
||||
import { UUID } from "crypto";
|
||||
|
||||
export interface PriceDetailProps {
|
||||
id: UUID;
|
||||
price: number;
|
||||
discount_price: number;
|
||||
amount: number;
|
||||
}
|
||||
interface ProductReservationProps {
|
||||
productTitle: string;
|
||||
productId: string;
|
||||
billboardId: string;
|
||||
priceUnit: Currencies;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
seatsData: {
|
||||
id: string;
|
||||
directus_files_id: {
|
||||
id: string;
|
||||
description: string;
|
||||
filename_download: string;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
persian_name: string;
|
||||
english_name: string;
|
||||
types: {
|
||||
id: number;
|
||||
seats_numbers: string[];
|
||||
}[];
|
||||
}[];
|
||||
thumb: {
|
||||
id: string;
|
||||
description: string;
|
||||
filename_download: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
billboardColor: string;
|
||||
venue_name: string;
|
||||
onTypeSelection?: (type: number) => void;
|
||||
}
|
||||
|
||||
const ProductReservation: React.FunctionComponent<ProductReservationProps> = ({ productTitle, productId, billboardId, venue_name, priceUnit, startDate, endDate, seatsData, thumb, billboardColor, onTypeSelection }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const today = new Date();
|
||||
const tomorrow = new Date(new Date().setDate(today.getDate() + 1));
|
||||
const oneDay = 86400000; // 24 * 60 * 60 * 1000 milliseconds in a day
|
||||
|
||||
// states
|
||||
const [selectedProvider, setSelectedProvider] = useState<number>(0);
|
||||
const [selectedType, setSelectedType] = useState<number>(1);
|
||||
const [selectedDate, setSelectedDate] = useState(today);
|
||||
const [selectedTime, setSelectedTime] = useState<slotsProps | null>(null);
|
||||
const [selectedSeat, setSelectedSeat] = useState<string[]>([]);
|
||||
const [showCalendar, setShowCalendar] = useState<boolean>(false);
|
||||
const [dateRangeCenter, setDateRangeCenter] = useState<Date>(today);
|
||||
const [priceDetails, setPriceDetails] = useState<PriceDetailProps[]>([]);
|
||||
const [ticketsAmount, setTicketsAmount] = useState<number>(0);
|
||||
|
||||
// variables
|
||||
const { locale } = useGetRouter();
|
||||
|
||||
const { data: reservationsData, error: reservationsDataError } = useSWR(productId ? ['', reservationsQuery(productId)] : null, ([route, query]) => fetchPublicData(route, query));
|
||||
const { data: serviceProviders, error: serviceProvidersError } = useSWR(billboardId ? ['', serviceProvidersQuery(billboardId)] : null, ([route, query]) => fetchPublicData(route, query));
|
||||
const { data: serviceTypes, error: serviceTypesError } = useSWR(billboardId ? ['', serviceTypesQuery(productId)] : null, ([route, query]) => fetchPublicData(route, query));
|
||||
|
||||
reservationsDataError && console.log(reservationsDataError);
|
||||
serviceProvidersError && console.log(serviceProvidersError);
|
||||
serviceTypesError && console.log(serviceTypesError);
|
||||
|
||||
|
||||
const reservations: BillboardReservation[] = reservationsData ? reservationsData.data.billboard_reservations : [];
|
||||
const serviceProvidersList: ServiceProvider[] = serviceProviders ? serviceProviders.data.service_provider : [];
|
||||
const serviceTypesList: BillboardServiceType[] = serviceTypes ? serviceTypes.data.billboard_service_types.map((x: BillboardServiceType) => x).sort((a: BillboardServiceType, b: BillboardServiceType) => a.service_id - b.service_id) : [];
|
||||
const currecntSeatsMap = seatsData.length > 0 ? (selectedTime ? seatsData.filter(x => Number(x.id) === selectedTime.seats_map)[0] : seatsData[0]) : null;
|
||||
|
||||
const isReservationReady = () => {
|
||||
let isReady = true;
|
||||
if (!selectedTime) {
|
||||
isReady = false;
|
||||
}
|
||||
if (seatsData.length > 0 && selectedSeat.length === 0) {
|
||||
isReady = false;
|
||||
}
|
||||
return isReady;
|
||||
}
|
||||
|
||||
// all the dates available for reservation for this service type
|
||||
const dates = useMemo(() => {
|
||||
return getReservationDates({ startDate, endDate, reservations, serviceTypesList, serviceProvidersList, selectedType, locale });
|
||||
}, [startDate, endDate, reservations, serviceTypes, serviceProviders, selectedType]);
|
||||
|
||||
// providers shared data
|
||||
const providerShared = useMemo(() => {
|
||||
return getProvidersSharedData({ serviceTypesList, serviceProvidersList, selectedDate, selectedType, selectedProvider, billboardId });
|
||||
}, [selectedDate, serviceTypes, serviceProviders, selectedType, selectedProvider]);
|
||||
|
||||
// all dates available for services with providers
|
||||
const withProviderDates = useMemo(() => {
|
||||
return getWithProviderDates({ startDate, endDate, serviceTypesList, serviceProvidersList, selectedType, selectedProvider, billboardId });
|
||||
}, [startDate, endDate, serviceTypes, selectedType, selectedProvider, serviceProviders]);
|
||||
|
||||
// all time slots available for services with providers
|
||||
const withProviderTimeSlots = useMemo(() => {
|
||||
return getWithProviderTimeSlots({ serviceTypesList, serviceProvidersList, selectedType, providerShared, reservations });
|
||||
}, [selectedDate, serviceTypes, serviceProviders, selectedType, selectedTime, selectedProvider]);
|
||||
|
||||
const timeSlots = serviceProvidersList.length === 0 ?
|
||||
(dates.filter(x => mtd(selectedDate.getTime()) === mtd(x.date.getTime()))[0] ? dates.filter(x => mtd(selectedDate.getTime()) === mtd(x.date.getTime()))[0].sessions_data : [])
|
||||
:
|
||||
[];
|
||||
|
||||
const currentDateRange = serviceProvidersList.length === 0 ?
|
||||
dates.filter(x => (
|
||||
// !x.closed &&
|
||||
Date.parse(mtd(x.date.getTime())) >= Date.parse(mtd(today.getTime())) &&
|
||||
Date.parse(mtd(x.date.getTime())) <= Date.parse(mtd(new Date((dateRangeCenter.getTime() + (7 * oneDay))))) &&
|
||||
Date.parse(mtd(x.date.getTime())) >= Date.parse(mtd(new Date((dateRangeCenter.getTime() - (6 * oneDay)))))
|
||||
))
|
||||
:
|
||||
withProviderDates.filter(x => (
|
||||
// !x.closed &&
|
||||
Date.parse(mtd(x.date.getTime())) >= Date.parse(mtd(today.getTime())) &&
|
||||
Date.parse(mtd(x.date.getTime())) <= Date.parse(mtd(new Date((dateRangeCenter.getTime() + (7 * oneDay))))) &&
|
||||
Date.parse(mtd(x.date.getTime())) >= Date.parse(mtd(new Date((dateRangeCenter.getTime() - (6 * oneDay)))))
|
||||
));
|
||||
|
||||
|
||||
// methods
|
||||
const handleProviderSelection = (id: number) => {
|
||||
setSelectedProvider(id);
|
||||
setSelectedType(1); // reset selected service type
|
||||
}
|
||||
|
||||
const handleTypeSelection = (serviceType: number) => {
|
||||
setSelectedType(serviceType);
|
||||
onTypeSelection && onTypeSelection(serviceType);
|
||||
}
|
||||
|
||||
const handleTimeSelection = (time: any) => {
|
||||
setSelectedTime(time);
|
||||
}
|
||||
|
||||
const handleSeatSelection = (seat: string) => {
|
||||
let localSeats = [...selectedSeat];
|
||||
if (selectedSeat.includes(seat)) {
|
||||
localSeats.splice(localSeats.indexOf(seat), 1);
|
||||
setSelectedSeat(localSeats);
|
||||
} else {
|
||||
if (selectedSeat.length === ticketsAmount) {
|
||||
localSeats.shift();
|
||||
setSelectedSeat([...localSeats, seat]);
|
||||
} else {
|
||||
setSelectedSeat([...localSeats, seat]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleDateSlideClick = (date: Date, i: number) => {
|
||||
setSelectedDate(date);
|
||||
}
|
||||
|
||||
const handleCalendarDateSelection = (date: Date) => {
|
||||
setSelectedDate(date);
|
||||
setDateRangeCenter(date);
|
||||
}
|
||||
|
||||
const handlePriceDetailsUpdate = (data: PriceDetailProps) => {
|
||||
const localPriceDetails: PriceDetailProps[] = safeClone(priceDetails);
|
||||
const currentTicketIndex = localPriceDetails.findIndex(x => x.id === data.id);
|
||||
localPriceDetails.splice(currentTicketIndex, 1, data);
|
||||
setPriceDetails(localPriceDetails);
|
||||
setTicketsAmount(localPriceDetails.reduce((total, a) => total + a.amount, 0));
|
||||
}
|
||||
|
||||
const handleReservation = () => {
|
||||
|
||||
}
|
||||
|
||||
|
||||
// useEffects
|
||||
// center align selected date
|
||||
useEffect(() => {
|
||||
const activeEl = document.getElementsByClassName(`slider-selected-date`)[0];
|
||||
const thumbSlide = setTimeout(() => {
|
||||
activeEl && activeEl.scrollIntoView({ behavior: "smooth", block: 'nearest', inline: "center" });
|
||||
}, 20);
|
||||
return () => {
|
||||
clearTimeout(thumbSlide);
|
||||
}
|
||||
}, [selectedDate])
|
||||
|
||||
// selects the first available date from today onwards
|
||||
useEffect(() => {
|
||||
let localDates = (serviceProvidersList.length > 0 ? withProviderDates : dates).filter(x => mtd(x.date.getTime()) >= mtd(new Date().getTime()));
|
||||
let firstAvailableDay = localDates.find(x => !x.closed);
|
||||
let firstAvailableDate = firstAvailableDay?.date;
|
||||
firstAvailableDate && setSelectedDate(firstAvailableDate);
|
||||
}, [selectedType, dates, withProviderDates])
|
||||
|
||||
// select the first available time of the selected date
|
||||
useEffect(() => {
|
||||
if (dates.length > 0) {
|
||||
let currentDate = dates.filter(x => mtd(x.date.getTime()) >= mtd(selectedDate.getTime()) && !x.closed)[0];
|
||||
setSelectedTime(currentDate?.sessions_data[0]);
|
||||
}
|
||||
if (withProviderDates.length > 0) {
|
||||
setSelectedTime(withProviderTimeSlots[0]);
|
||||
}
|
||||
}, [selectedDate, dates, selectedType])
|
||||
|
||||
// set available calendar dates
|
||||
// useEffect(() => {
|
||||
// let activeDates = dates.filter(x => !x.closed && Date.parse(mtd(x.date.getTime())) >= Date.parse(mtd(today.getTime()))).map(x => mtd(x.date.getTime()));
|
||||
// }, [dates])
|
||||
|
||||
// set available realtime calendar dates
|
||||
// useEffect(() => {
|
||||
// let activeDates = withProviderDates.filter(x => !x.closed && Date.parse(mtd(x.date.getTime())) >= Date.parse(mtd(today.getTime()))).map(x => mtd(x.date.getTime()));
|
||||
// }, [withProviderDates])
|
||||
|
||||
// re-adjust number of selected seats if number of tickets changed
|
||||
if (selectedSeat.length !== ticketsAmount && selectedSeat.length > ticketsAmount) {
|
||||
setSelectedSeat(selectedSeat.slice(0, ticketsAmount));
|
||||
}
|
||||
|
||||
// set initial price details & on every time slot / date cahnge after that
|
||||
useEffect(() => {
|
||||
if (selectedTime) {
|
||||
const hasMutipleTicketTypes = selectedTime.ticketTypesPrice.length > 1;
|
||||
|
||||
const ticketPrices = selectedTime.ticketTypesPrice.map(x => ({
|
||||
id: x.reservation_ticket_type_id,
|
||||
price: hasMutipleTicketTypes ? 0 : x.price,
|
||||
discount_price: hasMutipleTicketTypes ? 0 : x.discount_price,
|
||||
amount: hasMutipleTicketTypes ? 0 : 1
|
||||
}));
|
||||
setPriceDetails(ticketPrices);
|
||||
setTicketsAmount(0);
|
||||
}
|
||||
}, [selectedTime])
|
||||
|
||||
return (
|
||||
<div className="block w-full py-4 my-4 border-t border-dashed border-gray-200/75">
|
||||
{reservations && serviceTypes ?
|
||||
<>
|
||||
{/* service provider selection */}
|
||||
{serviceProviders && serviceProvidersList.length > 0 &&
|
||||
<ServiceProviders
|
||||
serviceProvidersList={serviceProvidersList}
|
||||
selectedProvider={selectedProvider}
|
||||
productTitle={productTitle}
|
||||
billboardColor={billboardColor}
|
||||
onSelect={handleProviderSelection}
|
||||
/>
|
||||
}
|
||||
|
||||
{/* service type selection */}
|
||||
<ServiceTypes
|
||||
serviceTypesList={serviceTypesList}
|
||||
serviceProvidersList={serviceProvidersList}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedType={selectedType}
|
||||
billboardColor={billboardColor}
|
||||
selectedDate={selectedDate}
|
||||
billboardId={billboardId}
|
||||
priceUnit={priceUnit}
|
||||
onSelect={handleTypeSelection}
|
||||
/>
|
||||
|
||||
{/* calendar date picker */}
|
||||
<CalendarDatePicker
|
||||
selectedDate={selectedDate}
|
||||
showCalendar={showCalendar}
|
||||
dates={serviceProvidersList.length === 0 ? dates : withProviderDates}
|
||||
onSelect={handleCalendarDateSelection}
|
||||
/>
|
||||
|
||||
{/* date selection */}
|
||||
<DateSelection
|
||||
selectedDate={selectedDate}
|
||||
today={today}
|
||||
tomorrow={tomorrow}
|
||||
showCalendar={showCalendar}
|
||||
currentDateRange={currentDateRange}
|
||||
serviceProvidersList={serviceProvidersList}
|
||||
selectedProvider={selectedProvider}
|
||||
onSelect={handleDateSlideClick}
|
||||
/>
|
||||
|
||||
{/* time selection */}
|
||||
<TimeSelection
|
||||
timeSlots={serviceProvidersList.length === 0 ? timeSlots : withProviderTimeSlots}
|
||||
selectedTime={selectedTime}
|
||||
billboardColor={billboardColor}
|
||||
onSelect={handleTimeSelection}
|
||||
/>
|
||||
|
||||
{/* reservation price */}
|
||||
<ReservationPrice
|
||||
serviceTypes={serviceTypesList}
|
||||
selectedTime={selectedTime!}
|
||||
selectedType={selectedType}
|
||||
priceUnit={priceUnit}
|
||||
selectedDate={selectedDate}
|
||||
onSelect={handlePriceDetailsUpdate}
|
||||
/>
|
||||
|
||||
{/* seat selection */}
|
||||
{seatsData.length > 0 &&
|
||||
<SeatSelection
|
||||
seatsData={seatsData}
|
||||
selectedTime={selectedTime}
|
||||
currecntSeatsMap={currecntSeatsMap}
|
||||
selectedType={selectedType}
|
||||
productTitle={productTitle}
|
||||
selectedSeat={selectedSeat}
|
||||
onSelect={handleSeatSelection}
|
||||
/>
|
||||
}
|
||||
|
||||
{/* reservation details */}
|
||||
<ReservationDetails
|
||||
selectedTime={selectedTime}
|
||||
selectedDate={selectedDate}
|
||||
productTitle={productTitle}
|
||||
thumb={thumb}
|
||||
priceDetails={priceDetails}
|
||||
venue_name={venue_name}
|
||||
serviceTypes={serviceTypesList}
|
||||
selectedType={selectedType}
|
||||
serviceProvidersList={serviceProvidersList}
|
||||
selectedProvider={selectedProvider}
|
||||
selectedSeat={selectedSeat}
|
||||
priceUnit={priceUnit}
|
||||
/>
|
||||
|
||||
{/* pay & confirm reservation */}
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("billboard-product-page-reservation-cta")}
|
||||
className="w-full py-3 px-gi rounded-lg text-sm font-semibold shadow-none ring-2 mt-4 ring-[var(--brand-color)] bg-[var(--brand-color)] text-white uppercase"
|
||||
leftIcon={<CreditCardSolid className="inline-block size-6 fill-current rtl:ml-4 ltr:mr-4" />}
|
||||
disabledClass="ring-gray-300"
|
||||
onClick={handleReservation}
|
||||
disabled={!isReservationReady()}
|
||||
/>
|
||||
</>
|
||||
:
|
||||
<InnerLoading loadingText={""} width={"200"} height={"100"} color={billboardColor} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ProductReservation
|
||||
@ -0,0 +1,162 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, hasValue, mtd, useGetRouter } from "services/general/general";
|
||||
import { CalendarCheckSolid, CircleDollarSolid, CircleExclamationSolid, CircleQuestionSolid, ClockSolid, HourGlassSolid, ImageSharpSolid, SeatAirlineSolid, TicketSolid, UserVneckHairSolid } from "components/icons";
|
||||
import Image from "components/image/image";
|
||||
import { PriceDetailProps } from "./product-reservation";
|
||||
import { BillboardServiceType } from "common/types/billboard";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
import { Currencies } from "common/types/general";
|
||||
|
||||
interface ReservationDetailsProps {
|
||||
selectedTime: any;
|
||||
selectedDate: Date;
|
||||
productTitle: string;
|
||||
thumb: {
|
||||
id: string;
|
||||
description: string;
|
||||
filename_download: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
priceDetails: PriceDetailProps[];
|
||||
venue_name: string;
|
||||
serviceTypes: BillboardServiceType[];
|
||||
selectedType: number;
|
||||
serviceProvidersList: ServiceProvider[];
|
||||
selectedProvider: number;
|
||||
selectedSeat: string[];
|
||||
priceUnit: Currencies;
|
||||
}
|
||||
|
||||
interface ReservationDetailProps {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value: string;
|
||||
className?: string;
|
||||
labelClass?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
export const ReservationDetail: React.FunctionComponent<ReservationDetailProps> = ({ label, value, icon, className, labelClass, valueClass }) => {
|
||||
return <div className={`flex items-center space-x-1 rtl:space-x-reverse text-secondary-light ${className}`}>
|
||||
{icon}
|
||||
<span className={`inline-block text-sm sm:text-sm text-secondary-light capitalize text-current ${labelClass}`}>{label} :</span>
|
||||
<span className={`inline-block text-sm/6 sm:text-sm/6 text-secondary-light capitalize text-current font-semibold ${valueClass}`}>{value}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ReservationDetails: React.FunctionComponent<ReservationDetailsProps> = ({
|
||||
selectedTime,
|
||||
selectedDate,
|
||||
productTitle,
|
||||
thumb,
|
||||
priceDetails,
|
||||
venue_name,
|
||||
serviceTypes,
|
||||
selectedType,
|
||||
serviceProvidersList,
|
||||
selectedProvider,
|
||||
selectedSeat,
|
||||
priceUnit
|
||||
}) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
const selectedServiceType = serviceTypes.filter(x => x.service_id === selectedType)[0];
|
||||
const totalReservationPrice = priceDetails.reduceRight((total, a) => total + a.price, 0);
|
||||
const serviceTicketTypes = selectedServiceType.ticket_types.map(x => x.reservation_ticket_type_id);
|
||||
|
||||
// methods
|
||||
|
||||
// console.log(priceDetails);
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center w-full justify-between mt-6">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-2">{translate("billboard-product-reservation-details-title")}</span>
|
||||
</div>
|
||||
<div className="block w-full border border-gray-200 rounded-xl pt-3 pb-4 px-4 mt-2 space-y-2">
|
||||
<div className="flex items-center w-full mb-3 pb-3 border-b border-dashed border-gray-200/75">
|
||||
{thumb ?
|
||||
<Image
|
||||
src={`${thumb.id}/${thumb.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={thumb.width}
|
||||
height={thumb.height}
|
||||
quality={75}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[100, 250, 250, 250]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className="rounded-full will-change-transform !object-cover border-4 border-[var(--light-brand-color)]"
|
||||
figureClass="rounded-full w-16 transition-transform duration-200 select-none [&>div]:!bg-transparent select-none"
|
||||
/>
|
||||
:
|
||||
<span className="block aspect-square w-16 rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
<div className="inline-block rtl:mr-3 ltr:ml-3 space-y-2">
|
||||
<span className="block w-full text-base font-extrabold text-secondary-light capitalize">{productTitle}</span>
|
||||
<span className="block w-full text-sm font-normal text-gray-500 capitalize">{venue_name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<ReservationDetail
|
||||
label={translate("billboard-product-reservation-details-type")}
|
||||
value={serviceTypes ? getLocaleTr(selectedServiceType, locale).title : "-"}
|
||||
icon={<CircleQuestionSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1`} />}
|
||||
/>
|
||||
<ReservationDetail
|
||||
label={translate("billboard-product-reservation-details-date")}
|
||||
value={mtd(selectedDate.getTime())}
|
||||
icon={<CalendarCheckSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1`} />}
|
||||
valueClass="[direction:ltr]"
|
||||
/>
|
||||
<ReservationDetail
|
||||
label={translate("billboard-product-reservation-details-time")}
|
||||
value={hasValue(selectedTime) ? `${selectedTime.from.slice(0, 5)} - ${selectedTime.to.slice(0, 5)}` : "-"}
|
||||
icon={<ClockSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1`} />}
|
||||
valueClass="[direction:ltr]"
|
||||
/>
|
||||
{serviceProvidersList.length > 0 && selectedTime && <ReservationDetail
|
||||
label={translate("billboard-reservation-service-duration")}
|
||||
value={`${selectedTime.session_duration} ${translate("minute")}`}
|
||||
icon={<HourGlassSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />}
|
||||
/>}
|
||||
{serviceProvidersList.length > 0 && <ReservationDetail
|
||||
label={translate("billboard-reservation-service-provider")}
|
||||
value={`${getLocaleTr(serviceProvidersList[selectedProvider], locale).first_name} ${getLocaleTr(serviceProvidersList[selectedProvider], locale).last_name}`}
|
||||
icon={<UserVneckHairSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />}
|
||||
/>}
|
||||
{getLocaleTr(selectedServiceType, locale).session_rules?.length > 0 && <ReservationDetail
|
||||
label={translate("billboard-product-reservation-details-rules")}
|
||||
value={getLocaleTr(selectedServiceType, locale).session_rules[0]}
|
||||
icon={<CircleExclamationSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />}
|
||||
/>}
|
||||
<ReservationDetail
|
||||
label={translate("billboard-product-reservation-price-title")}
|
||||
value={priceDetails.length > 0 ? priceDetails.map(x => `${getLocaleTr(serviceTicketTypes.filter(t => t.id === x.id)[0], locale).title} ${x.amount} ${translate("units-number")}`).join(" | ") : "-"}
|
||||
icon={<TicketSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />}
|
||||
/>
|
||||
{serviceProvidersList.length === 0 && <ReservationDetail
|
||||
label={translate("billboard-reservation-seat-selection-title")}
|
||||
value={selectedSeat.length > 0 ? selectedSeat.join(", ") : "-"}
|
||||
icon={<SeatAirlineSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />}
|
||||
/>}
|
||||
<ReservationDetail
|
||||
label={translate("billboard-product-reservation-price-total-price")}
|
||||
value={String(totalReservationPrice) + " " + getLocaleTr(priceUnit, locale).name}
|
||||
icon={<CircleDollarSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ReservationDetails
|
||||
@ -0,0 +1,192 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, safeClone, useGetRouter } from "services/general/general";
|
||||
import { CalendarXmarkSolid, CircleDollarSolid, MinusSolid, PlusSolid } from "components/icons";
|
||||
import { Currencies } from "common/types/general";
|
||||
import Input from "components/input/text";
|
||||
import { BillboardServiceType, ReservationTicketType, TicketTypePrice } from "common/types/billboard";
|
||||
import { ReservationDetail } from "./reservation-details";
|
||||
import { PriceDetailProps } from "./product-reservation";
|
||||
import { slotsProps } from "./services/reservation-services-with-providers";
|
||||
import { UUID } from "crypto";
|
||||
|
||||
interface TicketQuantityProps {
|
||||
id: UUID;
|
||||
count: number;
|
||||
}
|
||||
interface ReservationPriceProps {
|
||||
serviceTypes: BillboardServiceType[];
|
||||
selectedTime: slotsProps;
|
||||
selectedType: number;
|
||||
priceUnit: Currencies;
|
||||
selectedDate: Date;
|
||||
onSelect: (data: PriceDetailProps) => void;
|
||||
}
|
||||
interface ReservationPriceItemProps {
|
||||
locale: any;
|
||||
ticketTypes: ReservationTicketType[];
|
||||
price: TicketTypePrice[];
|
||||
priceUnit: Currencies;
|
||||
selectedDate: Date;
|
||||
selectedTime: slotsProps;
|
||||
className?: string;
|
||||
labelClass?: string;
|
||||
priceClass?: string;
|
||||
remainingCapacity: number;
|
||||
onSelect: (priceData: PriceDetailProps) => void;
|
||||
}
|
||||
|
||||
const ReservationPriceItem: React.FunctionComponent<ReservationPriceItemProps> = ({ locale, ticketTypes, price, priceUnit, selectedDate, selectedTime, className, labelClass, priceClass, remainingCapacity, onSelect }) => {
|
||||
|
||||
const initialTicketAmounts = ticketTypes.length > 1 ? 0 : 1;
|
||||
const initialOrderQuantity = ticketTypes.map(x => ({ id: x.id, count: initialTicketAmounts }));
|
||||
|
||||
// states
|
||||
const [orderQuantity, setOrderQuantity] = useState<TicketQuantityProps[]>(initialOrderQuantity);
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const orderTotalPrice = orderQuantity.reduce((total, x) => total + (price.filter(p => p.reservation_ticket_type_id === x.id)[0].price) * x.count, 0);
|
||||
|
||||
|
||||
// methods
|
||||
const getTicketPriceData = (id: UUID) => {
|
||||
return price.filter(x => x.reservation_ticket_type_id === id)[0];
|
||||
};
|
||||
const handleOrderQuantityUpdate = (data: TicketQuantityProps) => {
|
||||
const localOrderQuantity: TicketQuantityProps[] = safeClone(orderQuantity);
|
||||
const currentTicketIndex = localOrderQuantity.findIndex(x => x.id === data.id);
|
||||
localOrderQuantity.splice(currentTicketIndex, 1, data);
|
||||
setOrderQuantity(localOrderQuantity);
|
||||
}
|
||||
|
||||
const handleOrderQuantity = (id: UUID, value: number) => {
|
||||
if (value === 0) {
|
||||
handleOrderQuantityUpdate({ id: id, count: initialTicketAmounts });
|
||||
onSelect({
|
||||
id: id,
|
||||
price: getTicketPriceData(id).price,
|
||||
discount_price: getTicketPriceData(id).discount_price ?? -1,
|
||||
amount: initialTicketAmounts
|
||||
});
|
||||
} else {
|
||||
handleOrderQuantityUpdate({ id: id, count: value });
|
||||
onSelect({
|
||||
id: id,
|
||||
price: value * getTicketPriceData(id).price,
|
||||
discount_price: getTicketPriceData(id).discount_price ?? -1,
|
||||
amount: value
|
||||
});
|
||||
}
|
||||
}
|
||||
const handleIncrease = (id: UUID) => {
|
||||
const currentOrderQuantity = orderQuantity.filter(x => x.id === id)[0].count;
|
||||
if (currentOrderQuantity < remainingCapacity) {
|
||||
handleOrderQuantityUpdate({ id: id, count: currentOrderQuantity + 1 });
|
||||
onSelect({
|
||||
id: id,
|
||||
price: (currentOrderQuantity + 1) * getTicketPriceData(id).price,
|
||||
discount_price: getTicketPriceData(id).discount_price ?? -1,
|
||||
amount: currentOrderQuantity + 1
|
||||
});
|
||||
}
|
||||
}
|
||||
const handleDecrease = (id: UUID) => {
|
||||
const currentOrderQuantity = orderQuantity.filter(x => x.id === id)[0].count;
|
||||
if (currentOrderQuantity > (ticketTypes.length > 1 ? 0 : 1)) {
|
||||
handleOrderQuantityUpdate({ id: id, count: currentOrderQuantity - 1 });
|
||||
onSelect({
|
||||
id: id,
|
||||
price: (currentOrderQuantity - 1) * getTicketPriceData(id).price,
|
||||
discount_price: getTicketPriceData(id).discount_price ?? -1,
|
||||
amount: currentOrderQuantity - 1
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// reset order quantity on date change
|
||||
useEffect(() => {
|
||||
setOrderQuantity(initialOrderQuantity);
|
||||
}, [selectedDate, selectedTime])
|
||||
|
||||
return <div className={`flex flex-col rtl:space-x-reverse text-secondary-light ${className}`}>
|
||||
<div className="flex items-center pb-5">
|
||||
<span className={`inline-block text-sm sm:text-sm text-secondary-light capitalize text-current font-extrabold ${labelClass}`}>{translate("billboard-product-reservation-price-total-price")} :</span>
|
||||
<span className={`inline-block text-sm sm:text-sm text-secondary-light capitalize px-2 ${priceClass}`}>
|
||||
{`${orderTotalPrice} ${getLocaleTr(priceUnit, locale).name}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
{ticketTypes.map(x => (
|
||||
<div key={x.id} className="flex items-center justify-between gap-4 w-full">
|
||||
<span className="text-sm">{`${getLocaleTr(x, locale).title} - ${getTicketPriceData(x.id).price} ${getLocaleTr(priceUnit, locale).name}`}</span>
|
||||
<div className="flex items-center justify-between py-1 px-2 rounded-full ring-1 ring-gray-200/75 text-[var(--brand-color)] ltr:flex-row-reverse space-x-2 space-x-reverse">
|
||||
<PlusSolid className="reactive-button inline-block size-7 fill-current p-[6px] rounded-full" onClick={() => handleIncrease(x.id)} />
|
||||
<Input
|
||||
type="number"
|
||||
min={initialTicketAmounts}
|
||||
max={remainingCapacity}
|
||||
value={orderQuantity.filter(q => q.id === x.id)[0].count}
|
||||
className="inline-block bg-gray-50 text-secondary-light text-base font-extrabold select-none w-12 outline-none text-center rounded"
|
||||
onInput={(value) => Number(value) > remainingCapacity ? handleOrderQuantity(x.id, remainingCapacity) : handleOrderQuantity(x.id, Number(value))}
|
||||
/>
|
||||
<MinusSolid className="reactive-button inline-block size-7 fill-current p-[6px] rounded-full" onClick={() =>handleDecrease(x.id)} />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
const ReservationPrice: React.FunctionComponent<ReservationPriceProps> = ({ serviceTypes, selectedTime, selectedType, priceUnit, selectedDate, onSelect }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
const ticketTypes = serviceTypes.filter(x => x.service_id === selectedType)[0].ticket_types.map(x => x.reservation_ticket_type_id);
|
||||
|
||||
// methods
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center w-full justify-between mt-4">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-2">{translate("billboard-product-reservation-price-title")}</span>
|
||||
</div>
|
||||
<div className="block w-full border border-gray-200 rounded-xl p-4 mt-2 space-y-2">
|
||||
{selectedTime &&
|
||||
<ReservationPriceItem
|
||||
locale={locale}
|
||||
ticketTypes={ticketTypes}
|
||||
price={selectedTime.ticketTypesPrice}
|
||||
priceUnit={priceUnit}
|
||||
remainingCapacity={selectedTime.capacity}
|
||||
selectedDate={selectedDate}
|
||||
selectedTime={selectedTime}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
}
|
||||
|
||||
{/* cancellation & refund policies */}
|
||||
<div className="block w-full !mt-4 pt-3 space-y-1 border-t border-dashed border-gray-200/75">
|
||||
<ReservationDetail
|
||||
label={translate("billboard-reservation-free-cancellation")}
|
||||
value={serviceTypes.filter(x => x.service_id === selectedType)[0]?.free_cancellation ? translate("logic-yes") : translate("logic-no")}
|
||||
icon={<CalendarXmarkSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1 shrink-0`} />}
|
||||
labelClass=""
|
||||
/>
|
||||
<ReservationDetail
|
||||
label={translate("billboard-reservation-refundable")}
|
||||
value={serviceTypes.filter(x => x.service_id === selectedType)[0]?.refundable ? translate("logic-yes") : translate("logic-no")}
|
||||
icon={<CircleDollarSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1 shrink-0`} />}
|
||||
labelClass=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ReservationPrice
|
||||
@ -0,0 +1,122 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, useGetRouter } from "services/general/general";
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, BillboardServiceType, ProductVariantGalleries } from "common/types/billboard";
|
||||
import { PriceDataProps } from "pages/api/billboard/product-price-data";
|
||||
import ProductGallery from "../product-gallery";
|
||||
import dynamic from "next/dynamic";
|
||||
import ProductBreadcrumbDesktop from "../product-breadcrumb-desktop";
|
||||
import Parser from "components/parser/parser";
|
||||
import ProductSection from "../product-section";
|
||||
import ProductFeature, { featureIconClass } from "../product-feature";
|
||||
import { CaretLeftSolid, CircleExclamationSolid, PhoneSolid } from "components/icons";
|
||||
import ProductReservationHeader from "./product-reservation-header";
|
||||
const ProductReservation = dynamic(() => import("./product-reservation"), { ssr: false })
|
||||
|
||||
interface ReservationProductsDetailsProps {
|
||||
billboard: Billboard;
|
||||
product: BillboardProduct;
|
||||
priceData: PriceDataProps[];
|
||||
ratings: any;
|
||||
serviceTypes: BillboardServiceType[];
|
||||
categories: BillboardProductsCategory[];
|
||||
thumbs: ProductVariantGalleries[];
|
||||
}
|
||||
|
||||
const ReservationProductsDetails: React.FunctionComponent<ReservationProductsDetailsProps> = ({ billboard, product, priceData, ratings, serviceTypes, categories, thumbs }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
const gallery = thumbs[0].gallery;
|
||||
|
||||
// methods
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(serviceTypes) ?
|
||||
<div className="flex flex-col lg:flex-row lg:space-x-4 rtl:space-x-reverse items-center lg:items-start w-full bg-white rounded-md shrink-0 justify-center lg:px-2 lg:py-8">
|
||||
<ProductGallery
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
galleryItems={gallery}
|
||||
/>
|
||||
<div className="bg-white block w-full lg:w-1/2 px-6 lg:px-4 pt-4 pb-6 lg:pt-0 border-t-[4px] border-gray-50 lg:border-t-0 border-b lg:border-b-0 border-b-gray-100">
|
||||
{product.category && <ProductBreadcrumbDesktop productCategory={product.category} parentCat={categories.filter(x => x.local_id === product.category.parent[0])[0]} billboardId={billboard.id} billboardTitle={billboardTitle} />}
|
||||
<ProductReservationHeader
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
isRatingsAvailable={ratings ? true : false}
|
||||
ratings={ratings}
|
||||
productDetails={{
|
||||
type: product.type,
|
||||
price: priceData[0].price,
|
||||
discount_price: priceData[0].discounted_price,
|
||||
priceSign: priceData[0].currency.sign,
|
||||
reservationPrice: serviceTypes ? serviceTypes[0]?.ticket_types[0].price : -1,
|
||||
reservationDiscountedPrice: serviceTypes ? serviceTypes[0]?.ticket_types[0].discount_price : -1
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* product desccription */}
|
||||
{getLocaleTr(product, locale).description && <ProductSection id="product-description" title={translate("billboard-product-description")} className="pt-4 mt-6 border-t border-dashed border-gray-200/75">
|
||||
<Parser limit limitLength={800} text={getLocaleTr(product, locale).description} className="[&_*]:lg:!text-sm/7" />
|
||||
</ProductSection>}
|
||||
|
||||
{/* product features */}
|
||||
<ProductSection id="product-features" title={translate("billboard-product-features")} className="pt-4">
|
||||
{product.brand && <ProductFeature label={translate("brand")} value={`${locale === "en" ? product.brand.english_name : product.brand.persian_name}`} icon={<CaretLeftSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{product.code && <ProductFeature label={translate("code")} value={`${product.code}`} icon={<CaretLeftSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{product.type === "reservation" && getLocaleTr(product, locale).venue_name && <ProductFeature label={translate("billboard-products-reservation-venue-name")} value={`${getLocaleTr(product, locale).venue_name}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{product.type === "reservation" && getLocaleTr(product, locale).venue_address && <ProductFeature label={translate("billboard-products-reservation-venue-address")} value={`${getLocaleTr(product, locale).venue_address}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />}
|
||||
{product.type === "reservation" && getLocaleTr(product, locale).custom_property && getLocaleTr(product, locale).custom_property.map((x: any) => (
|
||||
<ProductFeature key={x.label} label={x.label} value={`${x.value}`} valueClass="" icon={<CaretLeftSolid className={featureIconClass} />} />
|
||||
))}
|
||||
</ProductSection>
|
||||
|
||||
{/* event rules */}
|
||||
{getLocaleTr(product, locale).event_rules && <ProductSection id="event-rules" title={translate("billboard-product-reservation-rules-title")} className="mt-6 pt-4 pb-2 border-t border-dashed border-gray-200/75">
|
||||
<div className="block w-full space-y-2">
|
||||
{getLocaleTr(product, locale).event_rules.map((x: any, i: number) => (
|
||||
<div key={i} className="block w-full text-sm/6 text-secondary-light">
|
||||
<CircleExclamationSolid className={`inline-block size-3 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />
|
||||
{x.rule}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ProductSection>}
|
||||
|
||||
{/* reservation */}
|
||||
<ProductReservation
|
||||
productTitle={getLocaleTr(product, locale).title}
|
||||
productId={product.id}
|
||||
billboardId={billboard.id}
|
||||
venue_name={getLocaleTr(product, locale).venue_name}
|
||||
priceUnit={product.price_currency}
|
||||
thumb={thumbs[0].gallery[0].directus_files_id}
|
||||
startDate={product.reservation_start_date}
|
||||
endDate={product.reservation_end_date}
|
||||
billboardColor={billboard.brand_color}
|
||||
seatsData={billboard.seats_data}
|
||||
/>
|
||||
|
||||
{/* gallery */}
|
||||
{product.type === "gallery" && <>
|
||||
<a href={`tel:${billboard.phone_number}`} className="reactive-button block w-full capitalize py-3 px-4 mt-10 rounded-lg bg-[var(--brand-color)] text-white font-semibold text-sm/6 text-center">
|
||||
<PhoneSolid className={`inline-block size-4 xl:size-4 shrink-0 rtl:ml-3 ltr:mr-3 fill-current`} />
|
||||
{translate("billboard-page-gallery-type-cta")}
|
||||
</a>
|
||||
</>}
|
||||
</div>
|
||||
</div>
|
||||
:
|
||||
<span>loading...</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
)
|
||||
}
|
||||
export default ReservationProductsDetails
|
||||
@ -0,0 +1,108 @@
|
||||
export const reservationsQuery = (productId: string) => `
|
||||
billboard_reservations (filter: { status: { _eq: "published" }, product_item: { id: { _eq: "${productId}" }} }) {
|
||||
id
|
||||
service_id
|
||||
session_type_id
|
||||
service_providers {
|
||||
service_provider_id {
|
||||
provider_id
|
||||
}
|
||||
}
|
||||
date
|
||||
start_time
|
||||
end_time
|
||||
ticket_data
|
||||
}
|
||||
`
|
||||
export const serviceProvidersQuery = (billboardId: string) => `
|
||||
service_provider (filter: { status: { _eq: "published" }, billboards: { Billboards_id: { id: { _eq: "${billboardId}" }}} }) {
|
||||
id
|
||||
date_created
|
||||
provider_id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
first_name
|
||||
last_name
|
||||
about_me
|
||||
}
|
||||
profile_pic {
|
||||
id
|
||||
description
|
||||
filename_download
|
||||
width
|
||||
height
|
||||
}
|
||||
birth_date
|
||||
billboards {
|
||||
Billboards_id {
|
||||
id
|
||||
}
|
||||
working_days
|
||||
special_dates
|
||||
busy
|
||||
day_start_buffer
|
||||
general_gap
|
||||
}
|
||||
services {
|
||||
id
|
||||
billboard_service_types_id {
|
||||
service_id
|
||||
}
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
details
|
||||
}
|
||||
capacity
|
||||
duration
|
||||
gap
|
||||
ticket_types
|
||||
}
|
||||
}
|
||||
`
|
||||
export const serviceTypesQuery = (productId: string) => `
|
||||
billboard_service_types (filter: { status: { _eq: "published" }, service: { id: { _eq: "${productId}" }} }) {
|
||||
id
|
||||
service_id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
title
|
||||
description
|
||||
}
|
||||
profile_pic {
|
||||
id
|
||||
description
|
||||
filename_download
|
||||
width
|
||||
height
|
||||
}
|
||||
service {
|
||||
id
|
||||
}
|
||||
category
|
||||
session_capacity
|
||||
session_duration
|
||||
gap
|
||||
ticket_types {
|
||||
reservation_ticket_type_id {
|
||||
id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
price
|
||||
discount_price
|
||||
}
|
||||
dates_type
|
||||
service_dates
|
||||
}
|
||||
`
|
||||
@ -0,0 +1,127 @@
|
||||
import React, { useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { hasValue, useGetRouter } from "services/general/general";
|
||||
import { CaretLeftSolid, CircleExclamationSolid, ImageSharpSolid } from "components/icons";
|
||||
import Modal from "components/modal/modal";
|
||||
import Image from "components/image/image";
|
||||
|
||||
interface SeatSelectionProps {
|
||||
seatsData: {
|
||||
id: string;
|
||||
directus_files_id: {
|
||||
id: string;
|
||||
description: string;
|
||||
filename_download: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
persian_name: string;
|
||||
english_name: string;
|
||||
types: {
|
||||
id: number;
|
||||
seats_numbers: string[];
|
||||
}[];
|
||||
}[];
|
||||
selectedTime: any;
|
||||
currecntSeatsMap: {
|
||||
id: string;
|
||||
directus_files_id: {
|
||||
id: string;
|
||||
description: string;
|
||||
filename_download: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
persian_name: string;
|
||||
english_name: string;
|
||||
types: {
|
||||
id: number;
|
||||
seats_numbers: string[];
|
||||
}[];
|
||||
} | null;
|
||||
selectedType: number;
|
||||
productTitle: string;
|
||||
selectedSeat: string[];
|
||||
onSelect: (seat: string) => void;
|
||||
}
|
||||
|
||||
|
||||
const SeatSelection: React.FunctionComponent<SeatSelectionProps> = ({ seatsData, selectedTime, currecntSeatsMap, selectedType, productTitle, selectedSeat, onSelect }) => {
|
||||
|
||||
// states
|
||||
const [showSeatsMap, setShowSeatsMap] = useState<boolean>(false);
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center w-full justify-between mt-6">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-2">{translate("billboard-reservation-seat-selection-title")}</span>
|
||||
</div>
|
||||
<span className="block text-sm/6 mb-2 mt-3 font-semibold text-secondary-light">
|
||||
<CaretLeftSolid className={`inline-block size-3 sm:size-3 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1 shrink-0 ltr:rotate-180`} />
|
||||
{locale === "fa" ? currecntSeatsMap?.persian_name : currecntSeatsMap?.english_name}
|
||||
</span>
|
||||
<div className="grid grid-cols-6 sm:grid-cols-8 md:grid-cols-10 lg:grid-cols-6 xl:grid-cols-7 2xl:grid-cols-8 gap-x-2 gap-y-2 w-full px-[2px] py-4">
|
||||
{seatsData.filter(x => Number(x.id) === 1)[0]?.types.filter(x => x.id === selectedType)[0].seats_numbers.map((x, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`reactive-button flex flex-col items-center w-full relative rounded-full px-2 py-2 select-none shrink-0 lg:cursor-pointer border-2 border-white ring-1
|
||||
${selectedTime?.reservedSeats.includes(x) ? "pointer-events-none bg-error-bg border-white ring-error-bg text-error-text" : (selectedSeat.includes(x) ? "ring-2 ring-[var(--brand-color)] bg-[var(--brand-color)] text-white" : "bg-white text-secondary-light ring-gray-200 lg:hover:bg-[var(--light-brand-color)]")} `}
|
||||
onClick={() => onSelect(x)}
|
||||
>
|
||||
<span className={`block text-xs lg:text-sm font-semibold mt-2 mb-1 uppercase text-current`}>{x}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* real seats map */}
|
||||
<Modal
|
||||
header={false}
|
||||
wrapperId={`seats-map-modal`}
|
||||
open={showSeatsMap}
|
||||
onClose={() => setShowSeatsMap(false)}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[70vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded -top-[10%] lg:top-0"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
titleClass="text-lg"
|
||||
>
|
||||
{currecntSeatsMap && hasValue(currecntSeatsMap) ?
|
||||
<Image
|
||||
src={`${currecntSeatsMap.directus_files_id.id}/${currecntSeatsMap.directus_files_id.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={currecntSeatsMap.directus_files_id.width}
|
||||
height={currecntSeatsMap.directus_files_id.height}
|
||||
quality={100}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[400, 600, 750, 750]}
|
||||
priority={false}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className="rounded-none will-change-transform !object-contain"
|
||||
figureClass="rounded-none w-full sm:w-[calc(100vw/3)] mx-auto transition-transform duration-200 select-none"
|
||||
/>
|
||||
:
|
||||
<span className="block aspect-square w-16 rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
</Modal>
|
||||
<div className="block w-full space-y-2 mt-4 mb-8">
|
||||
<p className="block text-sm/6 mb-4">
|
||||
<CircleExclamationSolid className={`inline-block size-4 sm:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2 shrink-0`} />
|
||||
{translate("billboard-reservation-seat-selection-map-notice")}
|
||||
</p>
|
||||
<span className="reactive-button flex items-center justify-center w-max mx-auto text-xs sm:text-sm select-none text-[var(--brand-color)] capitalize font-semibold lg:cursor-pointer py-2 px-3 sm:px-4 rounded-lg bg-[var(--light-brand-color)]" onClick={() => setShowSeatsMap(true)}>
|
||||
{translate("billboard-reservation-seat-selection-map-cta")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default SeatSelection
|
||||
@ -0,0 +1,75 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, hasValue, useGetRouter } from "services/general/general";
|
||||
import Image from "components/image/image";
|
||||
import { CircleCheckSolid, ImageSharpSolid } from "components/icons";
|
||||
import InnerLoading from "components/loading/inner-loading";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
|
||||
interface ServiceProvidersProps {
|
||||
serviceProvidersList: ServiceProvider[];
|
||||
selectedProvider: number;
|
||||
productTitle: string;
|
||||
billboardColor: string;
|
||||
onSelect: (id: number) => void;
|
||||
}
|
||||
|
||||
|
||||
const ServiceProviders: React.FunctionComponent<ServiceProvidersProps> = ({ serviceProvidersList, selectedProvider, productTitle, billboardColor, onSelect }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center w-full justify-between mt-2">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-2">{translate("billboard-service-providers-title")}</span>
|
||||
</div>
|
||||
{serviceProvidersList.length > 0 ?
|
||||
<div className="grid grid-cols-3 md:grid-cols-4 gap-x-2 gap-y-2 w-full px-[2px] py-4 mb-2">
|
||||
{serviceProvidersList.map((x, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex flex-col items-center justify-center w-full relative rounded-lg px-2 py-2 shrink-0 lg:cursor-pointer bg-white ${i === selectedProvider ? "" : ""}`}
|
||||
onClick={() => onSelect(i)}
|
||||
>
|
||||
<CircleCheckSolid className={`${i === selectedProvider ? "inline-block" : "hidden"} absolute top-0 right-5 -translate-y-1/2 translate-x-1/2 bg-white rounded-full size-5 fill-[var(--brand-color)]`} />
|
||||
{hasValue(x.profile_pic) ?
|
||||
<Image
|
||||
src={`${x.profile_pic.id}/${x.profile_pic.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={x.profile_pic.width}
|
||||
height={x.profile_pic.height}
|
||||
quality={100}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[100, 250, 250, 250]}
|
||||
priority={false}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className={`rounded-full will-change-transform !object-cover border-4 ${i === selectedProvider ? "border-[var(--brand-color)]" : "border-gray-100"} `}
|
||||
figureClass="rounded-full w-24 transition-transform duration-200 select-none [&>div]:!bg-transparent"
|
||||
/>
|
||||
:
|
||||
<span className="block aspect-square w-16 rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
<span className={`block text-base lg:text-base font-semibold capitalize mt-3 ${i === selectedProvider ? "text-[var(--brand-color)]" : "text-secondary-light"}`}>{getLocaleTr(serviceProvidersList[i], locale).first_name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
:
|
||||
<InnerLoading loadingText={""} width={"200"} height={"100"} color={billboardColor} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ServiceProviders
|
||||
@ -0,0 +1,139 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, stripHtml, useGetRouter } from "services/general/general";
|
||||
import { ArrowTurnDownLeftSolid, CaretLeftSolid, CircleDotSolid, CircleSolid } from "components/icons";
|
||||
import InnerLoading from "components/loading/inner-loading";
|
||||
import { BillboardServiceType } from "common/types/billboard";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
import { withProviderDuration, withProviderPrice } from "./services/reservation-services-with-providers";
|
||||
import { providerSharedData } from "./services/providers-shared-data";
|
||||
import { Currencies } from "common/types/general";
|
||||
|
||||
interface ServiceTypesProps {
|
||||
serviceTypesList: BillboardServiceType[];
|
||||
serviceProvidersList: ServiceProvider[];
|
||||
selectedProvider: number;
|
||||
selectedType: number;
|
||||
billboardColor: string;
|
||||
selectedDate: Date;
|
||||
billboardId: string;
|
||||
priceUnit: Currencies;
|
||||
onSelect: (serviceType: number) => void;
|
||||
}
|
||||
|
||||
|
||||
const ServiceTypes: React.FunctionComponent<ServiceTypesProps> = ({ serviceTypesList, serviceProvidersList, selectedProvider, selectedType, billboardColor, selectedDate, billboardId, priceUnit, onSelect }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
const priceCurrency = getLocaleTr(priceUnit, locale).name;
|
||||
|
||||
// methods
|
||||
const providerPrice = ({ service }: any) => withProviderPrice({
|
||||
sharedData: (providerSharedData as any)({
|
||||
date: selectedDate,
|
||||
provider: serviceProvidersList[selectedProvider],
|
||||
service: service,
|
||||
billboardId: billboardId,
|
||||
reservations: [],
|
||||
}),
|
||||
service: service,
|
||||
});
|
||||
const providerTicketPrice = (service: BillboardServiceType, ticket: any) => {
|
||||
return providerPrice({ service: service }).price.filter(p => p.reservation_ticket_type_id === ticket.reservation_ticket_type_id.id)[0];
|
||||
};
|
||||
const ticketPrice = (service: BillboardServiceType, ticket: any) => {
|
||||
return providerPrice({ service: service }).price.filter(p => p.reservation_ticket_type_id === ticket.reservation_ticket_type_id.id)[0];
|
||||
};
|
||||
const providerDuration = ({ service }: any) => withProviderDuration(
|
||||
(providerSharedData as any)({
|
||||
date: selectedDate,
|
||||
provider: serviceProvidersList[selectedProvider],
|
||||
service: service,
|
||||
billboardId: billboardId,
|
||||
reservations: [],
|
||||
})
|
||||
);
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* service types */}
|
||||
{(serviceTypesList.length > 0) ?
|
||||
<>
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-2">{translate("billboard-product-reservation-available-types")}</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 w-full px-[2px] mb-4 mt-2">
|
||||
{serviceTypesList
|
||||
.filter(x => serviceProvidersList.length > 0 ? serviceProvidersList[selectedProvider].services.some(y => y.billboard_service_types_id.service_id === x.service_id) : x)
|
||||
.map((x, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`${(serviceProvidersList.length === 0 && !x.service_dates) ? "hidden" : "flex"} flex-col w-full relative sm:px-4 py-3 border-b border-dashed border-gray-200/75 last:border-0 shrink-0`}
|
||||
>
|
||||
{/* category */}
|
||||
{/* <div className={`block`}>
|
||||
<CaretLeftSolid className={`inline-block size-3 fill-secondary-light rtl:ml-1 ltr:mr-1 ltr:rotate-180`} />
|
||||
<span className={`inline-block text-sm lg:text-sm font-semibold capitalize text-secondary-light`}>{getLocaleTr(x, locale).title}</span>
|
||||
</div> */}
|
||||
<div className="flex flex-col mt-2 lg:cursor-pointer transition-all [&_svg]:hover:fill-info-bg" onClick={() => onSelect(x.service_id)}>
|
||||
<div className="flex items-center mb-4">
|
||||
{x.service_id === selectedType ?
|
||||
<CircleDotSolid className={`inline-block rounded-full size-4 !fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2`} />
|
||||
:
|
||||
<CircleSolid className={`inline-block rounded-full size-4 fill-gray-100 rtl:ml-2 ltr:mr-2`} />
|
||||
}
|
||||
<span className={`inline-block text-sm capitalize`}>{getLocaleTr(x, locale).title}</span>
|
||||
{serviceProvidersList.length > 0 && <>
|
||||
<span className="inline-block px-[6px]">-</span>
|
||||
<span className={`inline-block text-sm capitalize`}>{`${providerDuration({ service: x }) / 60000} ${translate("minute")}`}</span>
|
||||
</>}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{x.ticket_types.map(t => (
|
||||
<div key={t.reservation_ticket_type_id.id} className="flex items-center w-full gap-[2px] ps-2">
|
||||
<ArrowTurnDownLeftSolid className={`inline-block size-3 !fill-[var(--brand-color)] me-2`} />
|
||||
{x.ticket_types.length > 1 &&
|
||||
<>
|
||||
<span className={`inline-block text-sm capitalize`}>{getLocaleTr(t.reservation_ticket_type_id, locale).title}</span>
|
||||
<span className="inline-block px-[6px]">-</span>
|
||||
</>
|
||||
}
|
||||
<span className={`inline-block text-sm capitalize`}>
|
||||
{serviceProvidersList.length > 0 ?
|
||||
`${providerTicketPrice(x, t).discount_price ?? providerTicketPrice(x, t).price} ${priceCurrency}`
|
||||
:
|
||||
`${t.discount_price ?? t.price} ${priceCurrency}`
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
:
|
||||
<InnerLoading loadingText={""} width={"200"} height={"100"} color={billboardColor} />
|
||||
}
|
||||
|
||||
{/* service type info */}
|
||||
{getLocaleTr(serviceTypesList.filter(x => x.service_id === selectedType)[0], locale).description && <>
|
||||
<div className="flex items-center w-full justify-between mt-2">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-4">{translate("billboard-product-reservation-available-types-info")}</span>
|
||||
</div>
|
||||
<div className="block w-full text-sm/6 text-secondary-light">
|
||||
{/* <CaretLeftSolid className={`inline-block size-3 fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1 ltr:rotate-180`} /> */}
|
||||
{stripHtml(getLocaleTr(serviceTypesList.filter(x => x.service_id === selectedType)[0], locale).description)}
|
||||
</div>
|
||||
</>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ServiceTypes
|
||||
@ -0,0 +1,79 @@
|
||||
import { BillboardReservation, BillboardServiceType } from "common/types/billboard";
|
||||
import { ServiceProvider, ServiceProviderBillboards, ServiceProviderDateType, ServiceProviderServices } from "common/types/service-provider";
|
||||
import { getWeekDay, htm } from "services/general/general";
|
||||
|
||||
interface withProviderSharedProps {
|
||||
date: Date;
|
||||
provider: ServiceProvider;
|
||||
service: BillboardServiceType;
|
||||
billboardId: string;
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
interface getProvidersSharedDataProps {
|
||||
serviceTypesList: BillboardServiceType[];
|
||||
serviceProvidersList: ServiceProvider[];
|
||||
selectedDate: Date;
|
||||
selectedType: number;
|
||||
selectedProvider: number;
|
||||
billboardId: string;
|
||||
}
|
||||
export interface providerSharedDataProps {
|
||||
selectedDate: ServiceProviderDateType;
|
||||
specificDate: ServiceProviderDateType;
|
||||
providerBillboard: ServiceProviderBillboards;
|
||||
providerService: ServiceProviderServices;
|
||||
provider: ServiceProvider;
|
||||
dayStart: number;
|
||||
dayEnd: number;
|
||||
minute: number;
|
||||
date: Date;
|
||||
weekday: string;
|
||||
service: BillboardServiceType;
|
||||
}
|
||||
|
||||
export const providerSharedData = ({ date, provider, service, billboardId }: withProviderSharedProps) => {
|
||||
const weekday = getWeekDay(date.getDay());
|
||||
const minute = 60 * 1000 // one min = 60,000 milliseconds
|
||||
|
||||
const providerBillboard = provider.billboards.filter(x => x.Billboards_id.id === billboardId)[0];
|
||||
const providerService = provider.services.filter(x => x.billboard_service_types_id.service_id === service.service_id)[0];
|
||||
|
||||
const selectedDate = providerBillboard.working_days.filter(x => x.weekday.toLowerCase() === weekday)[0];
|
||||
const specificDate = providerBillboard.special_dates?.filter(x => Date.parse(x.date) === date.getTime())[0];
|
||||
|
||||
const start = selectedDate.from ? htm(selectedDate.from) : 0;
|
||||
const finish = selectedDate.to ? htm(selectedDate.to) : 0;
|
||||
|
||||
let sharedData: providerSharedDataProps = {
|
||||
selectedDate: selectedDate, // provider's selected date (weekly) data
|
||||
specificDate: specificDate, // provider's selected date (specific) data
|
||||
providerBillboard: providerBillboard, // provider's billboard data
|
||||
providerService: providerService, // provider's offered services data
|
||||
provider: provider,
|
||||
dayStart: start, // selected date's starting hour
|
||||
dayEnd: finish, // selected date's finish hour
|
||||
minute: minute, // in milliseconds
|
||||
date: date, // current selected date's weekday
|
||||
weekday: weekday, // current selected date's weekday
|
||||
service: service,
|
||||
};
|
||||
|
||||
return sharedData;
|
||||
}
|
||||
|
||||
// returns all the dates available for reservation for this service type on the selected date; takes special dates like holidays or any other diff dates into account
|
||||
export const getProvidersSharedData = ({ serviceTypesList, serviceProvidersList, selectedType, selectedDate, selectedProvider, billboardId }: getProvidersSharedDataProps) => {
|
||||
|
||||
// if no service types exist
|
||||
if (serviceTypesList.length === 0 || serviceProvidersList.length === 0) return [];
|
||||
|
||||
return providerSharedData({
|
||||
date: selectedDate,
|
||||
provider: serviceProvidersList[selectedProvider],
|
||||
service: serviceTypesList.filter(x => x.service_id === selectedType)[0],
|
||||
billboardId: billboardId,
|
||||
reservations: [],
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,119 @@
|
||||
import { BillboardReservation, BillboardServiceType, TicketTypePrice } from "common/types/billboard";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
import { getDatesBetween, getWeekDay, hasValue } from "services/general/general";
|
||||
import { slotsProps } from "./reservation-services-with-providers";
|
||||
|
||||
interface getReservationDatesProps {
|
||||
startDate: string; // service reservations period start date
|
||||
endDate: string; // service reservations period end date
|
||||
reservations: BillboardReservation[]; // all reservations for all service types
|
||||
serviceTypesList: BillboardServiceType[]; // service types for this service
|
||||
serviceProvidersList: ServiceProvider[]; // all service providers for this service
|
||||
selectedType: number; // selected service types for this service
|
||||
locale: string | undefined;
|
||||
}
|
||||
|
||||
interface getReservationDataProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
serviceTypes: BillboardServiceType[];
|
||||
reservations: BillboardReservation[];
|
||||
selectedType: number;
|
||||
locale: any;
|
||||
}
|
||||
|
||||
export interface DatesProps {
|
||||
date: Date;
|
||||
weekday: "Saturday" | "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Working days" | "Weekend" | "Everyday";
|
||||
sessions_data: slotsProps[];
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
// returns all the dates available for reservation for this service type on the selected date;
|
||||
// takes special dates like holidays or any other diff dates into account
|
||||
export const getReservationData = ({ startDate, endDate, serviceTypes, selectedType, reservations, locale }: getReservationDataProps) => {
|
||||
|
||||
const selectedServiceType = serviceTypes.filter(x => x.service_id === selectedType)[0];
|
||||
const dates = selectedServiceType.service_dates;
|
||||
const specialDates = dates?.filter(x => hasValue(x.date));
|
||||
const isDynamic = selectedServiceType.dates_type === "dynamic";
|
||||
const datesBetween = getDatesBetween(startDate, endDate);
|
||||
const eventDates: any[] = [];
|
||||
const finalDates: any[] = [];
|
||||
|
||||
if (isDynamic) {
|
||||
if (specialDates.length > 0) {
|
||||
eventDates.map(x => specialDates.map(y => Date.parse(y.date) === Date.parse(x.date) ?
|
||||
finalDates.push({
|
||||
date: new Date(y.date),
|
||||
weekday: getWeekDay(new Date(y.date).getDay()),
|
||||
sessions_data: y.sessions_data,
|
||||
closed: y.closed
|
||||
}) : finalDates.push(x)));
|
||||
} else {
|
||||
eventDates.map(x => finalDates.push(x));
|
||||
}
|
||||
} else {
|
||||
datesBetween.map(x => {
|
||||
eventDates.push({
|
||||
date: x,
|
||||
weekday: getWeekDay(x.getDay()),
|
||||
sessions_data: dates.filter(y => y.weekday === getWeekDay(x.getDay()))[0]?.sessions_data ?? [],
|
||||
closed: dates.filter(y => y.weekday === getWeekDay(x.getDay()))[0]?.closed ?? false
|
||||
});
|
||||
});
|
||||
if (specialDates.length > 0) { // taking special dates into account (might be closed / operate on diff hours)
|
||||
eventDates.map(x => specialDates.map(y => Date.parse(y.date) === Date.parse(x.date) ?
|
||||
finalDates.push({
|
||||
date: new Date(y.date),
|
||||
weekday: getWeekDay(new Date(y.date).getDay()),
|
||||
sessions_data: y.sessions_data,
|
||||
closed: y.closed
|
||||
}) : finalDates.push(x)));
|
||||
} else {
|
||||
eventDates.map(x => finalDates.push(x));
|
||||
}
|
||||
}
|
||||
|
||||
let completeFinalDates: DatesProps[] = finalDates.map(x => x.closed ? x : {
|
||||
...x, sessions_data:
|
||||
x.sessions_data.map((y: any) => y = {
|
||||
from: y.from,
|
||||
to: y.to,
|
||||
capacity: y.capacity ?? selectedServiceType.session_capacity,
|
||||
session_duration: -1,
|
||||
gap: y.gap ?? selectedServiceType.gap,
|
||||
ticketTypesPrice: y.ticket_types ?? selectedServiceType.ticket_types.map(t => ({
|
||||
reservation_ticket_type_id: t.reservation_ticket_type_id.id,
|
||||
price: t.price,
|
||||
discount_price: t.discount_price
|
||||
})),
|
||||
seats_map: y.seats_map ? y.seats_map : 1,
|
||||
details: locale === "fa" ? y.persian_details : y.english_details,
|
||||
reserved: reservations.length > 0 ? reservations.filter(z => (Date.parse(z.date) === Date.parse(x.date) && z.start_time === y.from)).length : 0,
|
||||
reservedSeats: reservations.length > 0 ? reservations.filter(z => (Date.parse(z.date) === Date.parse(x.date) && z.start_time === y.from)).map(x => x.ticket_data).map(x => x.map(y => y.seat_number)).flat() : []
|
||||
})
|
||||
});
|
||||
|
||||
return completeFinalDates;
|
||||
}
|
||||
|
||||
|
||||
export const getReservationDates = ({ startDate, endDate, reservations, serviceTypesList, serviceProvidersList, selectedType, locale }: getReservationDatesProps) => {
|
||||
|
||||
// if no service types exist
|
||||
if (serviceTypesList.length === 0 || serviceProvidersList.length > 0) return [];
|
||||
|
||||
else {
|
||||
return getReservationData({
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
reservations: reservations ?? [],
|
||||
serviceTypes: serviceTypesList ?? [],
|
||||
selectedType: selectedType,
|
||||
locale: locale
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,255 @@
|
||||
import { BillboardReservation, BillboardServiceType, TicketTypePrice } from "common/types/billboard";
|
||||
import { getDateHours, htm, mtd } from "services/general/general";
|
||||
import { providerSharedDataProps } from "./providers-shared-data";
|
||||
|
||||
interface getRealTimeReservationDataProps {
|
||||
service: BillboardServiceType;
|
||||
sharedData: providerSharedDataProps;
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
export interface slotsProps {
|
||||
from: string;
|
||||
to: string;
|
||||
capacity: number;
|
||||
session_duration: number;
|
||||
gap: number;
|
||||
ticketTypesPrice: TicketTypePrice[];
|
||||
reserved: number;
|
||||
reservedSeats: string[];
|
||||
seats_map: number;
|
||||
details: string;
|
||||
}
|
||||
interface restHoursProps {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
interface withProviderPriceProps {
|
||||
sharedData: providerSharedDataProps;
|
||||
service: BillboardServiceType;
|
||||
}
|
||||
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderPrice = ({ sharedData, service }: withProviderPriceProps) => {
|
||||
|
||||
// price
|
||||
const specificDayPrice = sharedData.specificDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.ticket_types;
|
||||
const workDayPrice = sharedData.selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.ticket_types;
|
||||
const providerPrice = sharedData.providerService?.ticket_types;
|
||||
const servicePrice = service.ticket_types.map(t => ({
|
||||
reservation_ticket_type_id: t.reservation_ticket_type_id.id,
|
||||
price: t.price,
|
||||
discount_price: t.discount_price
|
||||
}));
|
||||
|
||||
const price = (
|
||||
specificDayPrice ?? // price set for that particular service for that specific date by the provider
|
||||
workDayPrice ?? // price set for that particular service for that day of the week by the provider (repeats weekly)
|
||||
providerPrice ?? // price set for that particular service by the provider
|
||||
servicePrice // price set for that particular service by the billboard
|
||||
);
|
||||
|
||||
return { price: price };
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderGaps = (sharedData: providerSharedDataProps) => {
|
||||
const service = sharedData.service;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const providerService = sharedData.providerService;
|
||||
const providerBillboard = sharedData.providerBillboard;
|
||||
|
||||
const minute = sharedData.minute;
|
||||
|
||||
// gaps
|
||||
const serviceWorkDayGap = selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.gap;
|
||||
const serviceSpecificDayGap = specialDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.gap;
|
||||
const workDayGap = selectedDate.today_gap;
|
||||
const specificDayGap = specialDate?.today_gap;
|
||||
|
||||
const gap = (
|
||||
serviceSpecificDayGap ?? // gap set by the provider for that service in that specific date
|
||||
serviceWorkDayGap ?? // gap set by the provider for that service in that day of the week (repeats weekly)
|
||||
specificDayGap ?? // gap set by the provider for all services in that specific date
|
||||
workDayGap ?? // gap set by the provider for all services in that day of the week
|
||||
providerService.gap ?? // gap set by the provider for that service (all type/subs affected)
|
||||
providerBillboard.general_gap ?? // general gap set by the provider for all the services performed at that billboard
|
||||
service.gap // general gap set for that service by the billboard
|
||||
) * minute;
|
||||
|
||||
return gap;
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderCapacity = (sharedData: providerSharedDataProps) => {
|
||||
const service = sharedData.service;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const providerService = sharedData.providerService;
|
||||
|
||||
// capacity
|
||||
const workDayCapacity = selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.capacity;
|
||||
const specificDayCapacity = specialDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.capacity;
|
||||
|
||||
const capacity = (
|
||||
specificDayCapacity ?? // capacity set for that particular service for that specific date by the provider
|
||||
workDayCapacity ?? // capacity set for that particular service for that day of the week by the provider (repeats weekly)
|
||||
providerService?.capacity ?? // capacity set for that particular service by the provider
|
||||
service.session_capacity // capacity set for that particular service by the billboard
|
||||
);
|
||||
|
||||
return capacity;
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderDuration = (sharedData: providerSharedDataProps) => {
|
||||
const serviceType = sharedData.service;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const providerServiceData = sharedData.providerService;
|
||||
const minute = sharedData.minute;
|
||||
|
||||
// durations
|
||||
const workDayDuration = selectedDate.service_types?.filter(x => x.service_id === serviceType.service_id)[0]?.duration;
|
||||
const specificDateDuration = specialDate?.service_types?.filter(x => x.service_id === serviceType.service_id)[0]?.duration;
|
||||
|
||||
const duration = (
|
||||
specificDateDuration ?? // session duration set for that service type for that specific date by the provider
|
||||
workDayDuration ?? // session duration set for that service session for that day of the week by the provider (repeats weekly)
|
||||
providerServiceData?.duration ?? // session duration set for that service session by the provider
|
||||
serviceType.session_duration // session duration set for that particular service type by the billboard
|
||||
) * minute;
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
|
||||
// for businesses with service providers
|
||||
export const getRealTimeReservationData = ({ service, sharedData, reservations }: getRealTimeReservationDataProps) => {
|
||||
const minute = sharedData.minute;
|
||||
const providerBillboard = sharedData.providerBillboard;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const start = sharedData.dayStart;
|
||||
const finish = sharedData.dayEnd;
|
||||
const date = sharedData.date;
|
||||
|
||||
const price = withProviderPrice({ sharedData: sharedData, service: service });
|
||||
const gap = withProviderGaps(sharedData);
|
||||
const capacity = withProviderCapacity(sharedData);
|
||||
const duration = withProviderDuration(sharedData);
|
||||
|
||||
const colidingReservations = reservations.filter(x =>
|
||||
(x.service_providers.some(y => y.service_provider_id.provider_id === sharedData.provider.provider_id)) &&
|
||||
(Date.parse(x.date) === date.getTime())
|
||||
);
|
||||
|
||||
const durationAndGap = duration + gap;
|
||||
const buffer = providerBillboard.day_start_buffer * minute;
|
||||
const restHours: restHoursProps[] = [];
|
||||
const availableFromHours: restHoursProps[] = [];
|
||||
const relevantReservations = reservations.filter(x =>
|
||||
(x.service_id === service.service_id) &&
|
||||
(Date.parse(x.date) === date.getTime())
|
||||
);
|
||||
|
||||
|
||||
const slots: slotsProps[] = [];
|
||||
let finalSlots: slotsProps[] = [];
|
||||
|
||||
// converting rest hours to milliseconds
|
||||
selectedDate.rest_hours && selectedDate.rest_hours.map(x => restHours.push(
|
||||
{
|
||||
from: htm(x.from),
|
||||
to: htm(x.to)
|
||||
}
|
||||
))
|
||||
// converting available hours to milliseconds
|
||||
selectedDate.service_types && service && selectedDate.service_types.filter(x => x.service_id === service.service_id)[0]?.available_from.map(x => availableFromHours.push(
|
||||
{
|
||||
from: htm(x.from),
|
||||
to: htm(x.to)
|
||||
}
|
||||
))
|
||||
|
||||
const numberOfSessions = Math.floor((((finish + gap) - (start + buffer))) / durationAndGap);
|
||||
|
||||
for (let i = 0; i < numberOfSessions; i++) {
|
||||
slots.push({
|
||||
from: getDateHours((start + buffer) + (i * durationAndGap)),
|
||||
to: getDateHours((start + buffer) + ((i * durationAndGap) + duration)),
|
||||
capacity: capacity,
|
||||
session_duration: duration / minute,
|
||||
gap: gap,
|
||||
ticketTypesPrice: price.price,
|
||||
reserved: relevantReservations.filter(x => htm(x.start_time) === htm(getDateHours((start + buffer) + (i * durationAndGap)))).length,
|
||||
reservedSeats: [],
|
||||
seats_map: -1,
|
||||
details: "",
|
||||
})
|
||||
}
|
||||
|
||||
const slotValidation = () => {
|
||||
// each step narrows available slots down one step furture to get the final avialable slots list //
|
||||
|
||||
// no rest hours confliction
|
||||
const restValidatedSlots = slots.filter(slot =>
|
||||
(specialDate && specialDate.rest_hours) ?
|
||||
!specialDate.rest_hours.some(rest => // rest period in that specific date
|
||||
(htm(slot.from) >= htm(rest.from) && htm(slot.from) <= htm(rest.to)) ||
|
||||
(htm(slot.to) > htm(rest.from) && htm(slot.to) <= htm(rest.to))
|
||||
)
|
||||
:
|
||||
!restHours.some(rest => // general rest hours
|
||||
(htm(slot.from) >= rest.from && htm(slot.from) <= rest.to) ||
|
||||
(htm(slot.to) > rest.from && htm(slot.to) <= rest.to)
|
||||
)
|
||||
);
|
||||
|
||||
// adhere to service availibility hours (if any)
|
||||
const hoursAvailibilityChecked = restValidatedSlots.filter(slot =>
|
||||
!availableFromHours.some(x =>
|
||||
(htm(slot.from) < x.from || htm(slot.to) > x.to)
|
||||
)
|
||||
);
|
||||
|
||||
// consider special dates (if any)
|
||||
const specialDatesChecked = hoursAvailibilityChecked.filter(slot =>
|
||||
specialDate ? (
|
||||
(htm(slot.from) >= htm(specialDate.from) && htm(slot.to) <= htm(specialDate.to)) && // adhere to the dates's special working hour
|
||||
!specialDate.service_types.filter(x => x.not_today).map(x => x.service_id).includes(service.service_id) // specific services not avilable in that date
|
||||
) : true
|
||||
);
|
||||
|
||||
// remove slots which time has passed now
|
||||
const timeChecked = specialDatesChecked.filter(slot =>
|
||||
date.getTime() === Date.parse(mtd(new Date().getTime())) ? htm(slot.from) >= new Date().getTime() : true
|
||||
);
|
||||
|
||||
// remove slots which time colides with other reservations for that provider in that hour
|
||||
const reservationColidingCheck = timeChecked.filter(slot =>
|
||||
!colidingReservations.some(x =>
|
||||
(x.service_id !== service.service_id) &&
|
||||
(
|
||||
(htm(slot.from) >= htm(x.start_time) && htm(slot.from) <= htm(x.end_time)) ||
|
||||
(htm(slot.from) >= htm(x.start_time) && htm(slot.to) <= htm(x.end_time)) ||
|
||||
(htm(slot.from) <= htm(x.start_time) && htm(slot.to) >= htm(x.end_time)) ||
|
||||
(htm(slot.to) === htm(x.start_time) || htm(slot.from) === htm(x.end_time))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
// service provider is busy now
|
||||
const busyCheck = reservationColidingCheck.filter(slot =>
|
||||
!providerBillboard.busy
|
||||
);
|
||||
|
||||
return busyCheck;
|
||||
};
|
||||
|
||||
finalSlots = slots.length > 0 ? slotValidation() : [];
|
||||
|
||||
|
||||
return finalSlots;
|
||||
}
|
||||
@ -0,0 +1,70 @@
|
||||
import { BillboardServiceType } from "common/types/billboard";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
import { getDatesBetween, getWeekDay } from "services/general/general";
|
||||
|
||||
interface getAvailableDatesProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
serviceType: BillboardServiceType;
|
||||
serviceProvider: ServiceProvider;
|
||||
billboardId: string;
|
||||
}
|
||||
interface ServiceProvidersDatesProps {
|
||||
date: Date;
|
||||
weekday: string;
|
||||
closed: boolean;
|
||||
}
|
||||
interface getWithProviderDatesProps {
|
||||
startDate: string; // service reservations period start date
|
||||
endDate: string; // service reservations period end date
|
||||
serviceTypesList: BillboardServiceType[]; // service types for this service
|
||||
serviceProvidersList: ServiceProvider[]; // all service providers for this service
|
||||
selectedType: number; // selected service types for this service
|
||||
selectedProvider: number; // selected provider for this service
|
||||
billboardId: string;
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const getServiceProvidersDates = ({ startDate, endDate, serviceType, serviceProvider, billboardId }: getAvailableDatesProps) => {
|
||||
const datesBetween = getDatesBetween(startDate, endDate);
|
||||
const dates: ServiceProvidersDatesProps[] = [];
|
||||
const workingDates = serviceProvider.billboards.filter(x => x.Billboards_id.id === billboardId)[0].working_days;
|
||||
const specialDates = serviceProvider.billboards[0].special_dates;
|
||||
|
||||
datesBetween.map(x => (
|
||||
dates.push({
|
||||
date: x,
|
||||
weekday: getWeekDay(x.getDay()),
|
||||
closed: workingDates.some(y =>
|
||||
y.weekday === getWeekDay(x.getDay()) &&
|
||||
(
|
||||
y.not_working || // service provider doesn't works in that day of the week
|
||||
y.service_types?.filter(x => x.service_id === serviceType.service_id)[0]?.not_today || // service provider doesn't do that particular service in that day
|
||||
specialDates?.filter(date => Date.parse(date.date) === x.getTime())[0]?.not_working // service provider doesn't works in that particular date
|
||||
)
|
||||
)
|
||||
})
|
||||
));
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
// returns all the dates available for reservation for this service type on the selected date;
|
||||
// for the selected service provider;
|
||||
// takes special dates like holidays or any other diff dates into account
|
||||
export const getWithProviderDates = ({ startDate, endDate, serviceTypesList, serviceProvidersList, selectedType, selectedProvider, billboardId }: getWithProviderDatesProps) => {
|
||||
|
||||
// if no service types exist
|
||||
if (serviceTypesList.length === 0 || serviceProvidersList.length === 0) return [];
|
||||
|
||||
// if service doesn't have service providers
|
||||
return getServiceProvidersDates({
|
||||
startDate: startDate,
|
||||
endDate: endDate,
|
||||
serviceType: serviceTypesList.filter(x => x.service_id === selectedType)[0],
|
||||
serviceProvider: serviceProvidersList[selectedProvider],
|
||||
billboardId: billboardId
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
import { BillboardReservation, BillboardServiceType } from "common/types/billboard";
|
||||
import { ServiceProvider } from "common/types/service-provider";
|
||||
import { getRealTimeReservationData } from "./reservation-services-with-providers";
|
||||
|
||||
interface getWithProviderTimeSlotsProps {
|
||||
serviceTypesList: BillboardServiceType[];
|
||||
serviceProvidersList: ServiceProvider[];
|
||||
selectedType: number;
|
||||
providerShared: any;
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
|
||||
// returns all the available time slots for reservation for this service type on the selected date;
|
||||
// for the selected service provider;
|
||||
// takes special dates like holidays or any other diff dates into account
|
||||
export const getWithProviderTimeSlots = ({ serviceTypesList, serviceProvidersList, selectedType, providerShared, reservations }: getWithProviderTimeSlotsProps) => {
|
||||
|
||||
// if no service types exist
|
||||
if (serviceTypesList.length === 0 || serviceProvidersList.length === 0 || !reservations) return [];
|
||||
|
||||
// if service doesn't have service providers
|
||||
return getRealTimeReservationData({
|
||||
service: serviceTypesList.filter(x => x.service_id === selectedType)[0],
|
||||
sharedData: providerShared,
|
||||
reservations: reservations
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,59 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { useGetRouter } from "services/general/general";
|
||||
import InnerLoading from "components/loading/inner-loading";
|
||||
import { CircleCheckSolid } from "components/icons";
|
||||
import { slotsProps } from "./services/reservation-services-with-providers";
|
||||
|
||||
interface TimeSelectionProps {
|
||||
timeSlots: any;
|
||||
selectedTime: any;
|
||||
billboardColor: string;
|
||||
onSelect: (data: any) => void;
|
||||
}
|
||||
|
||||
|
||||
const TimeSelection: React.FunctionComponent<TimeSelectionProps> = ({ timeSlots, selectedTime, billboardColor, onSelect }) => {
|
||||
|
||||
// states
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router } = useGetRouter();
|
||||
|
||||
// methods
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center w-full justify-between mt-6">
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold mb-2">{translate("billboard-product-reservation-available-hours")}</span>
|
||||
</div>
|
||||
{timeSlots ?
|
||||
<>
|
||||
{timeSlots.length > 0 ?
|
||||
<div className="grid grid-cols-3 gap-x-2 gap-y-6 w-full px-[2px] py-4 select-none">
|
||||
{timeSlots.map((x:any, i: number) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`reactive-button flex flex-col items-center w-full relative rounded-full px-2 py-3 shrink-0 lg:cursor-pointer ring-1 ${x.from === selectedTime?.from ? "selected-date bg-[var(--light-brand-color)] ring-2 ring-[var(--brand-color)]" : "ring-gray-200"} ${x.reserved === x.capacity ? "bg-gray-50 pointer-events-none" : "bg-white"}`}
|
||||
onClick={() => onSelect(x)}
|
||||
>
|
||||
<span className={`inline-block text-xs absolute top-0 px-4 py-[2px] rounded-full -translate-y-1/2 bg-white text-secondary-light border border-gray-200`}>{x.reserved === x.capacity ? `${translate("billboard-reservation-slot-full")}` : `${x.reserved}/${x.capacity}`}</span>
|
||||
<span className={`block text-xs lg:text-sm font-semibold mt-2 mb-1 ${x.from === selectedTime?.from ? "text-[var(--brand-color)]" : "text-secondary-light"} ${x.reserved === x.capacity ? "!text-gray-400" : ""} [direction:ltr]`}>{`${x.from.slice(0, 5)} - ${x.to.slice(0, 5)}`}</span>
|
||||
<CircleCheckSolid className={`${x.from === selectedTime?.from ? "inline-block" : "hidden"} absolute bottom-0 translate-y-1/2 bg-white rounded-full size-5 fill-[var(--brand-color)]`} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
:
|
||||
<p className="block py-3 px-gi text-sm/7 text-warning-text bg-warning-bg rounded-lg mt-2 mb-6 border-4 border-white ring-2 ring-warning-bg">{translate("billboard-reservation-no-slots-available")}</p>
|
||||
}
|
||||
</>
|
||||
:
|
||||
<InnerLoading loadingText={""} width={"200"} height={"100"} color={billboardColor} />
|
||||
}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TimeSelection
|
||||
@ -2,11 +2,11 @@ import React from "react"
|
||||
import Link from "components/link/link"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, url, useGetRouter } from 'services/general/general'
|
||||
import { ImageSharpSolid, PercentageSolid, PlusSolid } from "components/icons"
|
||||
import { CartPlusSolid, ImageSharpSolid, PercentageSolid } from "components/icons"
|
||||
import { BillboardProduct } from "common/types/billboard"
|
||||
import Image from "components/image/image"
|
||||
import StarRating from "components/rating/star-rating"
|
||||
import { PriceDataProps } from "pages/api/billboard/product-price-data"
|
||||
import ProductPhotos from "./product-photos"
|
||||
|
||||
interface ProductItemProps {
|
||||
item: BillboardProduct;
|
||||
@ -20,61 +20,83 @@ interface ProductItemProps {
|
||||
const ProductItem: React.FunctionComponent<ProductItemProps> = ({ item, priceData, billboardId, billboardTitle, rating = 0 }) => {
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const pic = item.ad_item ? item.ad_item.gallery[0]?.directus_files_id : (!item.variations || !item.variations.some(x => x.pics.length > 0) ? null : item.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery[0]?.directus_files_id);
|
||||
const pics = item.ad_item ? item.ad_item.gallery : (!item.variations || !item.variations.some(x => x.pics.length > 0) ? null : item.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery);
|
||||
const itemTitle = item.translations.filter((x: any) => x.languages_code.code.slice(0, 2) === locale)[0].title;
|
||||
const price = priceData;
|
||||
|
||||
const variantColorIds = new Set;
|
||||
item.variations.filter(x => x.color).sort((a, b) => a.variant_id - b.variant_id).map(x => variantColorIds.add(Number(x.color.id)));
|
||||
const hasColorSelection = Array.from(variantColorIds).length > 0;
|
||||
|
||||
const handleAddToCart = () => {
|
||||
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={item.ad_item ? `/market/${item.ad_item.id}/${url(getLocaleTr(item.ad_item, locale).title)}` : `/billboard/${billboardId}/${url(billboardTitle)}/products/${item.product_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={item.ad_item ? "_blank" : "_self"}
|
||||
className="flex flex-col items-center w-full relative bg-white rounded-md shrink-0 justify-center lg:cursor-pointer lg:hover:shadow-lg [&_.button]:hover:bg-[var(--brand-color)] [&_.button]:hover:text-white [&_.button]:hover:ring-[var(--brand-color)] overflow-hidden"
|
||||
>
|
||||
<div title={itemTitle} className="flex flex-col items-center w-full relative bg-white rounded-lg shrink-0 justify-center lg:cursor-pointer lg:hover:shadow-lg overflow-hidden">
|
||||
{/* discount percentage */}
|
||||
{price.discounted_price !== -1 && <div className="flex items-center w-max absolute capitalize top-0 rtl:right-0 ltr:left-0 z-10 text-white bg-[var(--brand-color)] rtl:rounded-bl-lg ltr:rounded-br-lg py-1 px-2">
|
||||
<PercentageSolid className="inline-block size-3 sm:size-3 fill-current rtl:ml-[2px] ltr:mr-[2px]" />
|
||||
<span className="inline-block text-[0.625rem]/4 sm:text-xs text-current font-semibold shrink-0">{`${Math.ceil(((price.price - price.discounted_price) / price.price) * 100)} ${translate("discount")}`}</span>
|
||||
</div>}
|
||||
<div className={`block w-full h-40 sm:h-52 relative sm:mb-1 p-2 ${item.ad_item ? "" : "mt-2 sm:mt-2"} `}>
|
||||
{pic ?
|
||||
<Image
|
||||
src={`${pic.id}/${pic.filename_download}`}
|
||||
alt={translate("pic-of") + itemTitle}
|
||||
width={pic.width}
|
||||
height={pic.height}
|
||||
quality={75}
|
||||
ar={[3 / 2, 3 / 2, 3 / 2, 3 / 2]}
|
||||
imageSizes={[200, 250, 250, 250]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className={`rounded will-change-transform ${item.ad_item ? "!object-cover" : "!object-contain"}`}
|
||||
figureClass="rounded w-full transition-transform duration-200 select-none [&>div]:!bg-transparent"
|
||||
<div className={`block w-full h-40 sm:h-52 relative p-2 ${item.ad_item ? "" : "mt-2 sm:mt-2"}`}>
|
||||
{pics ?
|
||||
<ProductPhotos
|
||||
billboardId={billboardId}
|
||||
billboardTitle={billboardTitle}
|
||||
variations={item.variations}
|
||||
product_id={item.product_id}
|
||||
productTitle={itemTitle}
|
||||
/>
|
||||
:
|
||||
<span className="block w-full aspect-3/2 md:aspect-3/2 rounded-lg lg:rounded">
|
||||
<Link
|
||||
href={item.ad_item ? `/market/${item.ad_item.id}/${url(getLocaleTr(item.ad_item, locale).title)}` : `/billboard/${billboardId}/${url(billboardTitle)}/products/${item.product_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={item.ad_item ? "_blank" : "_self"}
|
||||
className="block w-full aspect-3/2 md:aspect-3/2 rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
<div className="bg-white block w-full max-sm:pt-2 px-3 py-3 sm:px-4 sm:pb-4 sm:pt-2">
|
||||
{item.category && <span className="block text-xs text-[var(--brand-color)] capitalize font-semibold mt-1 mb-2">{getLocaleTr(item.category, locale).name}</span>}
|
||||
<h2 className={`text-xs/6 font-extrabold sm:text-[0.825rem] text-secondary-light ${item.ad_item ? "line-clamp-1" : "line-clamp-2 sm:line-clamp-1 h-12 sm:h-6 "} mb-2 capitalize`}>{itemTitle}</h2>
|
||||
<StarRating rating={rating} className="text-amber-500 -mr-1 w-max" starClass="size-[0.875rem] shrink-0" />
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between mt-4">
|
||||
<div className="flex items-center w-max text-secondary-light max-sm:mb-3">
|
||||
{price.discounted_price !== -1 && <span className="text-sm text-cool-gray line-through font-semibold rtl:ml-2 ltr:mr-2">{`${price.currency.sign}${price.price}`}</span>}
|
||||
<span className="text-base sm:text-base text-[var(--brand-color)] font-extrabold">{`${price.currency.sign}${price.discounted_price !== -1 ? price.discounted_price : price.price}`}</span>
|
||||
<div className={`bg-white block w-full px-3 pt-16 pb-3 sm:px-4 ${hasColorSelection ? "pt-[72px] lg:pt-16" : "pt-7 lg:pt-5"}`}>
|
||||
{/* {item.category && <span className="block text-xs text-cool-gray capitalize font-semibold mt-1 mb-2">{getLocaleTr(item.category, locale).name}</span>} */}
|
||||
<Link
|
||||
href={item.ad_item ? `/market/${item.ad_item.id}/${url(getLocaleTr(item.ad_item, locale).title)}` : `/billboard/${billboardId}/${url(billboardTitle)}/products/${item.product_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={item.ad_item ? "_blank" : "_self"}
|
||||
className={``}
|
||||
>
|
||||
<h2 className={`text-xs/6 font-extrabold sm:text-[0.825rem] text-secondary-light ${item.ad_item ? "line-clamp-1" : "line-clamp-2 sm:line-clamp-2 h-12 "} mb-2 capitalize`}>{itemTitle}</h2>
|
||||
</Link>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{item.brand && <span className="text-xs text-secondary-light font-semibold">{`${locale === "fa" ? item.brand.persian_name : item.brand.english_name}`}</span>}
|
||||
<StarRating
|
||||
mode="single"
|
||||
rating={Number(rating.toFixed(2))}
|
||||
className="text-amber-500 -mr-1 w-max gap-[2px]"
|
||||
starClass="size-[0.775rem] sm:size-[0.875rem] shrink-0"
|
||||
singleRateClass="font-semibold text-secondary-light text-sm"
|
||||
singleWrapperClass="gap-[6px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<div className="flex items-center w-max text-secondary-light select-none">
|
||||
{price.discounted_price !== -1 && <span className="text-sm text-cool-gray line-through font-semibold me-1">{`${price.price}`}</span>}
|
||||
<span className="text-sm sm:text-base text-secondary-light font-extrabold">{`${price.currency.sign}${price.discounted_price !== -1 ? price.discounted_price : price.price}`}</span>
|
||||
</div>
|
||||
{/* <span className="flex items-center w-max text-sm sm:text-base font-semibold text-market-title-light py-[2px] px-2 rounded bg-market-input [direction:ltr]">{`${item.weight} ${getWeightUnit.filter(x => x.unit === item.weight_unit)[0].abr}`}</span> */}
|
||||
<div className="reactive-button button flex items-center justify-center rounded sm:rounded py-[6px] px-2 bg-gray-50 sm:bg-white text-[var(--brand-color)] sm:text-secondary-light ring-1 ring-gray-100 sm:ring-gray-200">
|
||||
<span className="text-xs sm:text-xs text-current">{translate("billboard-products-thumb-see-details")}</span>
|
||||
<div
|
||||
className="reactive-button button flex items-center justify-center rounded sm:rounded py-2 px-2
|
||||
bg-gray-50 text-secondary-light sm:ring-gray-200 hover:bg-[var(--brand-color)] hover:text-white select-none"
|
||||
onClick={handleAddToCart}
|
||||
>
|
||||
<CartPlusSolid className="inline-block size-[14px] sm:size-4 fill-current me-2" />
|
||||
<span className="text-xs sm:text-xs text-current">{translate("billboard-add-to-cart")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -0,0 +1,131 @@
|
||||
import React, { useRef, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { url, useGetRouter } from "services/general/general";
|
||||
import { ImageSharpSolid } from "components/icons";
|
||||
import Image from "components/image/image";
|
||||
import { ProductVariations } from "common/types/billboard";
|
||||
import Link from "components/link/link";
|
||||
|
||||
interface ProductPhotosProps {
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
variations: ProductVariations[];
|
||||
product_id: number;
|
||||
productTitle: string;
|
||||
}
|
||||
|
||||
const ProductPhotos: React.FunctionComponent<ProductPhotosProps> = ({ billboardId, billboardTitle, variations, product_id, productTitle }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const variantColorIds = new Set;
|
||||
variations.filter(x => x.color).sort((a, b) => a.variant_id - b.variant_id).map(x => variantColorIds.add(Number(x.color.id)));
|
||||
const hasColorSelection = Array.from(variantColorIds).length > 0;
|
||||
|
||||
// states
|
||||
const [currentPic, setCurrentPic] = useState(0);
|
||||
const [selectedVariant, setSelectedVariant] = useState(hasColorSelection ? Number(variations.filter(x => x).sort((a, b) => a.variant_id - b.variant_id)[0].color.id) : 0);
|
||||
|
||||
// variables
|
||||
const { locale, router } = useGetRouter();
|
||||
const currentIndex = useRef(0);
|
||||
const selectedThumb = hasColorSelection ?
|
||||
variations.filter(x => Number(x.color.id) === selectedVariant)[0].pics[0].variant_galleries_id.gallery.filter(x => x).sort((a, b) => a.sort_id - b.sort_id)
|
||||
:
|
||||
variations.filter(x => x).sort((a, b) => a.variant_id - b.variant_id)[0].pics[0].variant_galleries_id.gallery;
|
||||
|
||||
// methods
|
||||
const handleScroll = (event: any) => {
|
||||
const activePic = Math.abs(Math.round(event.target.scrollLeft / event.target.clientWidth));
|
||||
if (currentIndex.current !== activePic) {
|
||||
setCurrentPic(activePic);
|
||||
currentIndex.current = activePic;
|
||||
}
|
||||
}
|
||||
const getColorData = (id: number) => {
|
||||
let color = variations.filter(x => Number(x.color.id) === id)[0].color;
|
||||
return { name: locale === "fa" ? color.persian_name : color.english_name, code: color.hex_code };
|
||||
}
|
||||
|
||||
const GalleryItem: React.FunctionComponent<any> = ({ item }) => {
|
||||
return <div id={item.id} className="block mx-auto rounded-lg w-full select-none [&>div]:!bg-transparent lg:cursor-pointer shrink-0 snap-center snap-always">
|
||||
<Image
|
||||
src={`${item.id}/${item.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={item.width}
|
||||
height={item.height}
|
||||
quality={100}
|
||||
ar={[3 / 2, 3 / 2, 3 / 2, 3 / 2]}
|
||||
imageSizes={[200, 250, 250, 250]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className="rounded-lg !object-contain"
|
||||
figureClass="block mx-auto rounded-lg w-full select-none [&>div]:!bg-transparent shrink-0"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* pics album */}
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}/products/${product_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={"_self"}
|
||||
className="block w-full h-full relative lg:mt-0 max-lg:px-2">
|
||||
{selectedThumb.length > 0 ?
|
||||
<>
|
||||
<div className="flex lg:hidden overflow-x-scroll h-full hide-scrollbar snap-x snap-mandatory" onScroll={handleScroll}>
|
||||
{selectedThumb.filter(x => x).sort((a, b) => a.sort_id - b.sort_id).map((x: any) => (
|
||||
<GalleryItem key={x.directus_files_id.id} item={x.directus_files_id} />
|
||||
))}
|
||||
</div>
|
||||
<Image
|
||||
src={`${selectedThumb[0].directus_files_id.id}/${selectedThumb[0].directus_files_id.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={selectedThumb[0].directus_files_id.width}
|
||||
height={selectedThumb[0].directus_files_id.height}
|
||||
quality={100}
|
||||
ar={[3 / 2, 3 / 2, 3 / 2, 3 / 2]}
|
||||
imageSizes={[200, 250, 250, 250]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className="rounded-lg !object-contain"
|
||||
figureClass="hidden lg:block mx-auto rounded-lg w-full select-none [&>div]:!bg-transparent shrink-0"
|
||||
/>
|
||||
</>
|
||||
:
|
||||
<span className="block w-full aspect-square md:aspect-square rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
</Link>
|
||||
|
||||
{/* pic index dots */}
|
||||
<div className="flex lg:hidden items-center justify-center gap-1 pt-4">
|
||||
{selectedThumb.map((x, i) => (
|
||||
<span key={x.directus_files_id.id} className={`inline-block size-[6px] ${currentPic === i ? "bg-[var(--brand-color)]" : "bg-[var(--light-brand-color)]"} rounded-sm`}></span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* variants / colors selection */}
|
||||
{hasColorSelection && <div className="flex items-center justify-center w-full px-2 pt-4 lg:pt-7 pb-2">
|
||||
<div className="flex items-center gap-[6px] overflow-x-scroll hide-scrollbar px-2 py-1">
|
||||
{Array.from(variantColorIds).map((x: any, i) => (
|
||||
<span
|
||||
key={x}
|
||||
title={getColorData(x).name}
|
||||
style={{ "--color": `#${getColorData(x).code}` } as React.CSSProperties}
|
||||
className={`inline-block size-[22px] sm:size-[22px] bg-[var(--color)] rounded-full border-2 border-white lg:hover:scale-105 transition-transform duration-75 ring-1 ${selectedVariant === x ? "ring-[var(--color)]" : "ring-gray-100"} shrink-0`}
|
||||
onClick={() => setSelectedVariant(x)}
|
||||
>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default ProductPhotos
|
||||
@ -2,8 +2,8 @@ import React from "react"
|
||||
import Link from "components/link/link"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, stripHtml, url, useGetRouter } from 'services/general/general'
|
||||
import { ImageSharpSolid, PercentageSolid, PlusSolid } from "components/icons"
|
||||
import { BillboardProduct, BillboardServiceTypes } from "common/types/billboard"
|
||||
import { ImageSharpSolid } from "components/icons"
|
||||
import { BillboardProduct, BillboardServiceType, ProductVariantGalleries } from "common/types/billboard"
|
||||
import Image from "components/image/image"
|
||||
import StarRating from "components/rating/star-rating"
|
||||
|
||||
@ -12,21 +12,18 @@ interface ReservationItemProps {
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
rating: number;
|
||||
serviceTypes: BillboardServiceTypes[];
|
||||
serviceTypes: BillboardServiceType[];
|
||||
variantGalleries: ProductVariantGalleries[];
|
||||
}
|
||||
|
||||
|
||||
const ReservationItem: React.FunctionComponent<ReservationItemProps> = ({ item, billboardId, billboardTitle, rating = 0, serviceTypes }) => {
|
||||
const ReservationItem: React.FunctionComponent<ReservationItemProps> = ({ item, billboardId, billboardTitle, rating = 0, serviceTypes, variantGalleries }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
// const pic = item.ad_item ? item.ad_item.gallery[0]?.directus_files_id : item.gallery[0]?.directus_files_id;
|
||||
const pic = variantGalleries.filter(x => x.product.id === item.id)[0].gallery[0].directus_files_id;
|
||||
const itemTitle = item.translations.filter((x: any) => x.languages_code.code.slice(0, 2) === locale)[0].title;
|
||||
const serviceType = serviceTypes[0];
|
||||
const getWeightUnit = [
|
||||
{ unit: "grams", abr: "gr" },
|
||||
{ unit: "kilograms", abr: "kg" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Link
|
||||
@ -34,7 +31,7 @@ const ReservationItem: React.FunctionComponent<ReservationItemProps> = ({ item,
|
||||
shallow
|
||||
scroll={false}
|
||||
target={item.ad_item ? "_blank" : "_self"}
|
||||
className="flex items-center w-full relative bg-white rounded-lg shrink-0 justify-center lg:cursor-pointer lg:hover:shadow-lg [&_.button]:hover:bg-[var(--brand-color)] [&_.button]:hover:text-white [&_.button]:hover:ring-[var(--brand-color)] overflow-hidden"
|
||||
className="reactive-button flex items-center w-full relative bg-white rounded-lg shrink-0 justify-center lg:cursor-pointer lg:hover:shadow-lg [&_.button]:hover:bg-[var(--brand-color)] [&_.button]:hover:text-white [&_.button]:hover:ring-[var(--brand-color)] overflow-hidden"
|
||||
>
|
||||
{/* discount percentage */}
|
||||
{/* {item.discount_price && <div className="flex items-center w-max absolute capitalize top-0 rtl:right-0 ltr:left-0 z-10 text-white bg-[var(--brand-color)] rtl:rounded-bl-lg ltr:rounded-br-lg py-1 px-2">
|
||||
@ -42,7 +39,7 @@ const ReservationItem: React.FunctionComponent<ReservationItemProps> = ({ item,
|
||||
<span className="inline-block text-[0.625rem]/4 sm:text-xs text-current font-semibold shrink-0">{`${Math.ceil(((item.price - item.discount_price) / item.price) * 100)} ${translate("discount")}`}</span>
|
||||
</div>} */}
|
||||
<div className={`block w-28 sm:w-44 h-full sm:h-56 relative p-2 shrink-0`}>
|
||||
{/* {pic ?
|
||||
{pic ?
|
||||
<Image
|
||||
src={`${pic.id}/${pic.filename_download}`}
|
||||
alt={translate("pic-of") + itemTitle}
|
||||
@ -61,7 +58,7 @@ const ReservationItem: React.FunctionComponent<ReservationItemProps> = ({ item,
|
||||
<span className="block w-full aspect-3/2 md:aspect-3/2 rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
} */}
|
||||
}
|
||||
</div>
|
||||
<div className="bg-white block w-full h-full px-3 rtl:pr-2 ltr:pl-2 py-3 max-sm:pt-2 sm:px-4 rtl:sm:pr-2 ltr:sm:pl-2 sm:pb-4">
|
||||
<h2 className={`text-sm/7 font-extrabold sm:text-xl text-secondary-light line-clamp-1 sm:line-clamp-1 mb-2 sm:mb-3 uppercase shrink-0`}>{itemTitle}</h2>
|
||||
@ -73,8 +70,8 @@ const ReservationItem: React.FunctionComponent<ReservationItemProps> = ({ item,
|
||||
<span className="hidden sm:inline-block text-xs bg-[var(--light-brand-color)] text-[var(--brand-color)] capitalize font-semibold mt-3 mb-1 py-1 px-2 rounded">{getLocaleTr(item.category, locale).name}</span>
|
||||
<div className="flex flex-row items-center justify-between mt-3">
|
||||
<div className="flex items-center w-max text-secondary-light">
|
||||
{item.session_details && serviceType.session_data[0].discount_price && <span className="text-sm text-cool-gray line-through font-semibold rtl:ml-2 ltr:mr-2">${serviceType.session_data[0].price}</span>}
|
||||
<span className="text-base sm:text-base text-[var(--brand-color)] font-extrabold">{`$${serviceType.session_data[0].discount_price ?? serviceType.session_data[0].price}`}</span>
|
||||
{item && serviceType.ticket_types[0].discount_price && <span className="text-sm text-cool-gray line-through font-semibold rtl:ml-2 ltr:mr-2">${serviceType.ticket_types[0].price}</span>}
|
||||
<span className="text-base sm:text-base text-[var(--brand-color)] font-extrabold">{`$${serviceType.ticket_types[0].discount_price ?? serviceType.ticket_types[0].price}`}</span>
|
||||
</div>
|
||||
<div className="reactive-button button flex items-center justify-center rounded py-[6px] px-2 bg-white text-secondary-light ring-1 ring-gray-200">
|
||||
<span className="text-xs sm:text-xs text-current capitalize">{translate("billboard-products-reservation-card-see-details")}</span>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import React, { useEffect, useRef, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, url, useGetRouter } from "services/general/general";
|
||||
import { BillboardProductsCategory } from "common/types/billboard";
|
||||
import { getLocaleTr, useGetRouter } from "services/general/general";
|
||||
import { Billboard, BillboardProductsCategory } from "common/types/billboard";
|
||||
import { ImageSharpSolid, SplitSolid } from "components/icons";
|
||||
import ProductCats from "./product-cats";
|
||||
import { getDescendantIds } from "pages/dashboard/billboards/[id]/products";
|
||||
@ -10,11 +10,7 @@ import Image from "components/image/image";
|
||||
|
||||
interface ProductCatsFilterProps {
|
||||
cats: BillboardProductsCategory[];
|
||||
billboard: {
|
||||
id: string;
|
||||
title: string;
|
||||
brand_color: string;
|
||||
};
|
||||
billboard: Billboard;
|
||||
onChange: (cats: number[]) => void;
|
||||
}
|
||||
|
||||
@ -33,6 +29,16 @@ const ProductCatsFilter: React.FunctionComponent<ProductCatsFilterProps> = ({ ca
|
||||
const parents: BillboardProductsCategory[] = cats.filter(x => x.parent.length === 0);
|
||||
const updateQueryParams = useUpdateQueryParams();
|
||||
const scrollRef = useRef(null);
|
||||
const filtersLabel = () => {
|
||||
let label = "";
|
||||
if (billboard.product_types.length > 1) {
|
||||
label = billboard.default_activity === "shop" ? translate("billboard-products-cats-title") : translate("services-categories");
|
||||
} else {
|
||||
if (billboard.product_types[0] === "reservation") label = translate("services-categories");
|
||||
if (billboard.product_types[0] === "shop") label = translate("billboard-products-cats-title");
|
||||
}
|
||||
return label;
|
||||
}
|
||||
|
||||
// methods
|
||||
const handleParentCatSelection = (id: number) => {
|
||||
@ -45,6 +51,11 @@ const ProductCatsFilter: React.FunctionComponent<ProductCatsFilterProps> = ({ ca
|
||||
updateQueryParams({ cat: activeChild !== id ? `${activeParent}-${id}` : activeParent });
|
||||
}
|
||||
const onCatMenuSelection = (id: number, pId: number, isParent: boolean) => {
|
||||
// if (!isParent && id === -1 && pId === -1) {
|
||||
// setActiveParent(0);
|
||||
// setActiveChild(0);
|
||||
// router.push(`${basePath()}/billboard/${query.id}/${query.billboard}/products`, undefined, { shallow: true, scroll: false });
|
||||
// }
|
||||
if (isParent) {
|
||||
setActiveParent(id);
|
||||
setActiveChild(0);
|
||||
@ -100,35 +111,31 @@ const ProductCatsFilter: React.FunctionComponent<ProductCatsFilterProps> = ({ ca
|
||||
|
||||
return (
|
||||
<div ref={scrollRef} className={`scroll-mt-20`}>
|
||||
{/* parent cats */}
|
||||
<div className="hidden lg:flex items-center lg:w-max space-x-4 rtl:space-x-reverse rounded-lg lg:bg-white lg:px-3 py-2">
|
||||
{parents.sort((a, b) => a.sort_id - b.sort_id).slice(0, 5).map(x => (
|
||||
<span
|
||||
key={x.local_id}
|
||||
className={`reactive-button hidden lg:flex items-center py-2 px-3 text-sm select-none capitalize lg:cursor-pointer ${activeParent === x.local_id ? "bg-[var(--light-brand-color)] text-[var(--brand-color)]" : "bg-white text-secondary-light"} font-semibold rounded-lg shadow`}
|
||||
onClick={() => handleParentCatSelection(x.local_id)}
|
||||
>
|
||||
{getLocaleTr(x, locale).name}
|
||||
</span>
|
||||
))}
|
||||
<span className={`flex items-center py-2 px-3 text-sm capitalize rounded-lg shadow lg:cursor-pointer`} onClick={() => setAllCatsOpen(true)}>
|
||||
<SplitSolid className="inline-block size-3 lg:size-3 fill-current rtl:ml-2 ltr:mr-2 lg:rtl:ml-2 lg:ltr:mr-2 select-none" />
|
||||
{translate("billboard-products-cats-see-all")}
|
||||
</span>
|
||||
{/* all cats modal */}
|
||||
<ProductCats
|
||||
brand_color={billboard.brand_color}
|
||||
cats={cats}
|
||||
title={filtersLabel()}
|
||||
allCatsLabel={`${translate("all")} ${billboard.product_types[0] === "reservation" ? translate("services") : translate("products")}`}
|
||||
isOpen={allCatsOpen}
|
||||
onClose={() => setAllCatsOpen(false)}
|
||||
onSelect={onCatMenuSelection}
|
||||
/>
|
||||
|
||||
<div className="flex items-center w-full gap-3 lg:px-4 pt-2 lg:pt-3 pb-5 border-b border-gray-200/50">
|
||||
{/* categories */}
|
||||
<div className={`reactive-button flex items-center w-max select-none bg-white py-2 px-4 text-xs sm:text-sm text-secondary-light rounded-full shadow-sm capitalize`} onClick={() => setAllCatsOpen(true)}>
|
||||
<SplitSolid className="inline-block size-3 lg:size-3 fill-current me-2 lg:me-2 select-none" />
|
||||
{activeParent === 0 ? filtersLabel() : getLocaleTr(parents.filter(x => x.local_id === activeParent)[0], locale).name}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* mobile parent cats */}
|
||||
<span className={`reactive-button flex lg:hidden items-center w-max bg-white py-2 px-3 text-sm rounded-lg shadow`} onClick={() => setAllCatsOpen(true)}>
|
||||
<SplitSolid className="inline-block size-3 lg:size-3 fill-current rtl:ml-2 ltr:mr-2 lg:rtl:ml-2 lg:ltr:mr-2" />
|
||||
{activeParent === 0 ? translate("billboard-products-cats-see-all") : getLocaleTr(parents.filter(x => x.local_id === activeParent)[0], locale).name}
|
||||
</span>
|
||||
|
||||
{/* child cats */}
|
||||
{cats.some(x => x.parent[0] === activeParent) && <div className="flex items-center w-full gap-2 lg:px-2 py-1 mt-4 hide-scrollbar overflow-x-scroll">
|
||||
{cats.some(x => x.parent[0] === activeParent) && <div className="flex items-center w-full gap-2 lg:px-4 py-4 hide-scrollbar overflow-x-scroll border-b border-gray-200/50">
|
||||
{cats.filter(x => x.parent[0] === activeParent).sort((a, b) => a.sort_id - b.sort_id).map(x => (
|
||||
<div
|
||||
key={x.id}
|
||||
className={`flex flex-col items-center justify-center gap-3 w-24 shrink-0 lg:cursor-pointer`}
|
||||
className={`flex flex-col items-center justify-center gap-3 w-24 shrink-0 lg:cursor-pointer [&>span]:hover:lg:bg-white [&>span]:hover:lg:text-[var(--brand-color)]`}
|
||||
onClick={() => handleSubCatSelection(x.local_id)}
|
||||
>
|
||||
<div className={`reactive-button block w-[88px] !h-[88px] relative rounded-full shadow-bot border-[4px] ${activeChild === x.local_id ? "border-[var(--medium-brand-color)]" : "border-white"}`}>
|
||||
@ -155,16 +162,14 @@ const ProductCatsFilter: React.FunctionComponent<ProductCatsFilterProps> = ({ ca
|
||||
</div>
|
||||
<span
|
||||
key={x.local_id}
|
||||
className={`${`${x.local_id}-active-cat`} reactive-button shrink-0 py-1 px-3 capitalize rounded-full text-xs/6 text-center select-none font-bold line-clamp-1 ${activeChild === x.local_id ? "bg-[var(--light-brand-color)] text-[var(--brand-color)]" : "text-secondary-light"}`}
|
||||
title={getLocaleTr(x, locale).name}
|
||||
className={`${`${x.local_id}-active-cat`} reactive-button shrink-0 py-1 px-3 capitalize rounded-full text-xs/6 text-center select-none font-semibold line-clamp-1 ${activeChild === x.local_id ? "!bg-[var(--light-brand-color)] !text-[var(--brand-color)]" : "text-secondary-light"}`}
|
||||
>
|
||||
{getLocaleTr(x, locale).name}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
{/* all cats modal */}
|
||||
<ProductCats brand_color={billboard.brand_color} cats={cats} isOpen={allCatsOpen} onClose={() => setAllCatsOpen(false)} onSelect={onCatMenuSelection} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import Link from "components/link/link"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { useGetRouter } from "services/general/general";
|
||||
import { Color } from "common/types/general";
|
||||
import { XmarkSolid } from "components/icons";
|
||||
import { ProductVariations } from "common/types/billboard";
|
||||
|
||||
interface ColorSelectionProps {
|
||||
allVariants: ProductVariations[];
|
||||
all: Color[];
|
||||
available: Color[];
|
||||
selected: Color;
|
||||
@ -13,7 +13,7 @@ interface ColorSelectionProps {
|
||||
}
|
||||
|
||||
|
||||
const ColorSelection: React.FunctionComponent<ColorSelectionProps> = ({ all, available, selected, onSelect }) => {
|
||||
const ColorSelection: React.FunctionComponent<ColorSelectionProps> = ({ allVariants, all, available, selected, onSelect }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
@ -28,6 +28,9 @@ const ColorSelection: React.FunctionComponent<ColorSelectionProps> = ({ all, ava
|
||||
onSelect(String(color.id));
|
||||
setSelectedColor(color)
|
||||
}
|
||||
const getVariantId = (colorId: string) => {
|
||||
return allVariants.filter(x => x.color.id === colorId)[0].variant_id;
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
@ -44,7 +47,7 @@ const ColorSelection: React.FunctionComponent<ColorSelectionProps> = ({ all, ava
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold rtl:mr-2 ltr:ml-2">{locale === "fa" ? selectedColor.persian_name : selectedColor.english_name}</span>
|
||||
</div>
|
||||
<div className="inline-block space-x-4 rtl:space-x-reverse mt-4">
|
||||
{all.map(x => (
|
||||
{all.sort((a, b) => getVariantId(String(a.id)) - getVariantId(String(b.id))).map(x => (
|
||||
<span
|
||||
key={x.id}
|
||||
style={{ "--color": `#${x.hex_code}` } as React.CSSProperties}
|
||||
|
||||
168
src/common/templates/billboard/page/products/filters.tsx
Normal file
168
src/common/templates/billboard/page/products/filters.tsx
Normal file
@ -0,0 +1,168 @@
|
||||
import React, { useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { useGetRouter } from "services/general/general";
|
||||
import Modal from "components/modal/modal";
|
||||
import Select from "components/select/select";
|
||||
import { SortingTypesProps } from "./products";
|
||||
import { ArrowDownArrowUpSolid, ArrowsRotateSolid, CheckSolid, PercentageSolid } from "components/icons";
|
||||
import Button from "components/button/button";
|
||||
|
||||
interface ProductFiltersProps {
|
||||
open: boolean;
|
||||
sortBy: SortingTypesProps;
|
||||
resultsOrderAscending: boolean;
|
||||
discountedOnly: boolean;
|
||||
themeStyles: React.CSSProperties;
|
||||
onClose: () => void;
|
||||
onSort: (type: SortingTypesProps) => void;
|
||||
onOrderChange: (order: boolean) => void;
|
||||
onShowDiscountedItems: (discounted: boolean) => void;
|
||||
onResetFilters: () => void;
|
||||
}
|
||||
interface FilterItemProps {
|
||||
label: string;
|
||||
value: React.ReactNode;
|
||||
wrapperClass?: string;
|
||||
labelClass?: string;
|
||||
labelWrapperClass?: string;
|
||||
valueClass?: string;
|
||||
}
|
||||
|
||||
const FilterItem: React.FunctionComponent<FilterItemProps> = ({ label, value, wrapperClass, labelClass, labelWrapperClass, valueClass }) => {
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-4 border-b border-gray-100/75 first:pt-0 last:border-0 py-3 ${wrapperClass}`}>
|
||||
<div className={`flex items-center ${labelWrapperClass}`}>
|
||||
<span className={`text-xs sm:text-sm ${labelClass}`}>{label} : </span>
|
||||
</div>
|
||||
<div className={`${valueClass}`}>{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const ProductFilters: React.FunctionComponent<ProductFiltersProps> = ({
|
||||
open = false,
|
||||
sortBy,
|
||||
resultsOrderAscending,
|
||||
discountedOnly,
|
||||
themeStyles,
|
||||
onSort,
|
||||
onClose,
|
||||
onOrderChange,
|
||||
onShowDiscountedItems,
|
||||
onResetFilters
|
||||
}) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
// states
|
||||
const [selectedSize, setSelectedSize] = useState();
|
||||
|
||||
// variables
|
||||
const { locale } = useGetRouter();
|
||||
const iconClass = "inline-block shrink-0 size-3 lg:size-[14px] fill-current";
|
||||
const buttonIconClass = "inline-block shrink-0 size-3 lg:size-4 fill-current me-3";
|
||||
|
||||
const sortingOptions = [
|
||||
{ id: 1, label: translate("date"), value: "date_created", disabled: false },
|
||||
{ id: 2, label: translate("ratings"), value: "ratings.product_quality", disabled: false },
|
||||
// { id: 3, label: translate("sales"), value: "sales", disabled: false },
|
||||
// { id: 4, label: translate("visits"), value: "visits", disabled: false },
|
||||
// { id: 5, label: translate("price"), value: "price", disabled: false },
|
||||
];
|
||||
|
||||
// methods
|
||||
|
||||
// Effects
|
||||
// useEffect(() => {
|
||||
|
||||
// }, []);
|
||||
|
||||
|
||||
return (
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`gallery-delete-modal`}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={translate("products-page-filter-results")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar min-h-96 flex flex-col p-4 justify-between"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="flex flex-col justify-between w-full h-full lg:px-2">
|
||||
{/* products on sale */}
|
||||
<FilterItem
|
||||
label={translate("products-page-filters-modal-discounted-only")}
|
||||
value={
|
||||
<div
|
||||
className={`flex items-center gap-2 w-fit ${!discountedOnly ? "bg-gray-50 text-secondary-light" : "bg-[var(--brand-color)] text-white"} py-2 px-4 rounded-full lg:cursor-pointer select-none`}
|
||||
onClick={() => onShowDiscountedItems(!discountedOnly)}
|
||||
>
|
||||
<PercentageSolid className={`${iconClass} `} />
|
||||
<span className="text-xs sm:text-sm text-current">{translate("products-page-discounted-products-button")}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* sort by */}
|
||||
<FilterItem
|
||||
label={translate("products-page-filters-modal-sort-by")}
|
||||
value={
|
||||
<Select
|
||||
id="products-sort-by"
|
||||
value={sortingOptions.filter(x => x.value === sortBy).map(x => ({ label: x.label, value: x.value, disabled: x.disabled }))[0]}
|
||||
options={sortingOptions.map(x => ({ label: x.label, value: x.value, disabled: x.disabled }))}
|
||||
onChange={(data) => onSort(data.value as SortingTypesProps)}
|
||||
optionsWrapper="inline-block w-full px-2 max-sm:!rounded-full max-sm:!bg-gray-50 !ring-0 capitalize text-xs sm:text-sm max-sm:!py-[6px]"
|
||||
buttonWrapper="!ring-0"
|
||||
wrapperClass="block !min-w-20 bg-gray-50 rounded-full"
|
||||
buttonText='text-xs sm:!text-sm'
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* sort direction */}
|
||||
<FilterItem
|
||||
label={translate("products-page-filters-modal-results-direction")}
|
||||
value={
|
||||
<div
|
||||
className={`flex items-center gap-2 w-fit bg-gray-50 text-secondary-light py-2 px-4 rounded-full lg:cursor-pointer select-none`}
|
||||
onClick={() => onOrderChange(!resultsOrderAscending)}
|
||||
>
|
||||
<ArrowDownArrowUpSolid className={`${iconClass} `} />
|
||||
<span className="text-xs sm:text-sm text-current">
|
||||
{translate(resultsOrderAscending ?
|
||||
(sortBy === "date_created" ? "oldest" : "products-page-sort-direction-highest")
|
||||
:
|
||||
(sortBy === "date_created" ? "newest" : "products-page-sort-direction-lowest")
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 w-full">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("products-page-filters-modal-cta")}
|
||||
className="block w-full mt-4 py-3 px-4 rounded-lg ring-2 ring-[var(--brand-color)] bg-[var(--brand-color)] text-white text-xs sm:text-sm"
|
||||
leftIcon={<CheckSolid className={`${buttonIconClass}`} />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("reset-filters")}
|
||||
className="block w-full mt-4 py-3 px-4 rounded-lg ring-2 ring-[var(--brand-color)] bg-white text-[var(--brand-color)] text-xs sm:text-sm"
|
||||
leftIcon={<ArrowsRotateSolid className={`${buttonIconClass}`} />}
|
||||
onClick={onResetFilters}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default ProductFilters
|
||||
@ -1,22 +1,22 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import Link from "components/link/link"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { fetchPublicData, getLocaleTr, useGetRouter } from 'services/general/general'
|
||||
import { ArrowLeftSolid, ChevronLeftSolid, CircleSolid, PlusSolid } from "components/icons"
|
||||
import { Billboard, BillboardProductsCategory } from "common/types/billboard"
|
||||
import Accordion from "components/accordion/accordion"
|
||||
import { getLocaleTr, useGetRouter } from 'services/general/general'
|
||||
import { ArrowTurnDownLeftSolid, ChevronLeftSolid, CircleSolid } from "components/icons"
|
||||
import { BillboardProductsCategory } from "common/types/billboard"
|
||||
import Modal from "components/modal/modal"
|
||||
|
||||
interface ProductCatsProps {
|
||||
cats: BillboardProductsCategory[]
|
||||
brand_color: string;
|
||||
title: string;
|
||||
allCatsLabel: string;
|
||||
isOpen: boolean;
|
||||
onClose?: () => void;
|
||||
onSelect: (id: number, pId: number, isParent: boolean) => void;
|
||||
}
|
||||
|
||||
|
||||
const ProductCats: React.FunctionComponent<ProductCatsProps> = ({ cats, isOpen = false, onClose, brand_color, onSelect }) => {
|
||||
const ProductCats: React.FunctionComponent<ProductCatsProps> = ({ cats, title, allCatsLabel, isOpen = false, onClose, brand_color, onSelect }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
@ -64,27 +64,29 @@ const ProductCats: React.FunctionComponent<ProductCatsProps> = ({ cats, isOpen =
|
||||
wrapperId={`billboard-product-categories`}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
title={`${translate("billboard-products-cats-title")}`}
|
||||
title={title}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[70vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded -top-[10%] lg:top-0"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
titleClass="text-lg"
|
||||
titleClass="text-lg select-none"
|
||||
style={{
|
||||
"--brand-color": brand_color ?? "#516ec2",
|
||||
"--light-brand-color": brand_color ? `${brand_color}10` : "#f3f5fb",
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<ul className="block py-2">
|
||||
<li className="flex items-center justify-between border-b border-gray-100 lg:cursor-pointer py-2 mx-6" onClick={() => handleCatSelection(-1, -1, false)}>{`${translate("all")} ${translate("products")}`}</li>
|
||||
<ul className="block pt-0 pb-3 lg:pt-2 lg:pb-4">
|
||||
<li className="flex items-center justify-between border-b border-gray-100 lg:cursor-pointer py-3 px-2 mx-6" onClick={() => handleCatSelection(-1, -1, false)}>
|
||||
{allCatsLabel}
|
||||
</li>
|
||||
{cats && cats.filter(x => x.parent.length === 0).map(x => (
|
||||
<li key={x.local_id} className="block text-sm/7 px-6">
|
||||
<div className="flex items-center justify-between border-b border-gray-100 last:border-b-0 lg:cursor-pointer">
|
||||
<li key={x.local_id} className="block text-sm/7 mx-6 border-b border-gray-100 last:border-b-0 ">
|
||||
<div className="flex items-center justify-between px-2 lg:cursor-pointer">
|
||||
<span
|
||||
className={`block py-2 lg:py-3 capitalize ${parentCats && getIsCatOpen(x.local_id) ? "text-[var(--brand-color)]" : ""}`}
|
||||
className={`block w-full py-2 lg:py-3 capitalize ${parentCats && getIsCatOpen(x.local_id) ? "text-[var(--brand-color)]" : ""}`}
|
||||
onClick={() => handleCatSelection(x.local_id, -1, true)}
|
||||
>
|
||||
{getLocaleTr(x, locale).name}
|
||||
</span>
|
||||
{x.parent.length === 0 &&
|
||||
{(x.parent.length === 0 && cats.some(y => y.parent[0] === x.local_id)) &&
|
||||
<ChevronLeftSolid
|
||||
className={`inline-block size-11 sm:size-12 fill-current px-gi py-3 lg:py-4 border-gray-100 ${parentCats && getIsCatOpen(x.local_id) ? "rtl:-rotate-90 ltr:-rotate-90 border-b" : "ltr:rotate-180 border-r"} `}
|
||||
onClick={() => handleCatToggle(x.local_id)}
|
||||
@ -95,10 +97,13 @@ const ProductCats: React.FunctionComponent<ProductCatsProps> = ({ cats, isOpen =
|
||||
{cats.filter(y => y.parent[0] === x.local_id).map(z => (
|
||||
<li
|
||||
key={z.local_id}
|
||||
className={`py-[10px] lg:py-2 rtl:pr-4 ltr:pl-4 border-b border-gray-100 lg:cursor-pointer ${activeCat === z.local_id ? "text-[var(--brand-color)]" : ""} text-xs lg:text-sm capitalize hover:text-[var(--brand-color)]`}
|
||||
className={`py-3 lg:py-3 px-4 lg:px-5 border-t border-gray-100 lg:cursor-pointer
|
||||
${activeCat === z.local_id ? "text-[var(--brand-color)]" : ""}
|
||||
text-xs lg:text-sm capitalize hover:text-[var(--brand-color)]
|
||||
`}
|
||||
onClick={() => handleCatSelection(z.local_id, x.local_id, false)}
|
||||
>
|
||||
<CircleSolid className="inline-block size-[6px] sm:size-2 fill-gray-300 rtl:ml-2 ltr:mr-2 lg:rtl:ml-3 lg:ltr:mr-3" />
|
||||
<ArrowTurnDownLeftSolid className="inline-block size-2 sm:size-[10px] fill-current rtl:me-2 lg:rtl:me-3" />
|
||||
{getLocaleTr(z, locale).name}
|
||||
</li>
|
||||
))}
|
||||
|
||||
@ -1,467 +0,0 @@
|
||||
import { BillboardReservation, BillboardReservationSessionData, BillboardServiceTypes } from "common/types/billboard";
|
||||
import { ServiceProvider, ServiceProviderBillboards, ServiceProviderDateType, ServiceProviderServices, ServiceProviderSessionDetails } from "common/types/service-provider";
|
||||
import { getDateHours, getDatesBetween, getWeekDay, htm, mtd } from "services/general/general";
|
||||
|
||||
interface getReservationDataProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
sessionDetails: {
|
||||
service_id: number;
|
||||
session_dates: {
|
||||
date: string;
|
||||
weekday: "Saturday" | "Sunday" | "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Working days" | "Weekend" | "Everyday";
|
||||
session_data: {
|
||||
from: string;
|
||||
to: string;
|
||||
capacity: number;
|
||||
price_units: BillboardReservationSessionData[];
|
||||
seats_map: number;
|
||||
details: string;
|
||||
}[];
|
||||
closed: boolean;
|
||||
}[];
|
||||
special_dates: {
|
||||
date: string;
|
||||
session_data: {
|
||||
from: string;
|
||||
to: string;
|
||||
capacity: number;
|
||||
price_units: BillboardReservationSessionData[];
|
||||
seats_map: number;
|
||||
details: string;
|
||||
}[];
|
||||
closed: boolean;
|
||||
}[];
|
||||
capacity: number;
|
||||
price_units: BillboardReservationSessionData[];
|
||||
dynamic_dates?: boolean;
|
||||
};
|
||||
serviceTypes: BillboardServiceTypes[];
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
|
||||
interface getAvailableDatesProps {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
serviceType: BillboardServiceTypes;
|
||||
serviceProvider: ServiceProvider;
|
||||
billboardId: string;
|
||||
}
|
||||
|
||||
interface ServiceProvidersDatesProps {
|
||||
date: Date;
|
||||
weekday: string;
|
||||
closed: boolean;
|
||||
}
|
||||
|
||||
export type withProviderSharedProps = {
|
||||
date: Date;
|
||||
provider: ServiceProvider;
|
||||
service: BillboardServiceTypes;
|
||||
subServiceId: number;
|
||||
billboardId: string;
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
|
||||
export type withProviderShared = {
|
||||
selectedDate: ServiceProviderDateType;
|
||||
specificDate: ServiceProviderDateType;
|
||||
providerBillboard: ServiceProviderBillboards;
|
||||
providerService: ServiceProviderServices;
|
||||
providerServiceSession: ServiceProviderSessionDetails;
|
||||
provider: ServiceProvider;
|
||||
dayStart: number;
|
||||
dayEnd: number;
|
||||
minute: number;
|
||||
date: Date;
|
||||
weekday: string;
|
||||
service: BillboardServiceTypes;
|
||||
subService: BillboardReservationSessionData;
|
||||
subServiceId: number;
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
|
||||
interface getRealTimeReservationDataProps {
|
||||
service: BillboardServiceTypes;
|
||||
subService: BillboardReservationSessionData;
|
||||
sharedData: withProviderShared;
|
||||
reservations: BillboardReservation[];
|
||||
}
|
||||
|
||||
interface slotsProps {
|
||||
from: string;
|
||||
to: string;
|
||||
capacity: number;
|
||||
reserved: number;
|
||||
price_units: BillboardReservationSessionData[];
|
||||
}
|
||||
|
||||
interface restHoursProps {
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
interface withProviderPriceProps {
|
||||
sharedData: withProviderShared;
|
||||
service: BillboardServiceTypes;
|
||||
subServiceId: number;
|
||||
}
|
||||
interface withProviderItemsProps {
|
||||
sharedData: withProviderShared;
|
||||
}
|
||||
|
||||
// returns selected reservation's correct time, price & capacity, etc. based on the selected date
|
||||
export const getReservationData = ({ startDate, endDate, sessionDetails, serviceTypes, reservations }: getReservationDataProps ) => {
|
||||
const dates = sessionDetails.session_dates;
|
||||
const specialDates = sessionDetails.special_dates;
|
||||
const isDynamic = sessionDetails.dynamic_dates;
|
||||
const datesBetween = getDatesBetween(startDate, endDate);
|
||||
const eventDates:any[] = [];
|
||||
const finalDates:any[] = [];
|
||||
|
||||
if (isDynamic) {
|
||||
if (specialDates) {
|
||||
eventDates.map(x => specialDates.map(y => Date.parse(y.date) === Date.parse(x.date) ?
|
||||
finalDates.push({
|
||||
date: new Date(y.date),
|
||||
weekday: getWeekDay(new Date(y.date).getDay()),
|
||||
session_data: y.session_data,
|
||||
closed: y.closed
|
||||
}) : finalDates.push(x)));
|
||||
} else {
|
||||
eventDates.map(x => finalDates.push(x));
|
||||
}
|
||||
} else {
|
||||
datesBetween.map(x => {
|
||||
eventDates.push({
|
||||
date: x,
|
||||
weekday: getWeekDay(x.getDay()),
|
||||
session_data: dates.filter(y => y.weekday === getWeekDay(x.getDay()))[0].session_data,
|
||||
closed: dates.filter(y => y.weekday === getWeekDay(x.getDay()))[0].closed
|
||||
});
|
||||
});
|
||||
if (specialDates) { // taking special dates into account (might be closed / operate on diff hours)
|
||||
eventDates.map(x => specialDates.map(y => Date.parse(y.date) === Date.parse(x.date) ?
|
||||
finalDates.push({
|
||||
date: new Date(y.date),
|
||||
weekday: getWeekDay(new Date(y.date).getDay()),
|
||||
session_data: y.session_data,
|
||||
closed: y.closed
|
||||
}) : finalDates.push(x)));
|
||||
} else {
|
||||
eventDates.map(x => finalDates.push(x));
|
||||
}
|
||||
}
|
||||
|
||||
let completeFinalDates = finalDates.map(x => x.closed ? x : {...x, session_data:
|
||||
x.session_data.map((y:any) => y = {
|
||||
from: y.from,
|
||||
to: y.to,
|
||||
capacity: y.capacity ?? ((sessionDetails.capacity || serviceTypes.length === 0) ? sessionDetails.capacity : serviceTypes[sessionDetails.service_id - 1].session_capacity),
|
||||
price_units: y.price_units ?? ((sessionDetails.price_units || serviceTypes.length === 0) ? sessionDetails.price_units : serviceTypes[sessionDetails.service_id - 1].session_data),
|
||||
seats_map: y.seats_map ? y.seats_map : 1,
|
||||
details: y.details,
|
||||
reserved: reservations.length > 0 ? reservations.filter(z => (Date.parse(z.date) === Date.parse(x.date) && z.start_time === y.from)).length : 0,
|
||||
reservedSeats: reservations.length > 0 ? reservations.filter(z => (Date.parse(z.date) === Date.parse(x.date) && z.start_time === y.from)).map(x => x.ticket_data).map(x => x.map(y => y.seat_number)).flat() : []
|
||||
})});
|
||||
|
||||
return completeFinalDates; // final available dates; takes special dates like holidays or any other diff dates into account
|
||||
}
|
||||
|
||||
|
||||
|
||||
// for businesses with service providers
|
||||
export const getServiceProvidersDates = ({ startDate, endDate, serviceType, serviceProvider, billboardId }: getAvailableDatesProps ) => {
|
||||
const datesBetween = getDatesBetween(startDate, endDate);
|
||||
const dates: ServiceProvidersDatesProps[] = [];
|
||||
const workingDates = serviceProvider.billboards.filter(x => x.Billboards_id.id === billboardId)[0].working_days;
|
||||
const specialDates = serviceProvider.billboards[0].special_dates;
|
||||
|
||||
datesBetween.map(x => (
|
||||
dates.push({
|
||||
date: x,
|
||||
weekday: getWeekDay(x.getDay()),
|
||||
closed: workingDates.some(y =>
|
||||
y.weekday === getWeekDay(x.getDay()) &&
|
||||
(
|
||||
y.not_working || // service provider doesn't works in that day of the week
|
||||
y.service_types?.filter(x => x.service_id === serviceType.service_id)[0]?.not_today || // service provider doesn't do that particular service in that day
|
||||
specialDates?.filter(date => Date.parse(date.date) === x.getTime())[0]?.not_working // service provider doesn't works in that particular date
|
||||
)
|
||||
)
|
||||
})
|
||||
));
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const providerSharedData = ({ date, provider, service, subServiceId, billboardId }: withProviderSharedProps) => {
|
||||
const weekday = getWeekDay(date.getDay());
|
||||
const minute = 60 * 1000 // one min = 60,000 milliseconds
|
||||
|
||||
const providerBillboard = provider.billboards.filter(x => x.Billboards_id.id === billboardId)[0];
|
||||
const providerService = provider.services.filter(x => x.billboard_service_types_id.service_id === service.service_id)[0];
|
||||
const providerServiceSession = providerService.session_details?.filter(x => x.type_id === subServiceId)[0];
|
||||
|
||||
const selectedDate = providerBillboard.working_days.filter(x => x.weekday === weekday)[0];
|
||||
const specificDate = providerBillboard.special_dates?.filter(x => Date.parse(x.date) === date.getTime())[0];
|
||||
|
||||
const start = selectedDate.from ? htm(selectedDate.from) : 0;
|
||||
const finish = selectedDate.to ? htm(selectedDate.to) : 0;
|
||||
|
||||
return {
|
||||
selectedDate: selectedDate, // provider's selected date (weekly) data
|
||||
specificDate: specificDate, // provider's selected date (specific) data
|
||||
providerBillboard: providerBillboard, // provider's billboard data
|
||||
providerService: providerService, // provider's offered services data
|
||||
providerServiceSession: providerServiceSession, // provider's offered services session data
|
||||
provider: provider,
|
||||
dayStart: start, // selected date's starting hour
|
||||
dayEnd: finish, // selected date's finish hour
|
||||
minute: minute, // in milliseconds
|
||||
date: date, // current selected date's weekday
|
||||
weekday: weekday, // current selected date's weekday
|
||||
service: service,
|
||||
subService: service.session_data.filter(x => x.type_id === subServiceId)[0],
|
||||
subServiceId: subServiceId,
|
||||
};
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderPrice = ({ sharedData, service, subServiceId }: withProviderPriceProps) => {
|
||||
|
||||
// price
|
||||
const specificDayPrice = sharedData.specificDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0]?.price;
|
||||
const workDayPrice = sharedData.selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0]?.price;
|
||||
const providerPrice = sharedData.providerService?.session_details?.filter(x => x.type_id === subServiceId)[0]?.price;
|
||||
const servicePrice = service.session_data.filter(x => x.type_id === subServiceId)[0].price;
|
||||
|
||||
// discount price
|
||||
const specificDayDiscountPrice = sharedData.specificDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0]?.discount_price;
|
||||
const workDayDiscountPrice = sharedData.selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0]?.discount_price;
|
||||
const providerDiscountPrice = sharedData.providerService?.session_details?.filter(x => x.type_id === subServiceId)[0]?.discount_price;
|
||||
const serviceDiscountPrice = service.session_data.filter(x => x.type_id === subServiceId)[0].discount_price;
|
||||
|
||||
const price = (
|
||||
specificDayPrice ?? // price set for that particular service for that specific date by the provider
|
||||
workDayPrice ?? // price set for that particular service for that day of the week by the provider (repeats weekly)
|
||||
providerPrice ?? // price set for that particular service by the provider
|
||||
servicePrice // price set for that particular service by the billboard
|
||||
);
|
||||
|
||||
const discountPrice = (
|
||||
specificDayDiscountPrice ?? // discount price set for that particular service for that specific date by the provider
|
||||
workDayDiscountPrice ?? // discount price set for that particular service for that day of the week by the provider (repeats weekly)
|
||||
providerDiscountPrice ?? // discount price set for that particular service by the provider
|
||||
serviceDiscountPrice // discount price set for that particular service by the billboard
|
||||
);
|
||||
|
||||
return { price: price, discountPrice: discountPrice };
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderGaps = ({ sharedData }: withProviderItemsProps) => {
|
||||
const service = sharedData.service;
|
||||
const subServiceId = sharedData.subServiceId;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const providerServiceSession = sharedData.providerServiceSession;
|
||||
const providerService = sharedData.providerService;
|
||||
const providerBillboard = sharedData.providerBillboard;
|
||||
const subService = sharedData.subService;
|
||||
const minute = sharedData.minute;
|
||||
|
||||
// gaps
|
||||
const serviceWorkDayGap = selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0].gap;
|
||||
const serviceSpecificDayGap = specialDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0].gap;
|
||||
const workDayGap = selectedDate.today_gap;
|
||||
const specificDayGap = specialDate?.today_gap;
|
||||
|
||||
const gap = (
|
||||
serviceSpecificDayGap ?? // gap set by the provider for that sub service in that specific date
|
||||
serviceWorkDayGap ?? // gap set by the provider for that sub service in that day of the week (repeats weekly)
|
||||
specificDayGap ?? // gap set by the provider for all services in that specific date
|
||||
workDayGap ?? // gap set by the provider for all services in that day of the week
|
||||
providerServiceSession?.gap ?? // gap set by the provider for that sub service
|
||||
providerService.service_gap ?? // gap set by the provider for that service (all type/subs affected)
|
||||
providerBillboard.general_gap ?? // general gap set by the provider for all the services performed at that billboard
|
||||
subService.gap // general gap set for that service by the billboard
|
||||
) * minute;
|
||||
|
||||
return gap;
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderCapacity = ({ sharedData }: withProviderItemsProps) => {
|
||||
const service = sharedData.service;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const providerService = sharedData.providerService;
|
||||
|
||||
// capacity
|
||||
const workDayCapacity = selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.capacity;
|
||||
const specificDayCapacity = specialDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.capacity;
|
||||
|
||||
const capacity = (
|
||||
specificDayCapacity ?? // capacity set for that particular service for that specific date by the provider
|
||||
workDayCapacity ?? // capacity set for that particular service for that day of the week by the provider (repeats weekly)
|
||||
providerService?.capacity ?? // capacity set for that particular service by the provider
|
||||
service.session_capacity // capacity set for that particular service by the billboard
|
||||
);
|
||||
|
||||
return capacity;
|
||||
}
|
||||
|
||||
// for businesses with service providers
|
||||
export const withProviderDuration = ({ sharedData }: withProviderItemsProps) => {
|
||||
const service = sharedData.service;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const subServiceId = sharedData.subServiceId;
|
||||
const providerServiceSession = sharedData.providerServiceSession;
|
||||
const subService = sharedData.subService;
|
||||
const minute = sharedData.minute;
|
||||
|
||||
// durations
|
||||
const workDayDuration = selectedDate.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0].session_duration;
|
||||
const specificDateDuration = specialDate?.service_types?.filter(x => x.service_id === service.service_id)[0]?.session_details?.filter(x => x.type_id === subServiceId)[0].session_duration;
|
||||
|
||||
const duration = (
|
||||
specificDateDuration ?? // session duration set for that service session for that specific date by the provider
|
||||
workDayDuration ?? // session duration set for that service session for that day of the week by the provider (repeats weekly)
|
||||
providerServiceSession?.session_duration ?? // session duration set for that service session by the provider
|
||||
subService.session_duration // session duration set for that particular service session by the billboard
|
||||
) * minute;
|
||||
|
||||
return duration;
|
||||
}
|
||||
|
||||
|
||||
// for businesses with service providers
|
||||
export const getRealTimeReservationData = ({ service, subService, sharedData, reservations }: getRealTimeReservationDataProps ) => {
|
||||
const minute = sharedData.minute;
|
||||
const providerBillboard = sharedData.providerBillboard;
|
||||
const selectedDate = sharedData.selectedDate;
|
||||
const specialDate = sharedData.specificDate;
|
||||
const start = sharedData.dayStart;
|
||||
const finish = sharedData.dayEnd;
|
||||
const date = sharedData.date;
|
||||
|
||||
const price = withProviderPrice({
|
||||
sharedData: sharedData,
|
||||
service: service,
|
||||
subServiceId: subService.type_id
|
||||
});
|
||||
const gap = withProviderGaps({
|
||||
sharedData: sharedData
|
||||
});
|
||||
const capacity = withProviderCapacity({
|
||||
sharedData: sharedData
|
||||
});
|
||||
const duration = withProviderDuration({
|
||||
sharedData: sharedData
|
||||
});
|
||||
|
||||
const durationAndGap = duration + gap;
|
||||
const buffer = providerBillboard.day_start_buffer * minute;
|
||||
const numberOfSessions = Math.floor(((finish + gap) - (start + buffer)) / durationAndGap);
|
||||
const restHours: restHoursProps[] = [];
|
||||
const availableFromHours: restHoursProps[] = [];
|
||||
const relevantReservations = reservations.filter(x =>
|
||||
// (x.service_providers.some(y => y.service_provider_id.provider_id === sharedData.provider.provider_id))
|
||||
(x.service_id === service.service_id) &&
|
||||
(x.session_type_id === subService.type_id) &&
|
||||
(Date.parse(x.date) === date.getTime())
|
||||
);
|
||||
const colidingReservations = reservations.filter(x =>
|
||||
(x.service_providers.some(y => y.service_provider_id.provider_id === sharedData.provider.provider_id)) &&
|
||||
(Date.parse(x.date) === date.getTime())
|
||||
);
|
||||
const slots: slotsProps[] = [];
|
||||
let finalSlots: slotsProps[] = [];
|
||||
|
||||
// converting rest hours to milliseconds
|
||||
selectedDate.rest_hours && selectedDate.rest_hours.map(x => restHours.push(
|
||||
{
|
||||
from: htm(x.from),
|
||||
to: htm(x.to)
|
||||
}
|
||||
))
|
||||
// converting available hours to milliseconds
|
||||
selectedDate.service_types && service && selectedDate.service_types.filter(x => x.service_id === service.service_id)[0]?.available_from.map(x => availableFromHours.push(
|
||||
{
|
||||
from: htm(x.from),
|
||||
to: htm(x.to)
|
||||
}
|
||||
))
|
||||
|
||||
for (let i = 0; i < numberOfSessions; i++) {
|
||||
slots.push({
|
||||
from: getDateHours((start + buffer) + (i * durationAndGap)),
|
||||
to: getDateHours((start + buffer) + ((i * durationAndGap) + duration)),
|
||||
capacity: capacity,
|
||||
reserved: relevantReservations.filter(x => htm(x.start_time) === htm(getDateHours((start + buffer) + (i * durationAndGap)))).length,
|
||||
price_units: [{
|
||||
type_id: subService.type_id,
|
||||
persian_title: subService.persian_title,
|
||||
english_title: subService.english_title,
|
||||
price: price.price,
|
||||
discount_price: price.discountPrice,
|
||||
session_duration: duration / minute,
|
||||
gap: gap
|
||||
}]
|
||||
})
|
||||
}
|
||||
|
||||
const slotValidation = () => {
|
||||
|
||||
// no rest hours confliction
|
||||
const restValidatedSlots = slots.filter(slot =>
|
||||
(specialDate && specialDate.rest_hours) ?
|
||||
!specialDate.rest_hours.some(rest => // specific rest period in that date
|
||||
(htm(slot.from) >= htm(rest.from) && htm(slot.to) <= htm(rest.to))
|
||||
)
|
||||
:
|
||||
!restHours.some(rest => // general rest hours
|
||||
(htm(slot.from) >= rest.from && htm(slot.to) <= rest.to)
|
||||
)
|
||||
);
|
||||
// adhere to service availibility hours (if any)
|
||||
const hoursAvailibilityChecked = restValidatedSlots.filter(slot =>
|
||||
!availableFromHours.some(x =>
|
||||
(htm(slot.from) < x.from || htm(slot.to) > x.to)
|
||||
)
|
||||
);
|
||||
// consider special dates (if any)
|
||||
const specialDatesChecked = hoursAvailibilityChecked.filter(slot =>
|
||||
specialDate ? (
|
||||
(htm(slot.from) >= htm(specialDate.from) && htm(slot.to) <= htm(specialDate.to)) && // adhere to the dates's special working hour
|
||||
!specialDate.service_types.filter(x => x.not_today).map(x => x.service_id).includes(service.service_id) // specific services not avilable in that date
|
||||
) : true
|
||||
);
|
||||
// remove slots which time has passed now
|
||||
const timeChecked = specialDatesChecked.filter(slot =>
|
||||
date.getTime() === Date.parse(mtd(new Date().getTime())) ? htm(slot.from) >= new Date().getTime() : true
|
||||
);
|
||||
// remove slots which time colides with other reservations for that provider in that hour
|
||||
const reservationColidingCheck = timeChecked.filter(slot =>
|
||||
!colidingReservations.some(x =>
|
||||
(x.service_id !== service.service_id) &&
|
||||
((htm(slot.from) >= htm(x.start_time) && htm(slot.to) <= htm(x.end_time)) ||
|
||||
(htm(slot.to) === htm(x.start_time) || htm(slot.from) === htm(x.end_time)))
|
||||
)
|
||||
);
|
||||
// service provider is busy now
|
||||
const busyCheck = reservationColidingCheck.filter(slot =>
|
||||
!providerBillboard.busy
|
||||
);
|
||||
|
||||
return busyCheck;
|
||||
};
|
||||
|
||||
finalSlots = slots.length > 0 ? slotValidation() : [];
|
||||
|
||||
|
||||
return finalSlots;
|
||||
}
|
||||
@ -1,10 +1,10 @@
|
||||
import React, { useEffect, useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { basePath, fetchPublicData, getLocaleTr, hasValue, url, useGetRouter } from 'services/general/general'
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, BillboardServiceTypes } from "common/types/billboard"
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, BillboardServiceType, ProductVariantGalleries } from "common/types/billboard"
|
||||
import useSWR from "swr"
|
||||
import InnerLoading from "components/loading/inner-loading"
|
||||
import { ArrowLeftSolid, BoxOpenSolid, MagnifyingGlass, XMark } from "components/icons"
|
||||
import { ArrowDownArrowUpSolid, ArrowLeftSolid, ArrowsRotateSolid, BoxOpenSolid, FilterListSolid, MagnifyingGlass, PercentageSolid, SortSolid, XMark } from "components/icons"
|
||||
import Pagination from "../../../../components/general/pagination"
|
||||
import Input from "components/input/text"
|
||||
import ProductItem from "./cards/product-item"
|
||||
@ -14,10 +14,17 @@ import ProductsPlaceHolder from "./products-placeholder"
|
||||
import { useUpdateQueryParams } from "services/general/update-query"
|
||||
import Button from "components/button/button"
|
||||
import { safeJson } from "lib/general/safe-response-json"
|
||||
import ReservationItem from "./cards/reservation-item"
|
||||
import Select from "components/select/select"
|
||||
import Modal from "components/modal/modal"
|
||||
import ProductFilters from "./filters"
|
||||
import NotificationSign from "components/notification-sign/notification-sign"
|
||||
|
||||
interface BillboardProductsProps {
|
||||
billboard: Billboard;
|
||||
themeStyles: React.CSSProperties;
|
||||
}
|
||||
export type SortingTypesProps = "date_created" | "ratings.product_quality" | "sales" | "visits" | "price";
|
||||
interface ProductsContainerProps {
|
||||
items: {
|
||||
item: BillboardProduct;
|
||||
@ -30,11 +37,12 @@ interface ProductsContainerProps {
|
||||
pageSize: number;
|
||||
activePage: number;
|
||||
productTypes: ["realestate" | "vehicle" | "shop" | "event" | "reservation"];
|
||||
serviceTypes: BillboardServiceTypes[];
|
||||
serviceTypes: BillboardServiceType[];
|
||||
variantGalleries: ProductVariantGalleries[];
|
||||
onPageSelect: (page: number) => void;
|
||||
}
|
||||
|
||||
const ProductsContainer: React.FunctionComponent<ProductsContainerProps> = ({ totalItemsCount, items, priceData, billboardId, billboardTitle, pageSize, activePage = 1, productTypes = ["shop"], serviceTypes, onPageSelect }) => {
|
||||
const ProductsContainer: React.FunctionComponent<ProductsContainerProps> = ({ totalItemsCount, items, priceData, billboardId, billboardTitle, pageSize, activePage = 1, productTypes = ["shop"], serviceTypes, variantGalleries, onPageSelect }) => {
|
||||
|
||||
// states
|
||||
const [currentPage, setCurrentPage] = useState(activePage);
|
||||
@ -53,7 +61,7 @@ const ProductsContainer: React.FunctionComponent<ProductsContainerProps> = ({ to
|
||||
<div className="flex flex-col space-y-2">
|
||||
{/* shop products */}
|
||||
{(currentProductType === "shop") &&
|
||||
<div className={`grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-4 w-full py-4`}>
|
||||
<div className={`grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-2 sm:gap-4 w-full py-4`}>
|
||||
{items.map(x => (
|
||||
<ProductItem key={x.item.id} item={x.item} priceData={priceData.filter(y => String(y.id) === String(x.item.id))[0]} billboardId={billboardId} billboardTitle={billboardTitle} rating={x.rating} />
|
||||
))}
|
||||
@ -63,9 +71,9 @@ const ProductsContainer: React.FunctionComponent<ProductsContainerProps> = ({ to
|
||||
{/* reservation product */}
|
||||
{currentProductType === "reservation" && serviceTypes.length > 0 &&
|
||||
<div className={`grid grid-cols-1 md:grid-cols-2 lg:grid-cols-1 xl:grid-cols-2 gap-4 w-full py-4`}>
|
||||
{/* {items.map(x => (
|
||||
<ReservationItem key={x.item.id} item={x.item} billboardId={billboardId} billboardTitle={billboardTitle} rating={x.rating} serviceTypes={serviceTypes} />
|
||||
))} */}
|
||||
{items.map(x => (
|
||||
<ReservationItem key={x.item.id} item={x.item} billboardId={billboardId} billboardTitle={billboardTitle} rating={x.rating} serviceTypes={serviceTypes} variantGalleries={variantGalleries} />
|
||||
))}
|
||||
</div>
|
||||
}
|
||||
<Pagination itemsCount={totalItemsCount} activePage={currentPage} pageSize={pageSize} onSelect={handleSelection} />
|
||||
@ -73,7 +81,7 @@ const ProductsContainer: React.FunctionComponent<ProductsContainerProps> = ({ to
|
||||
)
|
||||
}
|
||||
|
||||
const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ billboard }) => {
|
||||
const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ billboard, themeStyles }) => {
|
||||
|
||||
// states
|
||||
const [activeCats, setActiveCats] = useState<number[]>([0]);
|
||||
@ -81,12 +89,51 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
const [searchValue, setSearchValue] = useState("");
|
||||
const [searchVisible, setSearchVisible] = useState(false);
|
||||
const [priceData, setPriceData] = useState<PriceDataProps[]>([]);
|
||||
const [discountedOnly, setDiscountedOnly] = useState<boolean>(false);
|
||||
const [resultsOrderAscending, setResultsOrderAscending] = useState<boolean>(false);
|
||||
const [sortBy, setSortBy] = useState<SortingTypesProps>("date_created");
|
||||
const [filtersOpen, setFiltersOpen] = useState(false);
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, router, query } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
const itemsPerPage = 12;
|
||||
const areFiltersActive = discountedOnly;
|
||||
|
||||
const variantGalleriesQuery = `
|
||||
variant_galleries
|
||||
(
|
||||
filter: {
|
||||
billboard: { id: { _eq: "${billboard.id}" }},
|
||||
status: { _eq: "published" }
|
||||
},
|
||||
sort: ["sort", "-date_created"],
|
||||
limit: -1,
|
||||
)
|
||||
{
|
||||
id
|
||||
billboard {
|
||||
id
|
||||
}
|
||||
product {
|
||||
id
|
||||
}
|
||||
gallery {
|
||||
sort_id
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
description
|
||||
filename_download
|
||||
width
|
||||
height
|
||||
type
|
||||
filesize
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
const ratingsQuery = `
|
||||
billboard_product_ratings(filter: { status: { _eq: "published" }}) {
|
||||
id
|
||||
@ -97,7 +144,7 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
}
|
||||
`
|
||||
const serviceTypesQuery = `
|
||||
billboard_service_types (filter: { status: { _eq: "published" }, billboard: { id: { _eq: ${billboard.id} }}}) {
|
||||
billboard_service_types (filter: { status: { _eq: "published" }, service: { billboard: { Billboards_id: { id: { _eq: ${billboard.id} }}}}}) {
|
||||
id
|
||||
service_id
|
||||
translations {
|
||||
@ -114,11 +161,29 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
width
|
||||
height
|
||||
}
|
||||
billboard {
|
||||
service {
|
||||
id
|
||||
}
|
||||
session_capacity
|
||||
session_data
|
||||
category
|
||||
session_capacity
|
||||
session_duration
|
||||
gap
|
||||
ticket_types {
|
||||
reservation_ticket_type_id {
|
||||
id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
title
|
||||
description
|
||||
}
|
||||
}
|
||||
price
|
||||
discount_price
|
||||
}
|
||||
dates_type
|
||||
service_dates
|
||||
}
|
||||
`
|
||||
const categoriesQuery = `
|
||||
@ -156,9 +221,21 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
}
|
||||
`
|
||||
const itemsQuery = `
|
||||
filteredAggregatedProducts: billboard_products_aggregated(
|
||||
filteredAggregatedProducts: billboard_products_aggregated (
|
||||
filter: {
|
||||
billboard: { Billboards_id: { id: { _eq: "${billboard.id}" }}},
|
||||
${discountedOnly ? `
|
||||
_or: [
|
||||
{
|
||||
price_entries: { discounted_price: { _nnull: true } }
|
||||
},
|
||||
{
|
||||
variations: { discounted_price: { _nnull: true } }
|
||||
}
|
||||
]`
|
||||
:
|
||||
""
|
||||
},
|
||||
translations: {
|
||||
languages_code: { code: { _eq: "${locale === 'fa' ? 'fa-IR' : 'en-US'}" }},
|
||||
${hasValue(searchValue) ? `title: { _icontains: "${searchValue}" }` : ""}
|
||||
@ -167,7 +244,9 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
`category: {
|
||||
local_id: { _in: ${JSON.stringify(activeCats)} }
|
||||
},`
|
||||
: ""},
|
||||
:
|
||||
""
|
||||
},
|
||||
status: { _eq: "published" }
|
||||
},
|
||||
sort: ["sort", "-date_created"]
|
||||
@ -181,6 +260,18 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
(
|
||||
filter: {
|
||||
billboard: { Billboards_id: { id: { _eq: "${billboard.id}" }}},
|
||||
${discountedOnly ? `
|
||||
_or: [
|
||||
{
|
||||
price_entries: { discounted_price: { _nnull: true } }
|
||||
},
|
||||
{
|
||||
variations: { discounted_price: { _nnull: true } }
|
||||
}
|
||||
]`
|
||||
:
|
||||
""
|
||||
},
|
||||
translations: {
|
||||
languages_code: { code: { _eq: "${locale === 'fa' ? 'fa-IR' : 'en-US'}" }},
|
||||
${hasValue(searchValue) ? `title: { _icontains: "${searchValue}" }` : ""}
|
||||
@ -189,10 +280,12 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
`category: {
|
||||
local_id: { _in: ${JSON.stringify(activeCats)} }
|
||||
},`
|
||||
: ""},
|
||||
:
|
||||
""
|
||||
},
|
||||
status: { _eq: "published" }
|
||||
},
|
||||
sort: ["sort", "-date_created"],
|
||||
sort: ["${resultsOrderAscending ? '' : '-'}${sortBy}"],
|
||||
limit: ${itemsPerPage},
|
||||
page: ${currentPage}
|
||||
)
|
||||
@ -210,9 +303,21 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
description
|
||||
}
|
||||
is_featured
|
||||
brand {
|
||||
id
|
||||
english_name
|
||||
persian_name
|
||||
}
|
||||
variations {
|
||||
id
|
||||
variant_id
|
||||
main_variant
|
||||
color {
|
||||
id
|
||||
persian_name
|
||||
english_name
|
||||
hex_code
|
||||
}
|
||||
pics {
|
||||
variant_galleries_id {
|
||||
id
|
||||
@ -256,7 +361,6 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
}
|
||||
parent
|
||||
}
|
||||
session_details
|
||||
ad_item {
|
||||
id
|
||||
translations {
|
||||
@ -281,6 +385,7 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
}
|
||||
`
|
||||
|
||||
const { data: productsVariantGalleries, error: variantGalleriesError } = useSWR(['', variantGalleriesQuery], ([path, url]) => fetchPublicData(path, url));
|
||||
const { data: serviceTypes, error: serviceTypesError } = useSWR(['', serviceTypesQuery], ([path, url]) => fetchPublicData(path, url));
|
||||
const { data: productCats, error: productCatsError } = useSWR(['', categoriesQuery], ([path, url]) => fetchPublicData(path, url));
|
||||
const { data: productRatings, error: productRatingsError } = useSWR(['', ratingsQuery], ([path, url]) => fetchPublicData(path, url));
|
||||
@ -288,12 +393,14 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
const { data: currentProducts, error: currentProductsError } = useSWR(activeCats[0] !== -1 || currentPage !== 1 || hasValue(searchValue) ? ['', itemsQuery] : null, ([path, url]) => fetchPublicData(path, url));
|
||||
const productsList: BillboardProduct[] = currentProducts ? currentProducts.data.billboard_products : [];
|
||||
|
||||
variantGalleriesError && console.log(variantGalleriesError);
|
||||
serviceTypesError && console.log(serviceTypesError);
|
||||
productRatingsError && console.log(productRatingsError);
|
||||
productCatsError && console.log(productCatsError);
|
||||
allProductsError && console.log(allProductsError);
|
||||
currentProductsError && console.log(currentProductsError);
|
||||
|
||||
const variantGalleries = productsVariantGalleries?.data.variant_galleries;
|
||||
const serviceTypesList = serviceTypes?.data.billboard_service_types;
|
||||
const ratings = productRatings?.data.billboard_product_ratings;
|
||||
const cats: BillboardProductsCategory[] = productCats?.data.billboard_product_categories;
|
||||
@ -338,6 +445,14 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
router.push(`/billboard/${billboard.id}/${url(billboardTitle)}/products`);
|
||||
setSearchValue("");
|
||||
}
|
||||
const handleSortTypeSelection = (value: SortingTypesProps) => {
|
||||
setSortBy(value);
|
||||
}
|
||||
const resetFilters = () => {
|
||||
setDiscountedOnly(false);
|
||||
setResultsOrderAscending(false);
|
||||
setSortBy("date_created");
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
@ -361,28 +476,55 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
{totalProductsCount > 0 ?
|
||||
<>
|
||||
{/* product categories */}
|
||||
<div className="block lg:py-[10px] lg:mt-4 border-b border-gray-200/50 pb-4 max-lg:px-4 max-lg:pt-2">
|
||||
<div className="relative px-4 lg:px-0 pt-2">
|
||||
{cats ?
|
||||
<ProductCatsFilter
|
||||
cats={cats}
|
||||
billboard={{
|
||||
id: billboard.id,
|
||||
title: billboardTitle,
|
||||
brand_color: billboard.brand_color
|
||||
}}
|
||||
billboard={billboard}
|
||||
onChange={setActiveCats}
|
||||
/>
|
||||
:
|
||||
<InnerLoading loadingText={""} width={"200"} height={"100"} />
|
||||
}
|
||||
|
||||
{/* filters & reset button */}
|
||||
<div className="flex items-center gap-3 absolute rtl:left-4 ltr:right-4 top-4 lg:top-5">
|
||||
<div
|
||||
className={`${areFiltersActive ? "flex" : "hidden"} reactive-button items-center shadow-sm bg-white text-secondary-light py-2 px-2 rounded-full lg:cursor-pointer select-none`}
|
||||
onClick={resetFilters}
|
||||
title={translate("reset-filters")}
|
||||
>
|
||||
<ArrowsRotateSolid className="inline-block shrink-0 size-3 lg:size-4 fill-current" />
|
||||
</div>
|
||||
<div
|
||||
className={`flex items-center gap-2 relative shadow-sm bg-white text-secondary-light py-2 px-4 rounded-full lg:cursor-pointer select-none capitalize`}
|
||||
onClick={() => setFiltersOpen(true)}
|
||||
>
|
||||
{areFiltersActive && <NotificationSign wrapperClass="absolute right-[2px] -top-[2px]" />}
|
||||
<FilterListSolid className="inline-block shrink-0 size-3 lg:size-4 fill-current" />
|
||||
<span className="text-xs sm:text-sm text-current">{translate("products-page-filter-results")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductFilters
|
||||
open={filtersOpen}
|
||||
sortBy={sortBy}
|
||||
resultsOrderAscending={resultsOrderAscending}
|
||||
discountedOnly={discountedOnly}
|
||||
themeStyles={themeStyles}
|
||||
onClose={() => setFiltersOpen(false)}
|
||||
onSort={handleSortTypeSelection}
|
||||
onOrderChange={setResultsOrderAscending}
|
||||
onShowDiscountedItems={setDiscountedOnly}
|
||||
onResetFilters={resetFilters}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* search & count */}
|
||||
<div className="flex items-center justify-between w-full max-lg:px-4 pt-4 pb-2">
|
||||
{/* number of products */}
|
||||
<div className={`${searchVisible ? "hidden" : "flex"} md:flex shrink-0 items-center space-x-1 rtl:space-x-reverse py-2 px-3 rounded-lg bg-[var(--light-brand-color)]`}>
|
||||
<span className="inline-block text-xs first-letter:capitalize">{`${translate("number-of")} ${translate("products")}`} :</span>
|
||||
<span className="inline-block text-sm font-extrabold text-[var(--brand-color)]">{currentProductsCount}</span>
|
||||
<div className={`${searchVisible ? "hidden" : "flex"} md:flex shrink-0 items-center space-x-1 rtl:space-x-reverse py-2 px-3 rounded-lg bg-white`}>
|
||||
<span className="inline-block text-xs font-extrabold text-secondary-light">{`${currentProductsCount ?? 0} ${translate("product")}${locale === "en" ? "s" : ""}`}</span>
|
||||
</div>
|
||||
{/* products search */}
|
||||
<div className={`flex items-center ${searchVisible ? "max-md:w-full" : ""}`}>
|
||||
@ -428,17 +570,18 @@ const BillboardProducts: React.FunctionComponent<BillboardProductsProps> = ({ bi
|
||||
totalItemsCount={currentProductsCount}
|
||||
productTypes={billboard.product_types}
|
||||
serviceTypes={serviceTypes ? serviceTypesList : []}
|
||||
variantGalleries={variantGalleries}
|
||||
/>
|
||||
:
|
||||
<div className="flex flex-col w-4/5 mx-auto items-center py-8 mt-4">
|
||||
<BoxOpenSolid className="inline-block shrink-0 size-12 lg:size-20 fill-gray-300" />
|
||||
<span className="block text-base md:text-lg font-semibold mt-8">{translate("billboard-products-page-no-products")}</span>
|
||||
<span className="block text-xs/6 md:text-sm mt-5 text-gray-500 text-center">{translate("billboard-products-page-no-products-text")}</span>
|
||||
<span className="block text-base md:text-lg font-semibold mt-8 capitalize">{translate("billboard-products-page-no-products")}</span>
|
||||
<span className="block text-xs/6 md:text-sm mt-5 text-gray-500 text-center first-letter:capitalize">{translate("billboard-products-page-no-products-text")}</span>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("back")}
|
||||
className="block w-32 md:w-36 py-3 px-gi mt-10 rounded-lg text-sm md:text-base font-semibold !bg-gray-100 text-gray-600 capitalize"
|
||||
rightIcon={<ArrowLeftSolid className="inline-block size-4 fill-current rtl:mr-4 ltr:ml-4" />}
|
||||
className="block w-32 md:w-36 py-3 px-gi mt-10 rounded-lg text-sm md:text-base font-semibold !bg-gray-100 text-gray-600 capitalize ltr:[direction:ltr]"
|
||||
leftIcon={<ArrowLeftSolid className="inline-block size-4 fill-current me-4" />}
|
||||
onClick={handleGoToProducts}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -33,6 +33,9 @@ const VariantSelection: React.FunctionComponent<VariantSelectionProps> = ({ prod
|
||||
onSelect(id);
|
||||
setSelectedVariant(id);
|
||||
}
|
||||
const getVariantId = (galleryId: string) => {
|
||||
return allVariants.filter(x => x.pics[0].variant_galleries_id.id === galleryId)[0].variant_id;
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
@ -55,7 +58,7 @@ const VariantSelection: React.FunctionComponent<VariantSelectionProps> = ({ prod
|
||||
<span className="flex items-center text-sm sm:text-base text-secondary-light capitalize text-current font-extrabold">{`${translate("billboard-products-variants-title")}${hasValue(style) ? " - " + style : ""}`}</span>
|
||||
</div>
|
||||
<div className="flex items-center max-w-max lg:w-auto mt-5 overflow-x-scroll px-1 py-1 lg:px-2 lg:py-2 hide-scrollbar">
|
||||
{variants.map((x, i) => (
|
||||
{variants.sort((a, b) => getVariantId(a.id) - getVariantId(b.id)).map((x, i) => (
|
||||
<span
|
||||
key={x.id}
|
||||
className={`flex items-center justify-center ${x.id === selectedVariant ? `${x.id}-active-variation` : ""} relative hover:bg-[var(--light-brand-color)] hover:border-[var(--light-brand-color)] ltr:mr-4 rtl:ml-4 border shrink-0 border-gray-200 size-20 sm:size-20 lg:size-20 2xl:size-20 p-[2px] 2xl:p-1 text-sm font-semibold uppercase rounded-xl mx-auto lg:cursor-pointer lg:select-none ring-2 ${selectedVariant === x.id ? "ring-[var(--brand-color)]" : "ring-transparent"}`}
|
||||
|
||||
@ -18,12 +18,13 @@ interface TopNavProps {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
rating: number;
|
||||
totalRating: number;
|
||||
ratingVotesCount: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
|
||||
const TopNav: React.FunctionComponent<TopNavProps> = ({ title, logo, rating, className }) => {
|
||||
const TopNav: React.FunctionComponent<TopNavProps> = ({ title, logo, totalRating, ratingVotesCount, className }) => {
|
||||
|
||||
// states
|
||||
const [cartOpen, setCartOpen] = useState(false);
|
||||
@ -42,7 +43,7 @@ const TopNav: React.FunctionComponent<TopNavProps> = ({ title, logo, rating, cla
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`flex lg:hidden items-center w-full fixed top-0 h-[var(--menu-height)] bg-white py-4 px-4 shadow-bot z-50 ${className}`}>
|
||||
<div className={`flex lg:hidden items-center w-full fixed top-0 h-[var(--menu-height)] bg-white py-4 px-4 z-50 ${className}`}>
|
||||
{/* <ArrowLeftSolid className="inline-block fill-secondary-light size-6 absolute left-3" /> */}
|
||||
<ShoppingCart isOpen={cartOpen} onClose={handleCartClose} handleClass="shopping-cart-icon" className="left-0 !right-auto" cartClass="right-0" />
|
||||
<BasketShoppingSolid className={`shopping-cart-icon reactive-button absolute bottom-3 inline-block size-11 rtl:left-3 ltr:right-3 lg:bottom-0 rounded-full fill-secondary-light shrink-0 p-2`} onClick={toggleShoppingCart} />
|
||||
@ -55,15 +56,19 @@ const TopNav: React.FunctionComponent<TopNavProps> = ({ title, logo, rating, cla
|
||||
height={logo.height}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[50, 50, 50, 50]}
|
||||
className="inline-block !size-11 rounded-full object-conver"
|
||||
figureClass={`inline-block !size-11 bg-white rounded-full bg-gray-100 shrink-0 rtl:ml-3 ltr:mr-3`}
|
||||
className="inline-block !size-11 rounded-full !object-conver"
|
||||
figureClass={`inline-block !size-11 bg-gray-300 border-2 border-white ring-1 ring-gray-100 rounded-full bg-gray-100 shrink-0 me-3`}
|
||||
/>
|
||||
:
|
||||
<StoreSolid className="inline-block bg-gray-100 fill-secondary-light size-11 rtl:ml-3 ltr:mr-3 p-[6px] rounded-full" />
|
||||
<StoreSolid className="inline-block bg-gray-100 fill-secondary-light size-11 me-3 p-[6px] rounded-full" />
|
||||
}
|
||||
<div className="flex flex-col items-start space-y-2">
|
||||
<span className="inline-block text-sm font-extrabold text-title-color capitalize">{`${title.slice(0, 30)}${title.length > 30 ? "..." : ""}`}</span>
|
||||
<StarRating rating={rating} className="text-primary rtl:-mr-1" starClass="size-3 shrink-0" />
|
||||
<div className="flex flex-col items-start gap-[10px]">
|
||||
<span className="inline-block text-sm font-extrabold text-secondary-light capitalize">{`${title.slice(0, 30)}${title.length > 30 ? "..." : ""}`}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<StarRating rating={totalRating} className="text-logo-orange -mt-[2px] gap-[2px]" starClass="size-3 shrink-0" />
|
||||
<span className="text-xs font-semibold text-text-gray">{`(${ratingVotesCount} ${translate("vote")})`}</span>
|
||||
</div>
|
||||
{/* <StarRating rating={rating} className="text-amber-500 !gap-[2px]" starClass="size-3 shrink-0" /> */}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -0,0 +1,84 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, url, useGetRouter } from 'services/general/general'
|
||||
import Image from "components/image/image";
|
||||
import { ImageSharpSolid } from "components/icons";
|
||||
import { BillboardProductsCategory } from "common/types/billboard";
|
||||
import Link from "components/link/link";
|
||||
|
||||
interface CategoryVOneProps {
|
||||
cardWidth: string;
|
||||
data: BillboardProductsCategory;
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
}
|
||||
|
||||
const CategoryVOne: React.FunctionComponent<CategoryVOneProps> = ({ cardWidth, data, billboardId, billboardTitle }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const pic = data.thumbnail;
|
||||
const categoryTitle = getLocaleTr(data, locale).name;
|
||||
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}/products?cat=${data.parent[0]}-${data.local_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={"_self"}
|
||||
className={`flex flex-col bg-white w-[90px] sm:w-full rounded-lg overflow-hidden relative select-none shrink-0 mx-auto`}
|
||||
>
|
||||
<div
|
||||
className={`flex flex-col items-center justify-center gap-2 sm:gap-3 w-full shrink-0 lg:cursor-pointer [&>span]:hover:lg:bg-white [&>span]:hover:lg:text-[var(--brand-color)] [&>span]:hover:lg:!bg-[var(--light-brand-color)]`}
|
||||
>
|
||||
<div className={`reactive-button block size-full relative rounded-full shadow-bot border-[4px] border-white`}>
|
||||
{pic ?
|
||||
<Image
|
||||
src={`${pic.id}/${pic.filename_download}`}
|
||||
alt={translate("pic-of") + categoryTitle}
|
||||
width={pic.width}
|
||||
height={pic.height}
|
||||
quality={75}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[200, 250, 250, 250]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className={`rounded-full will-change-transform !object-cover`}
|
||||
figureClass="rounded-full w-full transition-transform duration-200 select-none [&>div]:!bg-transparent"
|
||||
/>
|
||||
:
|
||||
<span className="block w-full rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-10 xl:size-10 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<span
|
||||
title={categoryTitle}
|
||||
className={`reactive-button shrink-0 py-1 px-3 capitalize rounded-full text-xs/6 text-center select-none font-semibold line-clamp-1 text-secondary-light`}
|
||||
>
|
||||
{categoryTitle}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
}
|
||||
export default CategoryVOne
|
||||
|
||||
|
||||
export const CategoryVOnePlaceHolder = ({ width }: { width: string; }) => {
|
||||
return (
|
||||
<div style={{ "width": width } as React.CSSProperties} className={`flex flex-col w-[90px] sm:w-full rounded-lg overflow-hidden relative select-none shrink-0 mx-auto`}>
|
||||
<div className="flex flex-col items-center justify-center gap-2 sm:gap-3 w-full shrink-0">
|
||||
<div className={`block size-full aspect-square relative rounded-full shadow-bot border-[4px] bg-gray-200/75 border-white`}></div>
|
||||
<span className={`bg-gray-200/75 h-6 w-24 rounded-lg text-center`}></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,109 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, hasValue, url, useGetRouter } from 'services/general/general'
|
||||
import Image from "components/image/image";
|
||||
import { ImageSharpSolid, PercentageSolid } from "components/icons";
|
||||
import { BillboardProduct, BillboardVitrine } from "common/types/billboard";
|
||||
import Link from "components/link/link";
|
||||
import StarRating from "components/rating/star-rating";
|
||||
import { Currencies } from "common/types/general";
|
||||
|
||||
interface ProductVOneProps {
|
||||
cardWidth: string;
|
||||
data: BillboardProduct;
|
||||
currency: Currencies;
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
}
|
||||
|
||||
const ProductVOne: React.FunctionComponent<ProductVOneProps> = ({ cardWidth, data, currency, billboardId, billboardTitle }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const pic = data.variations[0].pics[0].variant_galleries_id.gallery[0].directus_files_id;
|
||||
const productTitle = getLocaleTr(data, locale).title;
|
||||
const rating = data.ratings.length > 0 ? data.ratings.reduce((total, x) => total + x.product_quality, 0) / data.ratings.length : 0;
|
||||
const price = data.variations[0].price ?? data.price_entries[0].price;
|
||||
const discountPrice = data.variations[0].discounted_price ?? data.price_entries[0].discounted_price;
|
||||
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}/products/${data.product_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={data.ad_item ? "_blank" : "_self"}
|
||||
style={{ "width": cardWidth } as React.CSSProperties}
|
||||
className={`flex flex-col bg-white rounded-lg p-3 overflow-hidden relative select-none`}
|
||||
>
|
||||
{discountPrice && <div className="flex items-center w-max absolute capitalize top-0 rtl:right-0 ltr:left-0 z-10 text-white bg-[var(--brand-color)] rtl:rounded-bl-lg rtl:rounded-tr-lg ltr:rounded-br-lg ltr:rounded-tl-lg py-1 px-2">
|
||||
<PercentageSolid className="inline-block size-3 sm:size-3 fill-current rtl:ml-[2px] ltr:mr-[2px]" />
|
||||
<span className="inline-block text-[0.625rem]/4 sm:text-xs text-current font-semibold shrink-0">{`${Math.ceil(((price - discountPrice) / price) * 100)} ${translate("discount")}`}</span>
|
||||
</div>}
|
||||
<div className="block w-full h-full relative">
|
||||
{pic ?
|
||||
<Image
|
||||
src={`${pic.id}/${pic.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={pic.width}
|
||||
height={pic.height}
|
||||
quality={100}
|
||||
ar={[3 / 2, 3 / 2, 3 / 2, 3 / 2]}
|
||||
imageSizes={[200, 250, 250, 250]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="auto"
|
||||
className="rounded-lg !object-contain"
|
||||
figureClass="block mx-auto rounded-lg w-full select-none [&>div]:!bg-transparent shrink-0"
|
||||
/>
|
||||
:
|
||||
<span className="block w-full aspect-square md:aspect-square rounded-lg lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-16 xl:size-24 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div className="block w-full pt-8 lg:pt-5 px-1">
|
||||
<h2 className={`text-xs/6 font-semibold sm:text-[0.825rem] text-secondary-light line-clamp-2 sm:line-clamp-2 h-12 mb-2 capitalize`}>{productTitle}</h2>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{data.brand && <span className="text-xs text-cool-gray font-semibold">{`${locale === "fa" ? data.brand.persian_name : data.brand.english_name}`}</span>}
|
||||
<StarRating
|
||||
mode="single"
|
||||
rating={Number(rating.toFixed(2))}
|
||||
className="text-amber-500 -mr-1 w-max gap-[2px]"
|
||||
starClass="size-[0.775rem] sm:size-[0.875rem] shrink-0"
|
||||
singleRateClass="font-semibold text-secondary-light text-sm"
|
||||
singleWrapperClass="gap-[6px]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 mt-4">
|
||||
<div className="flex items-center w-max select-none">
|
||||
{hasValue(discountPrice) && <span className="text-sm text-cool-gray line-through font-semibold me-1">{`${price}`}</span>}
|
||||
<span className="text-sm sm:text-base text-secondary-light font-extrabold">{`${currency.sign}${hasValue(discountPrice) ? discountPrice : price}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
}
|
||||
export default ProductVOne
|
||||
|
||||
|
||||
export const ProductVOnePlaceHolder = ({ width }: { width: string; }) => {
|
||||
return (
|
||||
<div style={{ "width": width } as React.CSSProperties} className={`flex flex-col bg-white p-3 rounded-lg shrink-0`}>
|
||||
<span className={`inline-block bg-gray-200/75 w-full aspect-3/2 rounded mb-5`}></span>
|
||||
<span className={`inline-block bg-gray-200/75 w-full h-4 rounded mb-2`}></span>
|
||||
<span className={`inline-block bg-gray-200/75 w-full h-4 rounded mb-4`}></span>
|
||||
<div className="flex items-center justify-between w-full mb-4">
|
||||
<span className={`inline-block bg-gray-200/75 w-12 h-4 rounded`}></span>
|
||||
<span className={`inline-block bg-gray-200/75 w-6 h-4 rounded`}></span>
|
||||
</div>
|
||||
<span className={`inline-block bg-gray-200/75 w-16 h-6 rounded`}></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,91 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, hasValue, url, useGetRouter } from 'services/general/general'
|
||||
import Image from "components/image/image";
|
||||
import { ImageSharpSolid } from "components/icons";
|
||||
import { BillboardProduct, BillboardProductsCategory } from "common/types/billboard";
|
||||
import Link from "components/link/link";
|
||||
import { Currencies } from "common/types/general";
|
||||
|
||||
interface ProductVTwoProps {
|
||||
cardWidth: string;
|
||||
data: BillboardProduct;
|
||||
currency: Currencies;
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
}
|
||||
|
||||
const ProductVTwo: React.FunctionComponent<ProductVTwoProps> = ({ cardWidth, data, currency, billboardId, billboardTitle }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const pic = data.variations[0].pics[0].variant_galleries_id.gallery[0].directus_files_id;
|
||||
const productTitle = getLocaleTr(data, locale).title;
|
||||
const rating = data.ratings.length > 0 ? data.ratings.reduce((total, x) => total + x.product_quality, 0) / data.ratings.length : 0;
|
||||
const price = data.variations[0].price ?? data.price_entries[0].price;
|
||||
const discountPrice = data.variations[0].discounted_price ?? data.price_entries[0].discounted_price;
|
||||
|
||||
|
||||
// methods
|
||||
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}/products/${data.product_id}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={"_self"}
|
||||
style={{ "width": cardWidth } as React.CSSProperties}
|
||||
title={productTitle}
|
||||
className={`reactive-button flex items-center bg-white rounded-lg overflow-hidden relative select-none shrink-0 mx-auto py-2 px-2 snap-center snap-always`}
|
||||
>
|
||||
<div className={`block size-16 relative rounded-full border-[4px] border-white shadow-sm`}>
|
||||
{pic ?
|
||||
<Image
|
||||
src={`${pic.id}/${pic.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={pic.width}
|
||||
height={pic.height}
|
||||
quality={75}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[100, 100, 100, 100]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className={`rounded-full will-change-transform !object-cover`}
|
||||
figureClass="rounded-full w-full transition-transform duration-200 select-none [&>div]:!bg-transparent"
|
||||
/>
|
||||
:
|
||||
<span className="block w-full rounded-full lg:rounded">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-10 xl:size-10 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
<div className="flex flex-col justify-start gap-4 select-none ps-3">
|
||||
<span className={`shrink-0 capitalize rounded-full text-xs/6 font-semibold line-clamp-1 pe-2 text-secondary-light`}>{productTitle}</span>
|
||||
<div className="flex items-center w-max select-none">
|
||||
{hasValue(discountPrice) && <span className="text-xs text-cool-gray line-through font-semibold me-1">{`${price}`}</span>}
|
||||
<span className="text-xs text-secondary-light font-extrabold">{`${currency.sign}${hasValue(discountPrice) ? discountPrice : price}`}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
|
||||
}
|
||||
export default ProductVTwo
|
||||
|
||||
|
||||
export const ProductVTwoPlaceHolder = ({ width }: { width: string; }) => {
|
||||
return (
|
||||
<div style={{ "width": width } as React.CSSProperties} className={`flex items-center bg-white rounded-lg overflow-hidden relative select-none shrink-0 mx-auto py-2 px-2`}>
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-3 w-full shrink-0">
|
||||
<div className={`block size-16 aspect-square relative rounded-full bg-gray-200/75`}></div>
|
||||
<div className="flex flex-col justify-start w-full gap-4 select-none ps-3">
|
||||
<span className={`bg-gray-200/75 h-6 w-full rounded-lg`}></span>
|
||||
<span className={`bg-gray-200/75 h-4 w-10 rounded-lg`}></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { useGetRouter } from 'services/general/general'
|
||||
import { getCard, getCardPlaceHolder, getCardWidth } from "../v-services";
|
||||
import { BillboardVitrine } from "common/types/billboard";
|
||||
import { Currencies } from "common/types/general";
|
||||
|
||||
interface VCategoriesContainerProps {
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
itemsData: any[];
|
||||
data: BillboardVitrine;
|
||||
currency: Currencies;
|
||||
wrapperClass?: string;
|
||||
headerClass?: string;
|
||||
titleClass?: string;
|
||||
childrenClass?: string;
|
||||
}
|
||||
|
||||
|
||||
const VCategoriesContainer: React.FunctionComponent<VCategoriesContainerProps> = ({ billboardId, billboardTitle, currency, itemsData, data, wrapperClass, headerClass, titleClass, childrenClass }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const cardWidth = getCardWidth(data);
|
||||
|
||||
// methods
|
||||
|
||||
return (
|
||||
<>
|
||||
{itemsData ?
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--item-order": data.content.order,
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`flex flex-col items-center justify-center [order:var(--item-order)] w-full max-sm:px-2 sm:w-max mx-auto [grid-column:var(--mobile-col-span)] sm:[grid-column:var(--desktop-col-span)] ${wrapperClass}`}
|
||||
>
|
||||
{/* header */}
|
||||
{!data.container.no_header &&
|
||||
<div className={`flex items-center justify-center w-full pb-6 sm:pb-10 ${headerClass}`}>
|
||||
<h2 className={`text-base sm:text-lg text-secondary-light ${titleClass}`}>{locale === "fa" ? data.container.title.fa : data.container.title.en}</h2>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* children */}
|
||||
<div className={`flex items-center overflow-x-scroll hide-scrollbar sm:grid max-sm:[grid-template-columns:repeat(auto-fit,minmax(90px,1fr))] sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-5 xl:grid-cols-6 gap-4 w-full ${childrenClass}`}>
|
||||
{itemsData.filter((x: any) => x.parent.length > 0).sort((a, b) => a.sort_id - b.sort_id).map((x: any, i: number) => getCard({ billboardId, billboardTitle, currency, cardWidth, data, x }))}
|
||||
</div>
|
||||
</div >
|
||||
:
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="flex flex-col items-center justify-center [order:var(--item-order)] w-full max-sm:px-2 sm:w-max mx-auto [grid-column:var(--mobile-col-span)] sm:[grid-column:var(--desktop-col-span)]"
|
||||
>
|
||||
<div className={`flex items-center justify-center w-full pb-6 sm:pb-10`}>
|
||||
<span className={`inline-block bg-gray-200/75 w-52 h-10 rounded-lg`}></span>
|
||||
</div>
|
||||
<div className="flex items-center overflow-x-scroll hide-scrollbar sm:grid max-sm:[grid-template-columns:repeat(auto-fit,minmax(90px,1fr))] sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-5 xl:grid-cols-6 gap-4 w-full">
|
||||
{Array.from(Array(6)).map((x, i) => (
|
||||
getCardPlaceHolder(data, cardWidth.formatted, i)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default VCategoriesContainer
|
||||
@ -0,0 +1,123 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { url, useGetRouter } from 'services/general/general'
|
||||
import { getCard, getCardPlaceHolder, getCardWidth } from "../v-services";
|
||||
import { BillboardVitrine } from "common/types/billboard";
|
||||
import { Currencies } from "common/types/general";
|
||||
import Link from "components/link/link";
|
||||
|
||||
interface VGridContainerProps {
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
itemsData: any[];
|
||||
data: BillboardVitrine;
|
||||
currency: Currencies;
|
||||
}
|
||||
|
||||
|
||||
const VGridContainer: React.FunctionComponent<VGridContainerProps> = ({ billboardId, billboardTitle, currency, itemsData, data }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const cardWidth = getCardWidth(data);
|
||||
|
||||
// methods
|
||||
|
||||
return (
|
||||
<>
|
||||
{itemsData ?
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--xl-cols": `repeat(${data.container.grid.xl_cols}, minmax(0, 1fr))`,
|
||||
"--lg-cols": `repeat(${data.container.grid.lg_cols}, minmax(0, 1fr))`,
|
||||
"--md-cols": `repeat(${data.container.grid.md_cols}, minmax(0, 1fr))`,
|
||||
"--sm-cols": `repeat(${data.container.grid.sm_cols}, minmax(0, 1fr))`,
|
||||
"--desktop-rows-count": data.container.grid.desktop_rows_count,
|
||||
"--mobile-rows-count": data.container.grid.mobile_rows_count,
|
||||
"--gap-x": `${data.container.grid.gap_x / 4}rem`,
|
||||
"--gap-y": `${data.container.grid.gap_y / 4}rem`,
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
"--item-order": data.content.order,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`flex flex-col w-full bg-white [order:var(--item-order)] [grid-column:var(--mobile-col-span)] md:[grid-column:var(--desktop-col-span)] ${data.container.wrapperClass}`}
|
||||
>
|
||||
{/* header */}
|
||||
{!data.container.no_header &&
|
||||
<div className={`flex items-center justify-between w-full py-4 px-4 ${data.container.headerClass}`}>
|
||||
<h2 className={`flex items-center text-sm sm:text-base justify-between capitalize text-secondary-light font-semibold ${data.container.titleClass}`}>
|
||||
{locale === "fa" ? data.container.title.fa : data.container.title.en}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={"_blank"}
|
||||
className={`inline-block px-1 text-text-gray text-sm hover:text-[var(--brand-color)] ${data.container.linkClass}`}
|
||||
>
|
||||
{`${locale === "fa" ? data.container.link_text.fa : data.container.link_text.en} ›`}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* children */}
|
||||
<div className={`${data.container.childrenWrapperClass}`}>
|
||||
<div
|
||||
className={`grid gap-x-[var(--gap-x)] gap-y-[var(--gap-y)] justify-start
|
||||
${data.container.grid.direction === "row" ?
|
||||
"grid-flow-col [grid-template-rows:repeat(var(--mobile-rows-count),1fr)] md:[grid-template-rows:repeat(var(--desktop-rows-count),1fr)]"
|
||||
:
|
||||
"grid-cols-[var(--sm-cols)] md:grid-cols-[var(--md-cols)] lg:grid-cols-[var(--lg-cols)] xl:grid-cols-[var(--xl-cols)]"
|
||||
}
|
||||
${data.container.childrenClass}
|
||||
`}
|
||||
>
|
||||
{itemsData.map((x: any, i: number) => (getCard({ billboardId, billboardTitle, currency, cardWidth, data, x })))}
|
||||
</div>
|
||||
</div>
|
||||
</div >
|
||||
:
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--xl-cols": `repeat(${data.container.grid.xl_cols}, minmax(0, 1fr))`,
|
||||
"--lg-cols": `repeat(${data.container.grid.lg_cols}, minmax(0, 1fr))`,
|
||||
"--md-cols": `repeat(${data.container.grid.md_cols}, minmax(0, 1fr))`,
|
||||
"--sm-cols": `repeat(${data.container.grid.sm_cols}, minmax(0, 1fr))`,
|
||||
"--desktop-rows-count": data.container.grid.desktop_rows_count,
|
||||
"--mobile-rows-count": data.container.grid.mobile_rows_count,
|
||||
"--gap-x": `${data.container.grid.gap_x / 4}rem`,
|
||||
"--gap-y": `${data.container.grid.gap_y / 4}rem`,
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
"--item-order": data.content.order,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`flex flex-col w-full bg-white [order:var(--item-order)] [grid-column:var(--mobile-col-span)] md:[grid-column:var(--desktop-col-span)]`}
|
||||
>
|
||||
<div className={`flex items-center justify-between w-full pb-4 px-1`}>
|
||||
<span className={`inline-block bg-gray-200/75 w-44 h-7 rounded-lg`}></span>
|
||||
<span className={`inline-block bg-gray-200/75 w-24 h-7 rounded-lg`}></span>
|
||||
</div>
|
||||
<div className={`grid gap-x-[var(--gap-x)] gap-y-[var(--gap-y)] justify-start p-2
|
||||
${data.container.grid.direction === "row" ?
|
||||
"grid-flow-col [grid-template-rows:repeat(var(--mobile-rows-count),1fr)] md:[grid-template-rows:repeat(var(--desktop-rows-count),1fr)]"
|
||||
:
|
||||
"grid-cols-[var(--sm-cols)] md:grid-cols-[var(--md-cols)] lg:grid-cols-[var(--lg-cols)] xl:grid-cols-[var(--xl-cols)]"
|
||||
}
|
||||
${data.container.childrenClass}
|
||||
`}
|
||||
>
|
||||
{Array.from(Array(data.content.count)).map((x, i) => (
|
||||
getCardPlaceHolder(data, cardWidth.formatted, i)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default VGridContainer
|
||||
@ -0,0 +1,45 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { hasValue, useGetRouter } from 'services/general/general'
|
||||
import { BillboardVitrine } from "common/types/billboard";
|
||||
|
||||
interface VSeparatorContainerProps {
|
||||
data: BillboardVitrine;
|
||||
wrapperClass?: string;
|
||||
}
|
||||
|
||||
|
||||
const VSeparatorContainer: React.FunctionComponent<VSeparatorContainerProps> = ({ data, wrapperClass }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
|
||||
// methods
|
||||
|
||||
return (
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"width": `${data.container.separator.width}%`,
|
||||
"--height": `${data.container.separator.height}px`,
|
||||
"--type": data.container.separator.type,
|
||||
"--b-color": data.container.separator.bg_color,
|
||||
"--mt": `${data.container.separator.mt / 4}rem`,
|
||||
"--mb": `${data.container.separator.mb / 4}rem`,
|
||||
"--item-order": data.content.order,
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`flex flex-col items-center justify-center [border-top-width:var(--height)] [border-style:var(--type)]
|
||||
${hasValue(data.container.separator.bg_color) ? "[border-color:var(--b-color)]" : "border-gray-100"}
|
||||
${data.container.separator.mt !== -1 ? "[margin-top:var(--mt)]" : ""}
|
||||
${data.container.separator.mb !== -1 ? "[margin-bottom:var(--mb)]" : ""}
|
||||
[order:var(--item-order)] mx-auto [grid-column:var(--mobile-col-span)] sm:[grid-column:var(--desktop-col-span)] ${wrapperClass}
|
||||
`}
|
||||
>
|
||||
</div >
|
||||
)
|
||||
}
|
||||
export default VSeparatorContainer
|
||||
@ -0,0 +1,121 @@
|
||||
import React, { useState } from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { url, useGetRouter } from 'services/general/general'
|
||||
import { BillboardVitrine } from "common/types/billboard";
|
||||
import { Currencies } from "common/types/general";
|
||||
import Slider from "components/slider/slider";
|
||||
import { getCard, getCardPlaceHolder, getCardWidth } from "../v-services";
|
||||
import Link from "components/link/link";
|
||||
import { BoxOpenSolid } from "components/icons";
|
||||
|
||||
interface VSliderContainerProps {
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
itemsData: any[];
|
||||
data: BillboardVitrine;
|
||||
currency: Currencies;
|
||||
wrapperClass?: string;
|
||||
headerClass?: string;
|
||||
titleClass?: string;
|
||||
linkClass?: string;
|
||||
childrenClass?: string;
|
||||
}
|
||||
|
||||
|
||||
const VSliderContainer: React.FunctionComponent<VSliderContainerProps> = ({ billboardId, billboardTitle, currency, itemsData, data, wrapperClass, headerClass, titleClass, linkClass, childrenClass }) => {
|
||||
|
||||
// states
|
||||
const [currentSlide, setCurrentSlide] = useState(0);
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const slidesGap = data.container.slider.gap;
|
||||
const cardWidth = getCardWidth(data);
|
||||
|
||||
// methods
|
||||
|
||||
return (
|
||||
<>
|
||||
{itemsData ?
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--gap": `${slidesGap}rem`,
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
"--item-order": data.content.order,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={`flex flex-col w-full bg-white rounded-lg [order:var(--item-order)] [grid-column:var(--mobile-col-span)] sm:[grid-column:var(--desktop-col-span)] ${wrapperClass}`}
|
||||
>
|
||||
{/* header */}
|
||||
{!data.container.no_header &&
|
||||
<div className={`flex items-center justify-between w-full py-4 px-4 ${headerClass}`}>
|
||||
<h2 className={`flex items-center text-sm sm:text-base justify-between capitalize text-secondary-light font-semibold ${titleClass}`}>
|
||||
{locale === "fa" ? data.container.title.fa : data.container.title.en}
|
||||
</h2>
|
||||
<Link
|
||||
href={`/billboard/${billboardId}/${url(billboardTitle)}`}
|
||||
shallow
|
||||
scroll={false}
|
||||
target={"_blank"}
|
||||
className={`inline-block px-1 text-text-gray text-sm hover:text-[var(--brand-color)] ${linkClass}`}
|
||||
>
|
||||
{`${locale === "fa" ? data.container.link_text.fa : data.container.link_text.en} ›`}
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
|
||||
{/* children */}
|
||||
<Slider
|
||||
slideType="size"
|
||||
space={slidesGap}
|
||||
slidesToShow={[1, 1, 1, 1, 1, 1]}
|
||||
slideSizes={[cardWidth.raw, cardWidth.raw, cardWidth.raw, cardWidth.raw, cardWidth.raw, cardWidth.raw]}
|
||||
dots={data.container.slider.dots}
|
||||
dragFree={data.container.slider.free_slide}
|
||||
buttons={data.container.slider.arrows}
|
||||
slides={itemsData}
|
||||
startIndex={0}
|
||||
align="start"
|
||||
direction={locale === "fa" ? "rtl" : "ltr"}
|
||||
loop={data.container.slider.loop}
|
||||
autoplay={data.container.slider.auto_play}
|
||||
autoplayDelay={data.container.slider.auto_play ? data.container.slider.duration : 4000}
|
||||
childComponent={(x: any, i) => getCard({ billboardId, billboardTitle, currency, cardWidth, data, x })}
|
||||
sliderClass={`block w-full [rtl:direction:rtl] mx-auto rounded-lg p-2 bg-gray-50 rounded-lg`}
|
||||
viewPortClass="!p-0"
|
||||
containerClass=""
|
||||
controlsContainerClass="!mt-0"
|
||||
onScroll={setCurrentSlide}
|
||||
currentIndex={currentSlide}
|
||||
/>
|
||||
</div >
|
||||
:
|
||||
<div
|
||||
style={
|
||||
{
|
||||
"--gap": `${slidesGap}rem`,
|
||||
"--desktop-col-span": `span ${data.container.desktop_col_span}`,
|
||||
"--mobile-col-span": `span ${data.container.mobile_col_span}`,
|
||||
"--item-order": data.content.order,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className="flex flex-col w-full rounded-lg [order:var(--item-order)] [grid-column:var(--mobile-col-span)] sm:[grid-column:var(--desktop-col-span)] overflow-hidden"
|
||||
>
|
||||
<div className={`flex items-center justify-between w-full pb-4 px-1`}>
|
||||
<span className={`inline-block bg-gray-200/75 w-44 h-7 rounded-lg`}></span>
|
||||
<span className={`inline-block bg-gray-200/75 w-24 h-7 rounded-lg`}></span>
|
||||
</div>
|
||||
<div className="flex items-center [gap:var(--gap)] w-full mx-auto p-2 bg-gray-200/75 rounded-lg">
|
||||
{Array.from(Array(data.content.count)).map((x, i) => (
|
||||
getCardPlaceHolder(data, cardWidth.formatted, i)
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
export default VSliderContainer
|
||||
164
src/common/templates/billboard/page/vitrine/v-data.tsx
Normal file
164
src/common/templates/billboard/page/vitrine/v-data.tsx
Normal file
@ -0,0 +1,164 @@
|
||||
import { BillboardVitrineContent } from "common/types/billboard";
|
||||
import { sortDecipher } from "./v-services";
|
||||
|
||||
export interface VDataProps {
|
||||
billboardId: string;
|
||||
data: BillboardVitrineContent;
|
||||
}
|
||||
|
||||
export const vProductsQuery = ({ billboardId, data }: VDataProps) => {
|
||||
const { selection, catId, custom_selection, sort, count } = data;
|
||||
|
||||
return `
|
||||
data${data.id}:billboard_products
|
||||
(
|
||||
filter: {
|
||||
status: { _eq: "published" },
|
||||
billboard: { Billboards_id: { id: { _eq: "${billboardId}" } } },
|
||||
${selection === "featured" ? `is_featured: { _eq: true },` : ""}
|
||||
${selection === "category" ? `category: { id: { _eq: "${catId}" }},` : ""}
|
||||
${selection === "custom" ? `id: { _in: ${custom_selection} },` : ""}
|
||||
},
|
||||
sort: ["${sort === "ascending" ? '' : '-'}${sortDecipher(selection)}"],
|
||||
limit: ${count},
|
||||
)
|
||||
{
|
||||
id
|
||||
date_created
|
||||
product_id
|
||||
type
|
||||
product_card_type
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
title
|
||||
description
|
||||
}
|
||||
price_entries {
|
||||
price
|
||||
discounted_price
|
||||
is_primary_unit
|
||||
}
|
||||
is_featured
|
||||
brand {
|
||||
id
|
||||
english_name
|
||||
persian_name
|
||||
}
|
||||
ratings {
|
||||
id
|
||||
product_quality
|
||||
}
|
||||
variations {
|
||||
id
|
||||
main_variant
|
||||
price
|
||||
discounted_price
|
||||
pics {
|
||||
variant_galleries_id {
|
||||
id
|
||||
gallery {
|
||||
directus_files_id {
|
||||
id
|
||||
title
|
||||
description
|
||||
filename_download
|
||||
width
|
||||
height
|
||||
type
|
||||
filesize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
stock_unit_type {
|
||||
id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
name
|
||||
}
|
||||
sign
|
||||
type
|
||||
ratio
|
||||
is_primary
|
||||
}
|
||||
category {
|
||||
id
|
||||
local_id
|
||||
sort_id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
name
|
||||
}
|
||||
parent
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const vNewsQuery = ({ billboardId, data }: VDataProps) => {
|
||||
const { selection, catId, custom_selection, sort, count } = data;
|
||||
|
||||
return `
|
||||
data${data.id}:billboard_news
|
||||
(
|
||||
filter: {
|
||||
status: { _eq: "published" },
|
||||
billboard: { Billboards_id: { id: { _eq: "${billboardId}" } } },
|
||||
${selection === "featured" ? `is_featured: { _eq: true }` : ""},
|
||||
${selection === "category" ? `category: { id: { _eq: "${catId}" }}` : ""},
|
||||
${selection === "custom" ? `id: { _in: ${custom_selection} }` : ""},
|
||||
},
|
||||
sort: ["${sort === "ascending" ? '' : '-'}${sortDecipher(selection)}"],
|
||||
limit: ${count},
|
||||
)
|
||||
{
|
||||
id
|
||||
date_created
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
export const vProductCategoriesQuery = ({ billboardId, data }: VDataProps) => {
|
||||
const { selection, sort } = data;
|
||||
|
||||
return `
|
||||
data${data.id}:billboard_product_categories
|
||||
(
|
||||
filter: {
|
||||
status: { _eq: "published" },
|
||||
billboard_id: { id: { _eq: "${billboardId}" } },
|
||||
${data.categories_ids.length > 0 ? `id: { _in: ${data.categories_ids} }` : ""},
|
||||
},
|
||||
sort: ["${sort === "ascending" ? '' : '-'}${sortDecipher(selection)}"],
|
||||
)
|
||||
{
|
||||
id
|
||||
local_id
|
||||
sort_id
|
||||
translations {
|
||||
languages_code {
|
||||
code
|
||||
}
|
||||
name
|
||||
}
|
||||
parent
|
||||
thumbnail {
|
||||
id
|
||||
title
|
||||
description
|
||||
filename_download
|
||||
width
|
||||
height
|
||||
type
|
||||
filesize
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
271
src/common/templates/billboard/page/vitrine/v-services.tsx
Normal file
271
src/common/templates/billboard/page/vitrine/v-services.tsx
Normal file
@ -0,0 +1,271 @@
|
||||
import { BillboardVitrine, BillboardVitrineSelection } from "common/types/billboard";
|
||||
import ProductVOne, { ProductVOnePlaceHolder } from "./cards/product-v-one";
|
||||
import { Currencies } from "common/types/general";
|
||||
import { VDataProps, vNewsQuery, vProductCategoriesQuery, vProductsQuery } from "./v-data";
|
||||
import CategoryVOne, { CategoryVOnePlaceHolder } from "./cards/category-v-one";
|
||||
import ProductVTwo, { ProductVTwoPlaceHolder } from "./cards/product-v-two";
|
||||
|
||||
interface GetCardProps {
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
currency: Currencies;
|
||||
cardWidth: { raw: number; formatted: string; };
|
||||
data: BillboardVitrine;
|
||||
x: any;
|
||||
}
|
||||
interface GetCombinedQueriesProps {
|
||||
billboardId: string;
|
||||
data: BillboardVitrine[];
|
||||
}
|
||||
|
||||
const mockData = [
|
||||
{
|
||||
"content": {
|
||||
"id": 1,
|
||||
"type": "product_categories",
|
||||
"selection": "date",
|
||||
"catId": "",
|
||||
"categories_ids": [],
|
||||
"custom_selection": [],
|
||||
"sort": "descending",
|
||||
"count": 8,
|
||||
"order": 0
|
||||
},
|
||||
"container": {
|
||||
"type": "categories",
|
||||
"desktop_col_span": 4,
|
||||
"mobile_col_span": 2,
|
||||
"title": {
|
||||
"fa": "دسته بندی محصولات",
|
||||
"en": "product categories"
|
||||
},
|
||||
"link_text": {
|
||||
"fa": "",
|
||||
"en": ""
|
||||
},
|
||||
"no_header": false,
|
||||
"grid": {
|
||||
"direction": "col",
|
||||
"rows_count": -1,
|
||||
"xl_cols": 6,
|
||||
"lg_cols": 5,
|
||||
"md_cols": 4,
|
||||
"sm_cols": 2,
|
||||
"gap_x": 4,
|
||||
"gap_y": 4
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"type": "category-v-one",
|
||||
"size": "md"
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"id": 2,
|
||||
"type": "separator",
|
||||
"selection": "date",
|
||||
"catId": "",
|
||||
"categories_ids": [],
|
||||
"custom_selection": [],
|
||||
"sort": "descending",
|
||||
"count": 0,
|
||||
"order": 1
|
||||
},
|
||||
"container": {
|
||||
"type": "separator",
|
||||
"desktop_col_span": 4,
|
||||
"mobile_col_span": 2,
|
||||
"separator": {
|
||||
"type": "dashed",
|
||||
"height": 1,
|
||||
"width": 50,
|
||||
"bg_color": null,
|
||||
"mt": 2,
|
||||
"mb": -1
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"id": 3,
|
||||
"type": "products",
|
||||
"selection": "category",
|
||||
"catId": "16",
|
||||
"categories_ids": [],
|
||||
"custom_selection": [],
|
||||
"sort": "descending",
|
||||
"count": 8,
|
||||
"order": 2
|
||||
},
|
||||
"container": {
|
||||
"type": "slider",
|
||||
"desktop_col_span": 4,
|
||||
"mobile_col_span": 2,
|
||||
"title": {
|
||||
"fa": "لباس های تابستانی",
|
||||
"en": "summer dresses"
|
||||
},
|
||||
"link_text": {
|
||||
"fa": "مشاهده همه",
|
||||
"en": "see all"
|
||||
},
|
||||
"no_header": false,
|
||||
"slider": {
|
||||
"gap": 2,
|
||||
"free_slide": true,
|
||||
"auto_play": false,
|
||||
"duration": 3000,
|
||||
"loop": false,
|
||||
"arrows": false,
|
||||
"dots": false
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"type": "product-v-one",
|
||||
"size": "sm"
|
||||
}
|
||||
},
|
||||
{
|
||||
"content": {
|
||||
"id": 4,
|
||||
"type": "products",
|
||||
"selection": "category",
|
||||
"catId": "25",
|
||||
"categories_ids": [],
|
||||
"custom_selection": [],
|
||||
"sort": "descending",
|
||||
"count": 9,
|
||||
"order": 3
|
||||
},
|
||||
"container": {
|
||||
"type": "grid",
|
||||
"desktop_col_span": 4,
|
||||
"mobile_col_span": 2,
|
||||
"title": {
|
||||
"fa": "انواع کیف زنانه",
|
||||
"en": "women purses & bags"
|
||||
},
|
||||
"link_text": {
|
||||
"fa": "مشاهده همه",
|
||||
"en": "see all"
|
||||
},
|
||||
"no_header": false,
|
||||
"childrenWrapperClass": "overflow-hidden p-2 sm:p-4 bg-gray-50 rounded-lg",
|
||||
"childrenClass": "overflow-x-scroll hide-scrollbar w-full bg-gray-50 rounded-lg",
|
||||
"grid": {
|
||||
"direction": "row",
|
||||
"desktop_rows_count": 3,
|
||||
"mobile_rows_count": 3,
|
||||
"xl_cols": 4,
|
||||
"lg_cols": 3,
|
||||
"md_cols": 4,
|
||||
"sm_cols": 2,
|
||||
"gap_x": 2,
|
||||
"gap_y": 2
|
||||
}
|
||||
},
|
||||
"card": {
|
||||
"type": "product-v-two",
|
||||
"size": "sm"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export const sortDecipher = (selection: BillboardVitrineSelection) => {
|
||||
let sortField = "date_created"
|
||||
|
||||
switch (selection) {
|
||||
case "date":
|
||||
sortField = "date_created";
|
||||
break;
|
||||
case "ratings":
|
||||
sortField = "ratings";
|
||||
break;
|
||||
case "visits":
|
||||
sortField = "visits";
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return sortField;
|
||||
}
|
||||
|
||||
export const getQuery = ({ billboardId, data }: VDataProps) => {
|
||||
let query = "";
|
||||
|
||||
switch (data.type) {
|
||||
case "products":
|
||||
query = vProductsQuery({ billboardId, data });
|
||||
break;
|
||||
case "news":
|
||||
query = vNewsQuery({ billboardId, data });
|
||||
break;
|
||||
case "product_categories":
|
||||
query = vProductCategoriesQuery({ billboardId, data });
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return query;
|
||||
}
|
||||
|
||||
export const getCardWidth = (data: BillboardVitrine) => {
|
||||
const cardsWidth = [
|
||||
{ name: "product-v-one", sizes: [{ name: "sm", value: 180 }, { name: "md", value: 240 }, { name: "lg", value: 360 }, { name: "fluid", value: 0 }] },
|
||||
{ name: "product-v-two", sizes: [{ name: "sm", value: 280 }, { name: "fluid", value: 0 }] },
|
||||
{ name: "category-v-one", sizes: [{ name: "sm", value: 60 }, { name: "md", value: 100 }, { name: "lg", value: 150 }, { name: "fluid", value: 0 }] },
|
||||
];
|
||||
let selectedWidth = cardsWidth.filter(x => x.name === data.card.type)[0].sizes.filter(x => x.name === data.card.size)[0].value;
|
||||
return { raw: selectedWidth, formatted: selectedWidth === 0 ? "100%" : `${selectedWidth}px` };
|
||||
}
|
||||
|
||||
export const getCard = ({ billboardId, billboardTitle, currency, cardWidth, data, x }: GetCardProps) => {
|
||||
let card;
|
||||
switch (data.card.type) {
|
||||
case "product-v-one":
|
||||
card = <ProductVOne cardWidth={cardWidth.formatted} key={Number(x.id)} data={x} billboardId={billboardId} billboardTitle={billboardTitle} currency={currency} />
|
||||
break;
|
||||
case "product-v-two":
|
||||
card = <ProductVTwo cardWidth={cardWidth.formatted} key={Number(x.id)} data={x} billboardId={billboardId} billboardTitle={billboardTitle} currency={currency} />
|
||||
break;
|
||||
case "category-v-one":
|
||||
card = <CategoryVOne cardWidth={cardWidth.formatted} key={Number(x.id)} data={x} billboardId={billboardId} billboardTitle={billboardTitle} />
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
export const getCardPlaceHolder = (data: BillboardVitrine, width: string, key: number) => {
|
||||
let card;
|
||||
switch (data.card.type) {
|
||||
case "product-v-one":
|
||||
card = <ProductVOnePlaceHolder key={key} width={width} />
|
||||
break;
|
||||
case "product-v-two":
|
||||
card = <ProductVTwoPlaceHolder key={key} width={width} />
|
||||
break;
|
||||
case "category-v-one":
|
||||
card = <CategoryVOnePlaceHolder key={key} width={width} />
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
export const getCombinedQueries = ({ billboardId, data }: GetCombinedQueriesProps) => {
|
||||
let combinedQueries: string[] = [];
|
||||
|
||||
data.map(x => combinedQueries.push(getQuery({ billboardId, data: x.content })));
|
||||
|
||||
return combinedQueries.join();
|
||||
}
|
||||
66
src/common/templates/billboard/page/vitrine/vitrine.tsx
Normal file
66
src/common/templates/billboard/page/vitrine/vitrine.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import React from "react"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { fetchPublicData, getLocaleTr, useGetRouter } from 'services/general/general'
|
||||
import { Billboard, BillboardVitrineContent } from "common/types/billboard"
|
||||
import VGridContainer from "./containers/v-grid-container";
|
||||
import VSliderContainer from "./containers/v-slider-container";
|
||||
import { getCombinedQueries } from "./v-services";
|
||||
import useSWR from "swr";
|
||||
import VCategoriesContainer from "./containers/v-categories-container";
|
||||
import VSeparatorContainer from "./containers/v-separator-container";
|
||||
|
||||
interface VitrineProps {
|
||||
billboard: Billboard;
|
||||
}
|
||||
|
||||
const Vitrine: React.FunctionComponent<VitrineProps> = ({ billboard }) => {
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const billboardTitle = getLocaleTr(billboard, locale).title;
|
||||
const billboardId = billboard.id;
|
||||
const currency = billboard.currency;
|
||||
|
||||
const { data: queryData, error: queryDataError } = useSWR(['', getCombinedQueries({ billboardId, data: billboard.vitrine })], ([path, url]) => fetchPublicData(path, url));
|
||||
|
||||
queryDataError && console.log(queryDataError);
|
||||
|
||||
const itemData: any = (x: BillboardVitrineContent) => queryData?.data[`data${x.id}`];
|
||||
|
||||
|
||||
// methods
|
||||
const generateVitrine = () => {
|
||||
const elements = [];
|
||||
for (const item of billboard.vitrine) {
|
||||
let container;
|
||||
|
||||
switch (item.container.type) {
|
||||
case "grid":
|
||||
container = <VGridContainer key={item.content.id} billboardId={billboard.id} billboardTitle={billboardTitle} currency={currency} itemsData={itemData(item.content)} data={item} />
|
||||
break;
|
||||
case "slider":
|
||||
container = <VSliderContainer key={item.content.id} billboardId={billboard.id} billboardTitle={billboardTitle} currency={currency} itemsData={itemData(item.content)} data={item} />
|
||||
break;
|
||||
case "categories":
|
||||
container = <VCategoriesContainer key={item.content.id} billboardId={billboard.id} billboardTitle={billboardTitle} currency={currency} itemsData={itemData(item.content)} data={item} />
|
||||
break;
|
||||
case "separator":
|
||||
container = <VSeparatorContainer key={item.content.id} data={item} />
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
elements.push(container)
|
||||
}
|
||||
return elements;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 w-full bg-white pt-6 lg:pt-8 p-4 gap-x-4 gap-y-6 mb-3 rounded-lg">
|
||||
{generateVitrine()}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default Vitrine
|
||||
@ -562,6 +562,12 @@ const AccountDetails: React.FunctionComponent<AccountDetails> = ({ userDetails,
|
||||
photoClass="!object-contain"
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-4 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-market-input text-market-title text-[0.7rem]/4 md:text-base shadow-none w-full hover:bg-market-title-light hover:text-white capitalize px-[10px] pt-[7px] pb-[8px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={closeProfilePicModal}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
@ -569,12 +575,6 @@ const AccountDetails: React.FunctionComponent<AccountDetails> = ({ userDetails,
|
||||
loading={updating}
|
||||
onClick={updateProfilePic}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-market-input text-market-title text-[0.7rem]/4 md:text-base shadow-none w-full hover:bg-market-title-light hover:text-white capitalize px-[10px] pt-[7px] pb-[8px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={closeProfilePicModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@ -21,7 +21,7 @@ const BillboardItem: React.FunctionComponent<BillboardItemProps> = ({ billboard,
|
||||
const billboardTtile = getLocaleTr(billboard, locale).title;
|
||||
const hasPics = billboard.gallery && billboard.gallery.length > 0;
|
||||
const catId = billboard.billboard_categories[0].billboard_categories_id.id;
|
||||
const catIconClass = "inline-block size-[14px] fill-current text-current shrink-0 rtl:ml-2 ltr:mr-2";
|
||||
const catIconClass = "inline-block size-[14px] !fill-current shrink-0 me-2";
|
||||
const location = locale === "fa" ? `${billboard.city.name}, ${billboard.city.state.name}` : `${billboard.city.English_name}, ${billboard.city.state.English_name}`;
|
||||
|
||||
// methods
|
||||
@ -36,7 +36,7 @@ const BillboardItem: React.FunctionComponent<BillboardItemProps> = ({ billboard,
|
||||
href={`/dashboard/billboards/${billboard.id}`}
|
||||
className="reactive-button flex sm:flex-col items-center w-full bg-white shadow-bot rounded-xl"
|
||||
>
|
||||
<div className="block relative max-sm:size-28 sm:w-full shrink-0 p-2 sm:pb-0">
|
||||
<div className="block relative max-sm:size-28 sm:w-full sm:h-44 shrink-0 p-2 sm:pb-0">
|
||||
{hasPics ?
|
||||
<Image
|
||||
src={`${billboard.gallery[0].directus_files_id.id}/${billboard.gallery[0].directus_files_id.filename_download}`}
|
||||
@ -54,7 +54,7 @@ const BillboardItem: React.FunctionComponent<BillboardItemProps> = ({ billboard,
|
||||
/>
|
||||
:
|
||||
<span className="block size-full aspect-1/1 md:aspect-1/1 bg-gray-100 lg:bg-gray-50 rounded-lg">
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-12 xl:size-24 fill-gray-200" />
|
||||
<ImageSharpSolid className="inline-block absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 size-12 xl:size-20 fill-gray-200" />
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@ -0,0 +1,383 @@
|
||||
import { deepClone, getLocaleTr, hasValue, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { ArrowTurnDownLeftSolid, CheckSolid, ClockSolid } from 'components/icons';
|
||||
import { Billboard, BillboardServiceType } from 'common/types/billboard';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import Input from 'components/input/text';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import DataList from 'components/select/data-list';
|
||||
import { restBulkCreateRequest } from 'services/queries/directus/billboard';
|
||||
import Button from 'components/button/button';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import { TranslatableFieldsInfo } from 'components/general/form-tools';
|
||||
import { BillboardFormItem, BillboardFormSection, textInputClass } from '../../../general/fields';
|
||||
import Schedule, { ScheduleFormat } from 'components/general/schedule';
|
||||
|
||||
interface ServiceProviderCreationFormProps {
|
||||
billboard: Billboard;
|
||||
serviceTypes: BillboardServiceType[];
|
||||
themeStyles: React.CSSProperties;
|
||||
theme_color: {
|
||||
superLight: string;
|
||||
light: string;
|
||||
medium: string;
|
||||
full: string;
|
||||
};
|
||||
lastProviderId: number;
|
||||
}
|
||||
|
||||
const ServiceProviderCreationForm: React.FunctionComponent<ServiceProviderCreationFormProps> = ({ theme_color, themeStyles, billboard, serviceTypes, lastProviderId }) => {
|
||||
|
||||
const { locale, query, router } = useGetRouter();
|
||||
const translate = useTranslate();
|
||||
const defaultSchedule = [
|
||||
{ id: 1, dayOfTheWeek: "monday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
{ id: 2, dayOfTheWeek: "tuesday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
{ id: 3, dayOfTheWeek: "wednesday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
{ id: 4, dayOfTheWeek: "thursday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
{ id: 5, dayOfTheWeek: "friday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
{ id: 6, dayOfTheWeek: "saturday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
{ id: 7, dayOfTheWeek: "sunday", workingHours: { from: "08:00", to: "16:00" }, restHours: [], isClosed: false },
|
||||
];
|
||||
|
||||
// states
|
||||
const [fieldsTr, setFieldsTr] = useState(locale);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [hoursOpen, setHoursOpen] = useState(false);
|
||||
const [servicesOpen, setServicesOpen] = useState(false);
|
||||
const [scheduleData, setScheduleData] = useState<ScheduleFormat[]>(defaultSchedule);
|
||||
const [services, setServices] = useState<{ names: string[]; ids: string[] }>({ names: [], ids: [] });
|
||||
const [fieldErrors, setFieldErrors] = useState({
|
||||
first_name: false,
|
||||
last_name: false,
|
||||
services: false,
|
||||
});
|
||||
|
||||
// variables
|
||||
const editorRef: any = useRef(null);
|
||||
|
||||
const formData = useRef({
|
||||
first_name: { fa: "", en: "" },
|
||||
last_name: { fa: "", en: "" },
|
||||
about_me: { fa: "", en: "" },
|
||||
});
|
||||
const formErrors = {
|
||||
necessaryFields: translate("all-fields-necessary-error"),
|
||||
}
|
||||
|
||||
// methods
|
||||
const handleCreateProvider = async () => {
|
||||
setUpdating(true);
|
||||
|
||||
const translations = [];
|
||||
if (hasValue(formData.current.first_name.fa)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "fa-IR"
|
||||
},
|
||||
first_name: formData.current.first_name.fa,
|
||||
last_name: formData.current.last_name.fa,
|
||||
about_me: formData.current.about_me.fa,
|
||||
});
|
||||
}
|
||||
if (hasValue(formData.current.first_name.en)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "en-US"
|
||||
},
|
||||
first_name: formData.current.first_name.en,
|
||||
last_name: formData.current.last_name.en,
|
||||
about_me: formData.current.about_me.en,
|
||||
});
|
||||
}
|
||||
|
||||
const createPayload = {
|
||||
status: "published",
|
||||
provider_id: lastProviderId + 1,
|
||||
translations: translations,
|
||||
billboards: [{
|
||||
Billboards_id: String(query.id),
|
||||
working_days: scheduleData.map(x => ({
|
||||
weekday: x.dayOfTheWeek,
|
||||
from: x.workingHours.from,
|
||||
to: x.workingHours.to,
|
||||
not_working: x.isClosed
|
||||
}))
|
||||
}],
|
||||
services: services.ids.map(x => ({
|
||||
billboard_service_types_id: Number(x)
|
||||
}))
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, error } = await restBulkCreateRequest({
|
||||
collection: 'service_provider',
|
||||
items: [createPayload]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error(`${"service provider creation failed:"} ${error}`);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const validate = async () => {
|
||||
const isMultilingual = hasValue(formData.current.first_name.fa) && hasValue(formData.current.first_name.en);
|
||||
const localErrors = deepClone(fieldErrors);
|
||||
|
||||
hasValue(formData.current.first_name.fa) || hasValue(formData.current.first_name.en) ? localErrors.first_name = false : localErrors.first_name = true;
|
||||
hasValue(formData.current.last_name.fa) || hasValue(formData.current.last_name.en) ? localErrors.last_name = false : localErrors.last_name = true;
|
||||
|
||||
if (isMultilingual) {
|
||||
hasValue(formData.current.first_name.fa) && hasValue(formData.current.first_name.en) ? localErrors.first_name = false : localErrors.first_name = true;
|
||||
hasValue(formData.current.last_name.fa) && hasValue(formData.current.last_name.en) ? localErrors.last_name = false : localErrors.last_name = true;
|
||||
}
|
||||
services.ids.length > 0 ? localErrors.services = false : localErrors.services = true;
|
||||
|
||||
setFieldErrors(localErrors);
|
||||
const hasErrors = Object.entries(localErrors).find(x => x[1] === true) ? true : false;
|
||||
return hasErrors;
|
||||
}
|
||||
|
||||
const handleScheduleUpdate = (data: ScheduleFormat[]) => {
|
||||
setScheduleData(scheduleData.map(x => data.find(y => (x.id === y.id)) ?? x));
|
||||
}
|
||||
|
||||
const handleFormUpdate = (name: string, value: string | string[] | undefined | { fa: any, en: any }) => {
|
||||
formData.current = { ...formData.current, [name]: value };
|
||||
}
|
||||
const handleLangSelect = (lang: string) => {
|
||||
setFieldsTr(lang);
|
||||
}
|
||||
const handleReturn = () => {
|
||||
router.back();
|
||||
}
|
||||
|
||||
const handleServicesSelection = (items: string[]) => {
|
||||
const itemsIds = serviceTypes.filter((x: any) => items.some(y => y === getLocaleTr(x, locale).title)).map((x: any) => x.id);
|
||||
const itemsNames = items;
|
||||
const data: any = { names: itemsNames, ids: itemsIds };
|
||||
setServices(data);
|
||||
}
|
||||
|
||||
const submitProviderData = async () => {
|
||||
const hasErrors = await validate();
|
||||
!hasErrors && handleCreateProvider();
|
||||
}
|
||||
|
||||
const onAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
alertOpen && router.push(`/dashboard/billboards/${query.id}/business-settings/service-providers`);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
locale !== fieldsTr && setFieldsTr(locale);
|
||||
}, [locale])
|
||||
|
||||
// return
|
||||
return <BillboardFormSection wrapperClass="bg-white">
|
||||
<div className="grid grid-cols-2 gap-4 w-full">
|
||||
<EventAlert
|
||||
text={translate("dashboard-changes-successfully-applied")}
|
||||
open={alertOpen}
|
||||
type="success"
|
||||
timeOut={3000}
|
||||
onClose={onAlertClose}
|
||||
hasTime
|
||||
/>
|
||||
{/* details */}
|
||||
<TranslatableFieldsInfo onLangSwitch={handleLangSelect} />
|
||||
|
||||
{/* first name */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "first_name", title: translate("first-name") }}
|
||||
wrapperClass="col-span-2 lg:col-span-1"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.first_name}
|
||||
translatable
|
||||
>
|
||||
<Input
|
||||
id="first_name"
|
||||
name="first_name"
|
||||
type="text"
|
||||
value={fieldsTr === "fa" ? formData.current.first_name.fa : formData.current.first_name.en}
|
||||
maxChar={100}
|
||||
maxLength={100}
|
||||
className={`${textInputClass} ${fieldsTr === "en" ? "[direction:ltr]" : ""}`}
|
||||
onInput={(data) => handleFormUpdate("first_name", { ...formData.current.first_name, [fieldsTr as any]: data })}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* last name */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "last_name", title: translate("last-name") }}
|
||||
wrapperClass="col-span-2 lg:col-span-1"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.last_name}
|
||||
translatable
|
||||
>
|
||||
<Input
|
||||
id="last_name"
|
||||
name="last_name"
|
||||
type="text"
|
||||
value={fieldsTr === "fa" ? formData.current.last_name.fa : formData.current.last_name.en}
|
||||
maxChar={100}
|
||||
maxLength={100}
|
||||
className={`${textInputClass} ${fieldsTr === "en" ? "[direction:ltr]" : ""}`}
|
||||
onInput={(data) => handleFormUpdate("last_name", { ...formData.current.last_name, [fieldsTr as any]: data })}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* about me */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "about_me", title: translate("about-me") }}
|
||||
wrapperClass="col-span-2"
|
||||
childrenClass="[&_div.tox]:rtl:[direction:rtl]"
|
||||
translatable
|
||||
>
|
||||
<Editor
|
||||
tinymceScriptSrc={'/tinymce/tinymce.min.js'}
|
||||
onInit={(evt, editor) => editorRef.current = editor}
|
||||
initialValue={(formData.current.about_me as any)[`${fieldsTr}`]}
|
||||
init={{
|
||||
height: 350,
|
||||
menubar: false,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen', 'directionality',
|
||||
'insertdatetime', 'media', 'table', 'preview', 'help', 'wordcount',
|
||||
],
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic ltr rtl removeformat | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent fullscreen|',
|
||||
content_style: `
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('/fonts/IRANSansWeb.woff2') format('woff2'),
|
||||
url('/fonts/IRANSansWeb.woff') format('woff'),
|
||||
url('/fonts/IRANSansWeb.ttf') format('truetype'),
|
||||
url('/fonts/IRANSansWeb.eot'),
|
||||
url('/fonts/IRANSansWeb.eot?#iefix') format('embedded-opentype');
|
||||
unicode-range: 0020-007F, 00A0-00BB, 0600-067F;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
body {
|
||||
font-family: 'IRANSans', Helvetica, Arial, sans-serif; font-size:14px; line-height: 28px; ${locale === "en" ? "direction:ltr" : "direction:rtl"};
|
||||
}
|
||||
@media only screen and (max-width: 639px) {
|
||||
body {
|
||||
font-size:12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
onChange={() => handleFormUpdate("about_me", { ...formData.current.about_me, [fieldsTr as any]: editorRef.current?.getContent() })}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* working days & hours */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "hours", title: translate("dashboard-billboard-create-service-provider-hours"), className: "pb-2" }}
|
||||
wrapperClass="col-span-2 lg:col-span-1 pt-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.last_name}
|
||||
>
|
||||
<Schedule
|
||||
open={hoursOpen}
|
||||
dayData={defaultSchedule[0]}
|
||||
onClose={() => setHoursOpen(false)}
|
||||
onChange={handleScheduleUpdate}
|
||||
themeStyles={themeStyles}
|
||||
/>
|
||||
<ul className="grid grid-cols-1 md:grid-cols-2 gap-x-8 gap-y-4 w-full lg:max-w-3xl py-3 px-3 md:py-4 md:px-4 bg-neutral-50 border-[3px] border-white ring-1 ring-neutral-100 rounded-lg">
|
||||
{scheduleData.map((x, index) => (
|
||||
<li key={index} className="flex space-x-4 rtl:space-x-reverse w-full items-center justify-between">
|
||||
<span className="inline-block text-xs/6 md:text-sm/7 text-secondary-light shrink-0 font-semibold py-1 px-4 rounded-full capitalize bg-white ring-1 ring-neutral-100">{translate(x.dayOfTheWeek)}</span>
|
||||
<span className="inline-block w-full border-t border-dashed border-cool-gray"></span>
|
||||
<span className="inline-block [direction:ltr] text-xs/6 md:text-sm/7 text-secondary-light font-semibold shrink-0 py-1 px-4 rounded-full bg-white ring-1 ring-neutral-100">
|
||||
{
|
||||
x.isClosed ?
|
||||
translate("is-closed")
|
||||
:
|
||||
`${x.workingHours.from} - ${x.workingHours.to}`
|
||||
}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
text={closed ? translate("dashboard-billboard-page-temporarily-closed-false-label") : translate("dashboard-billboard-edit-hours-add-day")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm md:text-base mt-6 capitalize shadow-none w-full md:w-72 px-[10px] py-2 md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={() => setHoursOpen(true)}
|
||||
leftIcon={<ClockSolid className="inline-block size-4 text-white fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* services */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "services", title: translate("dashboard-billboard-create-service-provider-services"), className: "pb-2" }}
|
||||
wrapperClass="col-span-2 lg:col-span-1 pt-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.services}
|
||||
>
|
||||
<DataList
|
||||
id={"service-types"}
|
||||
label={""}
|
||||
options={serviceTypes.map(x => getLocaleTr(x, locale).title)}
|
||||
onChange={handleServicesSelection}
|
||||
isOpen={servicesOpen}
|
||||
onClose={() => setServicesOpen(false)}
|
||||
labelHidden
|
||||
allValue={translate("select")}
|
||||
value={services.names}
|
||||
style={themeStyles}
|
||||
multiple
|
||||
wrapperClass={`ring-1 ring-billboard-input-ring rounded-xl`}
|
||||
buttonClass="leading-6 px-4 py-3 rounded-xl bg-billboard-input"
|
||||
buttonLabelsWrapperClass="[&_span]:text-xs/5 [&_.def-option]:text-sm/6 !overflow-x-visible grid grid-cols-3 gap-2"
|
||||
inputClass="market-datalist-input [&_input]:ring-[var(--brand-color)]"
|
||||
listContainerClass="market-datalist-list-container"
|
||||
listClass="market-datalist-list"
|
||||
listItemClass="market-datalist-list-item"
|
||||
multiLabelClass="qf-datalist-list-multi-labels bg-[var(--brand-color)] mb-0 shrink-0 !mx-0"
|
||||
modalWrapperClass="rounded-xl overflow-hidden"
|
||||
checkboxLabelClass="text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
checkboxTitleClass="text-text-gray text-xs sm:text-sm"
|
||||
ctaClass="!bg-[var(--brand-color)] rounded-lg"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="grid grid-cols-2 col-span-2 gap-4 pb-2 lg:px-2 lg:pb-4 pt-6 mt-2 w-full lg:max-w-3xl mx-auto">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm md:text-base capitalize shadow-none w-full px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
leftIcon={<CheckSolid className="inline-block size-4 text-white fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={submitProviderData}
|
||||
loading={updating}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm md:text-base capitalize shadow-none w-full px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<ArrowTurnDownLeftSolid className="inline-block size-4 text-[var(--brand-color)] fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={handleReturn}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</BillboardFormSection>
|
||||
}
|
||||
export default ServiceProviderCreationForm
|
||||
@ -0,0 +1,380 @@
|
||||
import { getEngTr, getLocaleTr, getPerTr, hasValue, stripHtml, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { DollarSignSolid, PenSolid, PlusSolid, TicketSolid, TriangleExclamationSolid, XmarkSolid } from 'components/icons';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import Button from 'components/button/button';
|
||||
import BillboardSection from '../section';
|
||||
import Modal from 'components/modal/modal';
|
||||
import { createPriceUnitItem, deletePriceUnitItem, mutationItemsRequest, restBulkCreateRequest, restBulkDeleteRequest, restBulkUpdateRequest, updatePriceUnitItem } from 'services/queries/directus/billboard';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import { ReservationTicketType } from 'common/types/billboard';
|
||||
import Input from 'components/input/text';
|
||||
import { BillboardFormItem } from '../../general/fields';
|
||||
import { formErrors, formInputClass, TranslatableFieldsInfo } from 'components/general/form-tools';
|
||||
import TextArea from 'components/input/text-area';
|
||||
|
||||
interface TicketTypesProps {
|
||||
ticketTypes: ReservationTicketType[];
|
||||
themeStyles: React.CSSProperties;
|
||||
}
|
||||
|
||||
const TicketTypes: React.FunctionComponent<TicketTypesProps> = ({ ticketTypes, themeStyles }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale, query, router } = useGetRouter();
|
||||
|
||||
// states
|
||||
const [fieldsTr, setFieldsTr] = useState(locale);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [popupOpen, setPopupOpen] = useState(false);
|
||||
const [formType, setFormType] = useState<"edit" | "create">("edit");
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [selectedTicketType, setSelectedTicketType] = useState<ReservationTicketType | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState({
|
||||
title: false,
|
||||
});
|
||||
|
||||
// variables
|
||||
const errors = formErrors(translate);
|
||||
const currentItem = useRef({
|
||||
title: {
|
||||
fa: "",
|
||||
en: ""
|
||||
},
|
||||
description: {
|
||||
fa: "",
|
||||
en: ""
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
|
||||
// methods
|
||||
const handleCloseModal = () => {
|
||||
setPopupOpen(false);
|
||||
}
|
||||
const resetFields = () => {
|
||||
currentItem.current = {
|
||||
title: {
|
||||
fa: "",
|
||||
en: ""
|
||||
},
|
||||
description: {
|
||||
fa: "",
|
||||
en: ""
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// checks for non-empty & non existing title field
|
||||
const validate = async () => {
|
||||
setFieldErrors({
|
||||
title: (
|
||||
(!hasValue(currentItem.current.title.fa) && !hasValue(currentItem.current.title.en)) &&
|
||||
(fieldsTr === "fa" ? currentItem.current.title.fa : currentItem.current.title.en) === getLocaleTr(selectedTicketType, locale).title)
|
||||
? true : false,
|
||||
});
|
||||
}
|
||||
|
||||
// create ticket type
|
||||
const handleTicketCreate = async () => {
|
||||
await validate();
|
||||
setUpdating(true);
|
||||
|
||||
const translations = [];
|
||||
if (hasValue(currentItem.current.title.fa)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "fa-IR"
|
||||
},
|
||||
title: currentItem.current.title.fa,
|
||||
description: currentItem.current.description.fa,
|
||||
});
|
||||
}
|
||||
if (hasValue(currentItem.current.title.en)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "en-US"
|
||||
},
|
||||
title: currentItem.current.title.en,
|
||||
description: currentItem.current.description.en,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await restBulkCreateRequest({
|
||||
collection: 'reservation_ticket_type',
|
||||
items: [
|
||||
{
|
||||
status: "published",
|
||||
billboard: query.id,
|
||||
translations: translations,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setPopupOpen(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error("ticket type creation failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// update ticket type
|
||||
const handleTicketUpdate = async () => {
|
||||
await validate();
|
||||
setUpdating(true);
|
||||
|
||||
const translations = [];
|
||||
if (hasValue(currentItem.current.title.fa)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "fa-IR"
|
||||
},
|
||||
title: currentItem.current.title.fa,
|
||||
description: currentItem.current.description.fa,
|
||||
});
|
||||
}
|
||||
if (hasValue(currentItem.current.title.en)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "en-US"
|
||||
},
|
||||
title: currentItem.current.title.en,
|
||||
description: currentItem.current.description.en,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await restBulkUpdateRequest({
|
||||
collection: 'reservation_ticket_type',
|
||||
updates: [
|
||||
{
|
||||
id: selectedTicketType!.id,
|
||||
data: {
|
||||
translations: translations,
|
||||
},
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setPopupOpen(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error("ticket type update failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// delete ticket type
|
||||
const handleTicketDelete = async () => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
await restBulkDeleteRequest({
|
||||
collection: 'reservation_ticket_type',
|
||||
ids: [selectedTicketType!.id]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setDeleteModalOpen(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error("ticket type delete failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
alertOpen && router.reload();
|
||||
}
|
||||
|
||||
const handleEdit = (ticket: ReservationTicketType) => {
|
||||
setFormType("edit");
|
||||
|
||||
currentItem.current = {
|
||||
title: {
|
||||
fa: getPerTr(ticket).title,
|
||||
en: getEngTr(ticket).title
|
||||
},
|
||||
description: {
|
||||
fa: getPerTr(ticket).description,
|
||||
en: getEngTr(ticket).description
|
||||
},
|
||||
};
|
||||
setSelectedTicketType(ticket);
|
||||
setPopupOpen(true);
|
||||
}
|
||||
|
||||
const handleDelete = (id: string) => {
|
||||
setSelectedTicketType(ticketTypes.filter(x => x.id === id)[0]);
|
||||
setDeleteModalOpen(true);
|
||||
}
|
||||
|
||||
const handleCreateTicketType = () => {
|
||||
setPopupOpen(true);
|
||||
resetFields();
|
||||
setFormType("create");
|
||||
}
|
||||
|
||||
const handleFormUpdate = (type: string, data: string) => {
|
||||
["title", "description"].includes(type) ? ((currentItem.current as any)[type][fieldsTr as any]) = data : (currentItem.current as any)[type] = data;
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
locale !== fieldsTr && setFieldsTr(locale);
|
||||
}, [locale])
|
||||
|
||||
// return
|
||||
return (
|
||||
<BillboardSection
|
||||
title={translate("dashboard-billboard-page-ticket-types-title")}
|
||||
icon={<TicketSolid className="inline-block size-4 text-[var(--brand-color)] fill-current" />}
|
||||
wrapperClass=""
|
||||
>
|
||||
<EventAlert
|
||||
text={translate("dashboard-changes-successfully-applied")}
|
||||
open={alertOpen}
|
||||
type="success"
|
||||
timeOut={3000}
|
||||
onClose={onAlertClose}
|
||||
hasTime
|
||||
/>
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`edit-ticket-modal`}
|
||||
open={popupOpen}
|
||||
onClose={handleCloseModal}
|
||||
title={formType === "edit" ? translate("edit") : translate("dashboard-billboard-page-price-units-create")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="block w-full sm:w-[500px] p-gi">
|
||||
<TranslatableFieldsInfo onLangSwitch={setFieldsTr} wrapperClass="mb-4" textClass="!text-xs/6" />
|
||||
<BillboardFormItem
|
||||
label={{ forId: "ticket-title", title: translate("title") }}
|
||||
wrapperClass="mb-5"
|
||||
required
|
||||
errorText={errors.necessaryFields}
|
||||
errorVisible={fieldErrors.title}
|
||||
translatable
|
||||
>
|
||||
<Input
|
||||
id="ticket-title"
|
||||
name="ticket-title"
|
||||
type="text"
|
||||
value={fieldsTr === "fa" ? currentItem.current.title.fa : currentItem.current.title.en}
|
||||
className={`${formInputClass} ${fieldsTr === "en" ? "[direction:ltr]" : ""}`}
|
||||
onInput={(data) => handleFormUpdate("title", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
<BillboardFormItem
|
||||
label={{ forId: "ticket-description", title: translate("description") }}
|
||||
wrapperClass="mb-5"
|
||||
translatable
|
||||
>
|
||||
<TextArea
|
||||
id="ticket-description"
|
||||
name="ticket-description"
|
||||
value={fieldsTr === "fa" ? currentItem.current.description.fa : currentItem.current.description.en}
|
||||
className={`${formInputClass} ${fieldsTr === "en" ? "[direction:ltr]" : ""}`}
|
||||
onInput={(data) => handleFormUpdate("description", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
<div className="grid grid-cols-2 gap-4 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={() => formType === "create" ? handleTicketCreate() : handleTicketUpdate()}
|
||||
loading={updating}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`delete-ticket-modal`}
|
||||
open={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title={translate("dashboard-billboard-page-ticket-delete-title")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="block w-full sm:w-[500px] p-gi">
|
||||
<p className="text-xs/6 sm:text-sm/7">{translate("dashboard-billboard-page-ticket-delete-text")}</p>
|
||||
<span className="block w-max py-1 px-4 rounded-lg bg-warning-bg text-warning-text mt-4 mx-auto">{selectedTicketType ? getLocaleTr(ticketTypes.filter(x => x.id === selectedTicketType.id)[0], locale).title : ""}</span>
|
||||
<div className="grid grid-cols-2 gap-4 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={`${translate("delete")} ${translate("ticket-type")}`}
|
||||
className="bg-[var(--brand-color)] text-white text-xs lg:text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={handleTicketDelete}
|
||||
loading={updating}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-xs lg:text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<p className="block text-xs/6">{translate("dashboard-billboard-page-ticket-types-text")}</p>
|
||||
{ticketTypes.length > 0 ?
|
||||
<>
|
||||
<p className="flex items-center bg-warning-bg text-warning-text rounded-xl py-2 px-4 border-4 border-white ring-2 ring-warning-bg text-xs/6 mt-4">
|
||||
<TriangleExclamationSolid className="inline-block size-4 shrink-0 fill-warning-text rtl:ml-3 ltr:mr-3" />
|
||||
{translate("dashboard-billboard-page-ticket-types-warning")}
|
||||
</p>
|
||||
<ul className="block w-full list-none space-y-2 mt-6">
|
||||
{ticketTypes.map(x => (
|
||||
<li key={x.id} className="flex items-center justify-between w-full py-2 px-2 rounded-xl ring-1 ring-neutral-100">
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 xl:text-sm/5 font-semibold bg-[var(--light-brand-color)] text-[var(--brand-color)]">{getLocaleTr(x, locale).title}</span>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<PenSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-warning-bg hover:fill-warning-text lg:cursor-pointer" onClick={() => handleEdit(x)} />
|
||||
<XmarkSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-error-bg hover:fill-error-text lg:cursor-pointer" onClick={() => handleDelete(x.id)} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
:
|
||||
<div className="flex flex-col items-center justify-center w-full mt-4 pt-4 border-t border-dashed border-neutral-100">
|
||||
<span className="flex items-center text-secondary-light text-xs/6 mb-4 font-semibold">
|
||||
{translate("dashboard-billboard-page-price-units-no-data")}
|
||||
</span>
|
||||
<img src="/pics/folder-no-data-blue.svg" className="block w-48" />
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-page-ticket-types-cta")}
|
||||
className="bg-[var(--light-brand-color)] text-[var(--brand-color)] text-xs capitalize shadow-none w-max px-[10px] pt-[6px] pb-[6px] md:px-4 md:py-2 inline-flex justify-center items-center rounded-lg mt-4"
|
||||
onClick={handleCreateTicketType}
|
||||
leftIcon={<PlusSolid className="inline-block size-3 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2" />}
|
||||
/>
|
||||
</BillboardSection>
|
||||
)
|
||||
}
|
||||
export default TicketTypes
|
||||
@ -1,13 +1,13 @@
|
||||
import { useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { ArrowLeftSolid, BellSolid, BoxOpenSolid, CircleDollarSolid, CircleQuestionSolid, MessagesSolid, RotateRightSolid } from 'components/icons';
|
||||
import { ArrowLeftSolid, BellSolid, BoxOpenSolid, CircleDollarSolid, CircleQuestionSolid, GearSolid, MessagesSolid } from 'components/icons';
|
||||
import Link from 'components/link/link';
|
||||
|
||||
interface BillboardProductsNewsProps {
|
||||
interface CTAButtonsProps {
|
||||
|
||||
}
|
||||
|
||||
const BillboardProductsNews: React.FunctionComponent<BillboardProductsNewsProps> = ({ }) => {
|
||||
const CTAButtons: React.FunctionComponent<CTAButtonsProps> = ({ }) => {
|
||||
|
||||
// states
|
||||
// const [value, setValue] = useState<string[]>([]);
|
||||
@ -19,10 +19,10 @@ const BillboardProductsNews: React.FunctionComponent<BillboardProductsNewsProps>
|
||||
const parts = [
|
||||
{ id: 1, title: translate("dashboard-billboard-page-products-management"), icon: <BoxOpenSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/products?page=1` },
|
||||
{ id: 2, title: translate("dashboard-billboard-page-news-management"), icon: <BellSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/news` },
|
||||
{ id: 3, title: translate("dashboard-billboard-page-faq-management"), icon: <CircleQuestionSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/faqs` },
|
||||
{ id: 6, title: translate("dashboard-billboard-page-reviews-management"), icon: <MessagesSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/reviews` },
|
||||
{ id: 4, title: translate("dashboard-billboard-page-cancel-return-management"), icon: <RotateRightSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/order-policies` },
|
||||
{ id: 5, title: translate("dashboard-billboard-page-billing-management"), icon: <CircleDollarSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/billing` },
|
||||
{ id: 3, title: translate("dashboard-billboard-page-vitrine-management"), icon: <CircleQuestionSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/showcase` },
|
||||
{ id: 4, title: translate("dashboard-billboard-page-reviews-management"), icon: <MessagesSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/reviews` },
|
||||
{ id: 5, title: translate("dashboard-billboard-page-business-settings"), icon: <GearSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/business-settings` },
|
||||
{ id: 6, title: translate("dashboard-billboard-page-billing-management"), icon: <CircleDollarSolid className={highlighIconClass} />, value: `/dashboard/billboards/${query.id}/billing` },
|
||||
]
|
||||
|
||||
// methods
|
||||
@ -48,4 +48,4 @@ const BillboardProductsNews: React.FunctionComponent<BillboardProductsNewsProps>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
export default BillboardProductsNews
|
||||
export default CTAButtons
|
||||
@ -51,6 +51,7 @@ const BillboardReview: React.FunctionComponent<BillboardReviewProps> = ({ themeS
|
||||
order: []
|
||||
});
|
||||
const defaultUnit = { fa: "عدد", en: "item" };
|
||||
const defaultTicket = { fa: "استاندارد", en: "standard" };
|
||||
|
||||
// methods
|
||||
const onPrev = () => {
|
||||
@ -79,6 +80,44 @@ const BillboardReview: React.FunctionComponent<BillboardReviewProps> = ({ themeS
|
||||
|
||||
// gallery methods
|
||||
|
||||
// step-4 => create a default ticket type
|
||||
const handleCreateTicketType = async (bId: string) => {
|
||||
const translations = [];
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "fa-IR"
|
||||
},
|
||||
title: defaultTicket.fa,
|
||||
});
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "en-US"
|
||||
},
|
||||
title: defaultTicket.en,
|
||||
});
|
||||
|
||||
try {
|
||||
const results = await restBulkCreateRequest({
|
||||
collection: 'reservation_ticket_type',
|
||||
items: [
|
||||
{
|
||||
status: "published",
|
||||
billboard: bId,
|
||||
translations: translations,
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const { data: ticketTypeData, error } = results;
|
||||
if (error) {
|
||||
console.log("creating ticket type failed:", error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Billboard creation failed:", err);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// step-3 => create a default price unit
|
||||
const handleCreatePriceUnit = async (bId: string) => {
|
||||
const translations = [];
|
||||
@ -227,10 +266,12 @@ const BillboardReview: React.FunctionComponent<BillboardReviewProps> = ({ themeS
|
||||
const billoboard = await handleCreateBillboard(null);
|
||||
bId = billoboard.data[0].id;
|
||||
}
|
||||
|
||||
|
||||
// step-3 => create price unit
|
||||
await handleCreatePriceUnit(bId);
|
||||
// step-4 => publish the billboard
|
||||
// step-4 => create standard ticket type (if reservation business)
|
||||
await handleCreateTicketType(bId);
|
||||
// step-5 => publish the billboard
|
||||
await handlePublishBillboard(bId);
|
||||
|
||||
localStorage.removeItem("unfinished_billboard");
|
||||
|
||||
@ -35,11 +35,11 @@ const StepButtons: React.FunctionComponent<StepButtonsProps> = ({ onPrev, onNext
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="flex items-center justify-center py-4 sm:px-4 mt-8 max-w-3xl mx-auto space-x-4 rtl:space-x-reverse">
|
||||
return <div className="grid grid-cols-2 gap-4 items-center justify-center pt-4 pb-2 sm:px-4 mt-8 max-w-3xl mx-auto">
|
||||
{step > 1 && <Button
|
||||
type="button"
|
||||
text={translate("prev-step")}
|
||||
className={`${step === 4 ? "col-span-2" : "col-span-1"} bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] py-3 px-4 text-sm md:text-base capitalize shadow-none w-full max-w-96 inline-flex justify-center items-center rounded-xl`}
|
||||
className={`${step === 4 ? "col-span-2 sm:col-span-1" : "col-span-1"} bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] py-3 px-4 text-sm md:text-base capitalize shadow-none w-full max-w-96 inline-flex justify-center items-center rounded-xl`}
|
||||
leftIcon={<ArrowRight className="ltr:rotate-180 inline-block size-4 text-[var(--brand-color)] fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={goPrevStep}
|
||||
/>}
|
||||
@ -53,7 +53,7 @@ const StepButtons: React.FunctionComponent<StepButtonsProps> = ({ onPrev, onNext
|
||||
{step === 4 && <Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-form-create-submit-cta")}
|
||||
className={`col-span-1 bg-[var(--brand-color)] py-3 px-4 text-white text-sm md:text-base capitalize shadow-none w-full max-w-96 inline-flex justify-center items-center rounded-xl`}
|
||||
className={`col-span-2 sm:col-span-1 bg-[var(--brand-color)] py-3 px-4 text-white text-sm md:text-base capitalize shadow-none w-full max-w-96 inline-flex justify-center items-center rounded-xl`}
|
||||
rightIcon={<ArrowLeft className="ltr:rotate-180 inline-block size-4 text-white fill-current rtl:mr-3 ltr:ml-3" />}
|
||||
onClick={onCreate}
|
||||
loading={updating}
|
||||
|
||||
@ -38,6 +38,7 @@ interface FormGroupProps {
|
||||
title: string,
|
||||
text?: string,
|
||||
textClass?: string;
|
||||
headerClass?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
open: boolean;
|
||||
@ -122,7 +123,7 @@ export const BillboardFormItem: React.FunctionComponent<BillboardFormItemProps>
|
||||
}
|
||||
|
||||
// form items category label
|
||||
export const FormGroup: React.FunctionComponent<FormGroupProps> = ({ className, title, text, children, open = false, onChange, labelIcon, titleClass, textClass, hidden = false, childrenClass }) => {
|
||||
export const FormGroup: React.FunctionComponent<FormGroupProps> = ({ className, title, text, children, open = false, onChange, labelIcon, titleClass, textClass, headerClass, hidden = false, childrenClass }) => {
|
||||
|
||||
// states
|
||||
const [isOpen, setIsOpen] = useState(open);
|
||||
@ -143,7 +144,7 @@ export const FormGroup: React.FunctionComponent<FormGroupProps> = ({ className,
|
||||
<>
|
||||
{!hidden ?
|
||||
<div className={`block w-full ${className}`}>
|
||||
<div className="flex items-center justify-between bg-white w-full lg:cursor-pointer border-b border-dashed border-neutral-200/75 py-4" onClick={handleChange}>
|
||||
<div className={`flex items-center justify-between bg-white w-full lg:cursor-pointer border-b border-dashed border-neutral-200/75 py-4 ${headerClass}`} onClick={handleChange}>
|
||||
<span className={`block w-full text-sm md:text-base text-text-gray font-semibold select-none capitalize ${titleClass}`}>
|
||||
{labelIcon ? labelIcon : <CaretLeftSolid className={`inline-block size-3 md:size-4 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2 transition-all ${open ? "rtl:-rotate-90 ltr:rotate-[270deg]" : "ltr:rotate-180"}`} />}
|
||||
{title}
|
||||
@ -185,7 +186,7 @@ export const SectionControls: React.FunctionComponent<SectionControlsProps> = ({
|
||||
{buttons.map(x => (
|
||||
<div
|
||||
key={x.id}
|
||||
className={`reactive-button flex items-center justify-center px-4 py-3 rounded-xl bg-neutral-50 text-secondary-light space-x-3 rtl:space-x-reverse border-[3px] border-white shadow ${buttonClass}`}
|
||||
className={`reactive-button flex items-center justify-center px-4 py-3 rounded-xl bg-neutral-50 text-secondary-light select-none space-x-3 rtl:space-x-reverse border-[3px] border-white shadow ${buttonClass}`}
|
||||
onClick={() => handleClick(x.cb)}
|
||||
>
|
||||
{x.icon}
|
||||
|
||||
@ -2,13 +2,15 @@ import { getLocaleTr, hasValue, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { ImageSharpSolid } from 'components/icons';
|
||||
import Link from 'components/link/link';
|
||||
import { BillboardProduct } from 'common/types/billboard';
|
||||
import { Billboard, BillboardProduct, ProductVariantGalleries } from 'common/types/billboard';
|
||||
import Image from 'components/image/image';
|
||||
import { StockDataProps } from 'pages/api/billboard/product-stock-data';
|
||||
import { PriceDataProps } from 'pages/api/billboard/product-price-data';
|
||||
|
||||
interface ProductsTableItemProps {
|
||||
billboard: Billboard;
|
||||
data: BillboardProduct;
|
||||
thumb: ProductVariantGalleries;
|
||||
stockData: StockDataProps;
|
||||
priceData: PriceDataProps;
|
||||
wrapperClass?: string;
|
||||
@ -29,7 +31,7 @@ const DetailItem: React.FunctionComponent<DetailItemProps> = ({ label, value, wr
|
||||
<span className={`inline-block text-[0.7rem]/5 text-secondary-light ${valueClass}`}>{value}</span>
|
||||
</li>
|
||||
}
|
||||
const ProductsTableItem: React.FunctionComponent<ProductsTableItemProps> = ({ data, stockData, priceData, wrapperClass, style }) => {
|
||||
const ProductsTableItem: React.FunctionComponent<ProductsTableItemProps> = ({ billboard, data, thumb, stockData, priceData, wrapperClass, style }) => {
|
||||
|
||||
// states
|
||||
|
||||
@ -38,11 +40,10 @@ const ProductsTableItem: React.FunctionComponent<ProductsTableItemProps> = ({ da
|
||||
const translate = useTranslate();
|
||||
const productTtile = getLocaleTr(data, locale).title;
|
||||
const price = priceData;
|
||||
const hasPics = !data.variations || !data.variations.some(x => x.pics.length > 0) ? false : true;
|
||||
const hasPics = billboard.product_types[0] === "reservation" ? true : (!data.variations || !data.variations.some(x => x.pics.length > 0) ? false : true);
|
||||
|
||||
const thumbPic = () => {
|
||||
let pic = data.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery[0];
|
||||
if (data.type === "ad") pic = data.ad_item.gallery[0];
|
||||
let pic = billboard.product_types[0] === "reservation" ? thumb.gallery[0] : data.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery[0];
|
||||
return pic
|
||||
}
|
||||
|
||||
@ -52,8 +53,8 @@ const ProductsTableItem: React.FunctionComponent<ProductsTableItemProps> = ({ da
|
||||
|
||||
// return
|
||||
return (
|
||||
<Link href={`/dashboard/billboards/${data.billboard[0].Billboards_id.id}/products/${data.product_id}`} style={style} className={`reactive-button flex items-center sm:flex-col max-sm:space-x-2 rtl:space-x-reverse w-full bg-white p-2 rounded-xl shadow-sm ${wrapperClass}`}>
|
||||
<div className="block relative sm:w-full h-24 sm:h-36 shrink-0 sm:p-2 rounded-xl">
|
||||
<Link href={`/dashboard/billboards/${data.billboard[0].Billboards_id.id}/products/${data.product_id}`} style={style} className={`reactive-button flex items-center sm:flex-col max-sm:gap-x-2 w-full bg-white p-2 rounded-xl shadow-sm ${wrapperClass}`}>
|
||||
<div className="block relative sm:w-full h-24 sm:h-36 shrink-0 p-1 sm:p-2 rounded-xl">
|
||||
{hasPics && hasValue(thumbPic()) ?
|
||||
<Image
|
||||
src={`${thumbPic().directus_files_id.id}/${thumbPic().directus_files_id.filename_download}`}
|
||||
|
||||
@ -15,8 +15,11 @@ import StepButtons from '../create-product/step-buttons';
|
||||
import { UnfinishedProductProps } from 'pages/dashboard/billboards/[id]/products/create-product';
|
||||
import { UnitItem } from 'common/types/general';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import Select from 'components/select/select';
|
||||
|
||||
interface BasicDataFormProps {
|
||||
product_types: ["realestate" | "vehicle" | "shop" | "reservation"];
|
||||
skuCodes: string[];
|
||||
themeStyles: React.CSSProperties;
|
||||
theme: {
|
||||
superLight: string;
|
||||
@ -33,19 +36,22 @@ interface BasicDataFormProps {
|
||||
onUpdate: (data: any) => void;
|
||||
}
|
||||
|
||||
const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyles, theme, fieldsData, data, currency, onUpdate }) => {
|
||||
const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ product_types, skuCodes, themeStyles, theme, fieldsData, data, currency, onUpdate }) => {
|
||||
|
||||
const { locale, router } = useGetRouter();
|
||||
const translate = useTranslate();
|
||||
const primaryPriceUnitId = fieldsData.price_units.filter(x => x.unit_type.is_primary)[0].id;
|
||||
const selectedStockUnitType = fieldsData.units.filter(x => Number(x.id) === 1)[0];
|
||||
const initialProductGeneralType = product_types[0] === "shop" ? "purchasable" : "reservation";
|
||||
|
||||
// states
|
||||
const [fieldsTr, setFieldsTr] = useState(locale);
|
||||
const [typeSelectionModal, setTypeSelectionModal] = useState(false);
|
||||
const [productTypeSearch, setProductTypeSearch] = useState("");
|
||||
const [selectionOpen, setSelectionOpen] = useState(false);
|
||||
const [productGeneralType, setProductGeneralType] = useState(initialProductGeneralType);
|
||||
const [features, setFeatures] = useState({
|
||||
price_unit: data ? data.price_unit : { names: [], ids: [] },
|
||||
stock_unit_type: data ? data.stock_unit_type : { names: [], ids: [] },
|
||||
stock_unit_type: data.stock_unit_type.ids.length === 0 ? { names: [getLocaleTr(selectedStockUnitType, locale).name], ids: [selectedStockUnitType.id] } : data.stock_unit_type,
|
||||
});
|
||||
|
||||
// variables
|
||||
@ -73,6 +79,7 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
product_type: data ? data.product_type : null,
|
||||
// price
|
||||
price: data ? data.price : -1,
|
||||
price_unit: primaryPriceUnitId,
|
||||
// stock data
|
||||
code: data ? data.code : "",
|
||||
});
|
||||
@ -83,10 +90,12 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
price: false,
|
||||
price_unit: false,
|
||||
code: false,
|
||||
unique_code: false,
|
||||
stock_unit_type: false
|
||||
});
|
||||
const formErrors = {
|
||||
necessaryFields: translate("all-fields-necessary-error"),
|
||||
uniqueCode: translate("product-code-not-unique-error"),
|
||||
}
|
||||
|
||||
const productOtherTypeQuery = `
|
||||
@ -182,6 +191,14 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
const otherProducts: ProductType = productTypesData?.data.other_type.find((x: ProductType) => x.id === "2204");
|
||||
|
||||
// methods
|
||||
const getAvailableProductTypes = () => {
|
||||
let types = [];
|
||||
if (product_types.includes("shop")) types.push({ label: translate(`billboard-product-sell-type-purchasable`), value: "purchasable", disabled: false });
|
||||
if (product_types.includes("reservation")) types.push({ label: translate(`billboard-product-sell-type-reservation`), value: "reservation", disabled: false });
|
||||
types.push({ label: translate(`billboard-product-sell-type-gallery`), value: "gallery", disabled: false });
|
||||
return types;
|
||||
}
|
||||
|
||||
const handleUpdateProductData = async () => {
|
||||
onUpdate({
|
||||
title: {
|
||||
@ -192,15 +209,16 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
fa: formData.current.description.fa,
|
||||
en: formData.current.description.en
|
||||
},
|
||||
type: productGeneralType,
|
||||
product_card_type: productGeneralType === "purchasable" ? "shop" : "reservation",
|
||||
product_type: formData.current.product_type,
|
||||
code: formData.current.code,
|
||||
price: formData.current.price,
|
||||
price_unit: features.price_unit,
|
||||
price_unit: formData.current.price_unit,
|
||||
stock_unit_type: features.stock_unit_type,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// validation
|
||||
const validate = async () => {
|
||||
// const isMultilingual = hasValue(formData.current.style_text.fa) && hasValue(formData.current.style_text.en);
|
||||
@ -209,9 +227,10 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
(!hasValue(formData.current.title.fa) && !hasValue(formData.current.title.en)) ? localErrors.title = true : localErrors.title = false;
|
||||
!hasValue(formData.current.product_type) ? localErrors.productType = true : localErrors.productType = false;
|
||||
!hasValue(formData.current.code) ? localErrors.code = true : localErrors.code = false;
|
||||
formData.current.price === -1 ? localErrors.price = true : localErrors.price = false;
|
||||
features.price_unit.ids.length === 0 ? localErrors.price_unit = true : localErrors.price_unit = false;
|
||||
features.stock_unit_type.ids.length === 0 ? localErrors.stock_unit_type = true : localErrors.stock_unit_type = false;
|
||||
skuCodes.includes(formData.current.code) ? localErrors.unique_code = true : localErrors.unique_code = false;
|
||||
if (productGeneralType !== "gallery") {
|
||||
formData.current.price === -1 ? localErrors.price = true : localErrors.price = false;
|
||||
}
|
||||
|
||||
setFieldErrors({ ...localErrors });
|
||||
const hasErrors = Object.entries(localErrors).find(x => x[1] === true) ? true : false;
|
||||
@ -266,14 +285,15 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
useEffect(() => {
|
||||
setFeatures(prev => ({
|
||||
...prev,
|
||||
price_unit: data ? data.price_unit : { names: [], ids: [] },
|
||||
stock_unit_type: data ? data.stock_unit_type : { names: [], ids: [] },
|
||||
stock_unit_type: data.stock_unit_type.ids.length === 0 ? { names: [getLocaleTr(selectedStockUnitType, locale).name], ids: [selectedStockUnitType.id] } : data.stock_unit_type,
|
||||
}));
|
||||
setProductGeneralType(data ? data.type : initialProductGeneralType);
|
||||
formData.current = {
|
||||
title: data ? { fa: data.title.fa, en: data.title.en } : { fa: "", en: "" },
|
||||
description: data ? { fa: data.description.fa, en: data.description.en } : { fa: "", en: "" },
|
||||
product_type: data ? data.product_type : null,
|
||||
price: data ? data.price : -1,
|
||||
price_unit: primaryPriceUnitId,
|
||||
code: data ? data.code : "",
|
||||
};
|
||||
}, [data]);
|
||||
@ -432,6 +452,24 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
</BillboardFormItem>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-3 col-span-2 gap-8 w-full pt-2">
|
||||
{/* is it gallery (non purchasable) */}
|
||||
<BillboardFormItem label={{ forId: "is-product-gallery", title: translate("billboard-product-sell-type-title"), text: translate("billboard-product-sell-type-text"), textClass: "pb-4" }}
|
||||
wrapperClass=""
|
||||
labelIcon={<CircleQuestionSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Select
|
||||
id="is-product-gallery"
|
||||
value={{ label: translate(`billboard-product-sell-type-${productGeneralType}`), value: productGeneralType, disabled: false }}
|
||||
options={getAvailableProductTypes()}
|
||||
onChange={(data: any) => setProductGeneralType(data.value)}
|
||||
optionsWrapper="inline-block w-full px-2 rounded-lg bg-white capitalize text-sm py-2 lg:cursor-pointer"
|
||||
optionClass="!text-xs"
|
||||
buttonWrapper="!ring-0 bg-white py-3"
|
||||
buttonText="!text-sm"
|
||||
wrapperClass="block bg-market-input rounded-xl py-1 px-1"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* product type */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "product-type", title: translate("dashboard-product-form-product-type"), text: translate("dashboard-product-form-product-type-text"), textClass: "pb-4" }}
|
||||
@ -450,37 +488,13 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
</div>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* stock unit type */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "stock_unit_type", title: translate("dashboard-product-form-stock-unit-type"), text: translate("dashboard-product-form-stock-unit-type-text"), textClass: "pb-4" }}
|
||||
labelIcon={<WarehouseFullSolid className={infoTextIconClass} />}
|
||||
childrenClass="my-0"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.stock_unit_type}
|
||||
>
|
||||
<DataList
|
||||
id={"stock_unit_type"}
|
||||
label={""}
|
||||
options={fieldsData.units.map(x => getLocaleTr(x, locale).name)}
|
||||
onChange={(value) => handleFeatureSelection("stock_unit_type", value)}
|
||||
isOpen={selectionOpen}
|
||||
onClose={() => setSelectionOpen(false)}
|
||||
labelHidden
|
||||
allValue={translate("select")}
|
||||
value={features.stock_unit_type.names}
|
||||
style={themeStyles}
|
||||
{...dataListStyles}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* product code */}
|
||||
<BillboardFormItem label={{ forId: "product-code", title: translate("dashboard-product-form-product-code"), text: translate("dashboard-product-form-code-text"), textClass: "pb-4" }}
|
||||
wrapperClass=""
|
||||
labelIcon={<BarcodeSolid className={infoTextIconClass} />}
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.code}
|
||||
errorText={fieldErrors.code ? formErrors.necessaryFields : formErrors.uniqueCode}
|
||||
errorVisible={fieldErrors.code || fieldErrors.unique_code}
|
||||
>
|
||||
<Input
|
||||
id="product-code"
|
||||
@ -493,13 +507,37 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
</BillboardFormItem>
|
||||
</div>
|
||||
|
||||
{/* stock unit type */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "stock_unit_type", title: translate("dashboard-product-form-stock-unit-type"), text: translate("dashboard-product-form-stock-unit-type-text"), textClass: "pb-4" }}
|
||||
labelIcon={<WarehouseFullSolid className={infoTextIconClass} />}
|
||||
childrenClass="my-0"
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.stock_unit_type}
|
||||
hidden={productGeneralType === "gallery"}
|
||||
>
|
||||
<DataList
|
||||
id={"stock_unit_type"}
|
||||
label={""}
|
||||
options={fieldsData.units.toSorted((a, b) => a.id - b.id).map(x => getLocaleTr(x, locale).name)}
|
||||
onChange={(value) => handleFeatureSelection("stock_unit_type", value)}
|
||||
isOpen={selectionOpen}
|
||||
onClose={() => setSelectionOpen(false)}
|
||||
labelHidden
|
||||
allValue={translate("select")}
|
||||
value={features.stock_unit_type.names}
|
||||
style={themeStyles}
|
||||
{...dataListStyles}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* price */}
|
||||
<BillboardFormItem label={{ forId: "price", title: translate("dashboard-product-form-price"), text: translate("dashboard-product-form-price-text"), textClass: "pb-4" }}
|
||||
wrapperClass="max-md:col-span-2"
|
||||
labelIcon={<SackDollarSolid className={infoTextIconClass} />}
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.price}
|
||||
hidden={productGeneralType === "gallery"}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<Input
|
||||
@ -515,31 +553,6 @@ const BasicDataForm: React.FunctionComponent<BasicDataFormProps> = ({ themeStyle
|
||||
<span className="inline-block py-3 px-3 bg-billboard-input ring-1 ring-billboard-input-ring text-sm/6 rounded-xl shrink-0">{currency}</span>
|
||||
</div>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* price unit */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "price_unit", title: translate("dashboard-product-form-price-unit"), text: translate("dashboard-product-form-price-unit-text"), textClass: "pb-4" }}
|
||||
labelIcon={<BoxTapedSolid className={infoTextIconClass} />}
|
||||
wrapperClass="max-md:col-span-2"
|
||||
childrenClass="my-0"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.price_unit}
|
||||
>
|
||||
<DataList
|
||||
id={"price_unit"}
|
||||
label={""}
|
||||
options={fieldsData.price_units.map(x => getLocaleTr(x, locale).name)}
|
||||
onChange={(value) => handleFeatureSelection("price_unit", value)}
|
||||
isOpen={selectionOpen}
|
||||
onClose={() => setSelectionOpen(false)}
|
||||
labelHidden
|
||||
allValue={translate("select")}
|
||||
value={features.price_unit.names}
|
||||
style={themeStyles}
|
||||
{...dataListStyles}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
</div>
|
||||
<StepButtons onNext={validate} />
|
||||
</>
|
||||
|
||||
@ -34,7 +34,7 @@ interface BatchVariationFormProps {
|
||||
};
|
||||
productId?: string;
|
||||
mainAttr: string;
|
||||
attrOrder: string;
|
||||
attrOrder: string[];
|
||||
fieldsData: {
|
||||
size_types: string[];
|
||||
sizes: Size[];
|
||||
|
||||
@ -38,7 +38,7 @@ const ProductGalleryThumbs: React.FunctionComponent<ProductGalleryThumbsProps> =
|
||||
|
||||
return <>
|
||||
<ImageUploader
|
||||
maxPhotos={8}
|
||||
maxPhotos={10}
|
||||
photos={data.gallery}
|
||||
onGalleryUpdate={setGalleryUpdates}
|
||||
uploaderId="cover-photo"
|
||||
|
||||
@ -39,7 +39,7 @@ interface ProductReviewProps {
|
||||
data: UnfinishedProductProps;
|
||||
uId: string;
|
||||
latestProduct?: BillboardProduct;
|
||||
attrOrder: string;
|
||||
attrOrder: string[];
|
||||
}
|
||||
interface FeatureItemProps {
|
||||
label: string;
|
||||
@ -275,7 +275,7 @@ const ProductReview: React.FunctionComponent<ProductReviewProps> = ({ extraData,
|
||||
{
|
||||
status: "published",
|
||||
product: productId.current,
|
||||
price_unit: data.price_unit.ids[0],
|
||||
price_unit: data.price_unit,
|
||||
is_primary_unit: true,
|
||||
price: Number(data.price) !== 0 ? Number(data.price) : null,
|
||||
},
|
||||
@ -323,8 +323,8 @@ const ProductReview: React.FunctionComponent<ProductReviewProps> = ({ extraData,
|
||||
ownership: [{ directus_users_id: uId }],
|
||||
billboard: [{ Billboards_id: query.id }],
|
||||
product_id: latestProduct ? latestProduct.product_id + 1 : 1,
|
||||
type: "purchasable",
|
||||
product_card_type: "shop",
|
||||
type: data.type,
|
||||
product_card_type: data.product_card_type,
|
||||
translations: translations,
|
||||
product_type: hasValue(data.product_type) ? data.product_type?.id : null,
|
||||
code: data.code ?? null,
|
||||
@ -420,7 +420,7 @@ const ProductReview: React.FunctionComponent<ProductReviewProps> = ({ extraData,
|
||||
<ul className="block list-none pt-4 pb-6 mt-2 space-y-3 border-b border-dashed border-neutral-200/75 mb-4">
|
||||
<FeatureItem label={translate("dashboard-product-form-product-code")} value={data.code} />
|
||||
<FeatureItem label={translate("dashboard-product-form-price")} value={`${String(data.price)} ${extraData.currency}`} />
|
||||
<FeatureItem label={translate("dashboard-product-form-price-unit")} value={String(data.price_unit.names[0])} />
|
||||
<FeatureItem label={translate("dashboard-product-form-stock-unit-type")} value={String(data.stock_unit_type.names[0])} />
|
||||
</ul>
|
||||
<span className="block text-center text-secondary-light text-sm mb-6 font-semibold capitalize">{translate("dashboard-product-form-step-two-title")}</span>
|
||||
<div className="grid grid-cols-12 py-2 px-6 mb-2 border-4 bg-white border-gray-50/75 rounded-xl w-full">
|
||||
|
||||
@ -122,8 +122,9 @@ const ProductGalleryManagement: React.FunctionComponent<ProductGalleryManagement
|
||||
{
|
||||
id: String(selectedThumb!.id),
|
||||
data: {
|
||||
gallery: galleryData.current.order.length > 0 ? galleryData.current.order.map(x => ({
|
||||
directus_files_id: x.src.includes("/") ? x.src.slice(x.src.lastIndexOf("/") + 1) : x.src
|
||||
gallery: galleryData.current.order.length > 0 ? galleryData.current.order.map((x, i) => ({
|
||||
directus_files_id: x.src.includes("/") ? x.src.slice(x.src.lastIndexOf("/") + 1) : x.src,
|
||||
sort_id: i
|
||||
})) : [],
|
||||
},
|
||||
},
|
||||
@ -348,7 +349,7 @@ const ProductGalleryManagement: React.FunctionComponent<ProductGalleryManagement
|
||||
>
|
||||
<div className="block w-full sm:w-[500px] p-gi">
|
||||
<ImageUploader
|
||||
maxPhotos={8}
|
||||
maxPhotos={10}
|
||||
photos={galleryPics}
|
||||
onGalleryUpdate={setGalleryUpdates}
|
||||
uploaderId={`product-gallery-${selectedThumb ? selectedThumb.id : 0}`}
|
||||
|
||||
@ -3,21 +3,23 @@ import useTranslate from 'services/translation/translation';
|
||||
import Link from 'components/link/link';
|
||||
import ProductSection from './product-section';
|
||||
import ProductGallery from './product-gallery';
|
||||
import { BillboardProduct, BillboardProductsCategory } from 'common/types/billboard';
|
||||
import { Billboard, BillboardProduct, BillboardProductsCategory, ProductVariantGalleries } from 'common/types/billboard';
|
||||
import ProductBreadcrumb from './product-breadcrumb';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import { BoxArchiveSolid, CalendarCheckSolid, FlaskRoundPotionSolid, PenSolid, SquareArrowUpRightSolid, TrashCanCheckSolid, TrashCanXmarkSolid, WeightScale } from 'components/icons';
|
||||
import { BoxArchiveSolid, CalendarCheckSolid, FlaskRoundPotionSolid, PenSolid, SquareArrowUpRightSolid, TrashCanXmarkSolid, WeightScale } from 'components/icons';
|
||||
import Button from 'components/button/button';
|
||||
import { useRef, useState } from 'react';
|
||||
import { deleteProductItems, deleteProductPriceEntriesItems, deleteProductVariationItems, deleteVariantGalleryItems, mutationItemsRequest, updateBillboardItem, updateBillboardProductItem } from 'services/queries/directus/billboard';
|
||||
import { deleteProductItems, deleteProductPriceEntriesItems, deleteProductVariationItems, deleteVariantGalleryItems, mutationItemsRequest, updateBillboardProductItem } from 'services/queries/directus/billboard';
|
||||
import { StockDataProps } from 'pages/api/billboard/product-stock-data';
|
||||
import Modal from 'components/modal/modal';
|
||||
import { deleteFiles } from 'services/queries/directus/general';
|
||||
|
||||
interface ProductDetailsProps {
|
||||
parentCats: BillboardProductsCategory[];
|
||||
billboard: Billboard;
|
||||
product: BillboardProduct;
|
||||
stockData: StockDataProps;
|
||||
thumbs: ProductVariantGalleries[];
|
||||
stockData: StockDataProps | null;
|
||||
themeStyles: React.CSSProperties;
|
||||
}
|
||||
interface ProductFeatureProps {
|
||||
@ -55,7 +57,7 @@ const LinkToProduct: React.FunctionComponent<LinkToProductProps> = ({ billboardT
|
||||
</Link>
|
||||
}
|
||||
|
||||
const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ product, parentCats, stockData, themeStyles }) => {
|
||||
const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ billboard, product, thumbs, parentCats, stockData, themeStyles }) => {
|
||||
|
||||
// states
|
||||
const [updating, setUpdating] = useState(false);
|
||||
@ -71,7 +73,7 @@ const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ product,
|
||||
const billboardId = product.billboard[0].Billboards_id.id;
|
||||
const billboardTitle = getLocaleTr(product.billboard[0].Billboards_id, locale).title;
|
||||
const featureIconClass = "inline-block size-3 xl:size-[14px] fill-[var(--brand-color)] rtl:ml-1 ltr:mr-1 ltr:rotate-180";
|
||||
const hasPics = !product.variations || !product.variations.some(x => x.main_variant && x.pics.length > 0) ? false : true;
|
||||
const hasPics = billboard.product_types[0] === "reservation" ? true : (!product.variations || !product.variations.some(x => x.main_variant && x.pics.length > 0) ? false : true);
|
||||
const galleryData = {
|
||||
galleriesIds: new Set(),
|
||||
galleriesPicsIds: new Set(),
|
||||
@ -130,7 +132,6 @@ const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ product,
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
|
||||
// return
|
||||
return (
|
||||
@ -138,7 +139,10 @@ const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ product,
|
||||
<div className="flex flex-col md:flex-row items-start w-full md:space-x-8 rtl:space-x-reverse">
|
||||
<div className="block w-72 sm:w-64 xl:w-80 shrink-0 mx-auto">
|
||||
{hasPics ?
|
||||
<ProductGallery productTitle={"brooo"} galleryItems={product.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery} />
|
||||
<ProductGallery
|
||||
productTitle={productTitle}
|
||||
galleryItems={billboard.product_types[0] === "reservation" ? thumbs[0].gallery : product.variations.filter(x => x.main_variant)[0].pics[0].variant_galleries_id.gallery}
|
||||
/>
|
||||
:
|
||||
<img src="/pics/billboard/product-placeholder.svg" alt="product image placeholder" className="block w-full" />
|
||||
}
|
||||
@ -157,8 +161,6 @@ const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ product,
|
||||
translations: x.translations,
|
||||
parent: x.parent
|
||||
}))}
|
||||
billboardId={billboardId}
|
||||
billboardTitle={billboardTitle}
|
||||
/>
|
||||
}
|
||||
<LinkToProduct billboardTitle={billboardTitle} query={query} translate={translate} className="max-2xl:hidden" />
|
||||
@ -177,7 +179,7 @@ const ProductDetails: React.FunctionComponent<ProductDetailsProps> = ({ product,
|
||||
<ProductFeature label={translate("billboartd-product-creation-date")} value={`${smartDate(new Date(product.date_created))}`} icon={<CalendarCheckSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />
|
||||
<ProductFeature label={translate("billboartd-product-update-date")} value={`${smartDate(new Date(product.date_updated))}`} icon={<PenSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />
|
||||
<ProductFeature label={translate("product-code")} value={product.code ?? "--"} icon={<FlaskRoundPotionSolid className={`ltr:rotate-180 ${featureIconClass}`} />} />
|
||||
<ProductFeature label={translate("billboard-product-stock-count")} value={hasValue(stockData.totalAvailableStock) && stockData.totalAvailableStock > 0 ? `${stockData.totalAvailableStock} ${getLocaleTr(product.stock_unit_type, locale).name}` : "--"} icon={<WeightScale className={`ltr:rotate-180 ${featureIconClass}`} />} />
|
||||
{stockData && <ProductFeature label={translate("billboard-product-stock-count")} value={hasValue(stockData.totalAvailableStock) && stockData.totalAvailableStock > 0 ? `${stockData.totalAvailableStock} ${getLocaleTr(product.stock_unit_type, locale).name}` : "--"} icon={<WeightScale className={`ltr:rotate-180 ${featureIconClass}`} />} />}
|
||||
{/* <ProductFeature label={`${translate("dimensions")} (${translate("lenght")}, ${translate("width")}, ${translate("height")})`} value={productDimentions} icon={<BoxOpenSolid className={`ltr:rotate-180 ${featureIconClass}`} />} /> */}
|
||||
</div>
|
||||
<EventAlert
|
||||
|
||||
@ -1,7 +1,5 @@
|
||||
import React from "react"
|
||||
import Link from "components/link/link"
|
||||
import useTranslate from "services/translation/translation"
|
||||
import { getLocaleTr, url, useGetRouter } from "services/general/general";
|
||||
import { getLocaleTr, useGetRouter } from "services/general/general";
|
||||
import { ReplySolid, SplitSolid } from "components/icons";
|
||||
|
||||
interface catProps {
|
||||
@ -17,14 +15,10 @@ interface catProps {
|
||||
interface ProductBreadcrumbProps {
|
||||
productCategory: catProps;
|
||||
parentCats: catProps[];
|
||||
billboardId: string;
|
||||
billboardTitle: string;
|
||||
}
|
||||
|
||||
|
||||
const ProductBreadcrumb: React.FunctionComponent<ProductBreadcrumbProps> = ({ productCategory, parentCats, billboardId, billboardTitle }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const ProductBreadcrumb: React.FunctionComponent<ProductBreadcrumbProps> = ({ productCategory, parentCats }) => {
|
||||
|
||||
// states
|
||||
|
||||
@ -36,7 +30,6 @@ const ProductBreadcrumb: React.FunctionComponent<ProductBreadcrumbProps> = ({ pr
|
||||
router.back();
|
||||
}
|
||||
|
||||
|
||||
// useEffects
|
||||
|
||||
return (
|
||||
|
||||
@ -0,0 +1,311 @@
|
||||
import { hasValue, safeClone, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useRef, useState } from 'react';
|
||||
import { ReservationTicketType, TicketTypePrice } from 'common/types/billboard';
|
||||
import { BillboardFormItem, BillboardFormSection, textInputClass } from 'common/templates/dashboard/billboards/general/fields';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import Input from 'components/input/text';
|
||||
import { ArrowTurnDownLeftSolid, CalendarCheckSolid, CheckSolid, CircleQuestionSolid, ClockRotateLeftSolid, HourGlassSolid, PeopleSolid, TicketSolid } from 'components/icons';
|
||||
import Button from 'components/button/button';
|
||||
import { Currencies } from 'common/types/general';
|
||||
import TicketPrices from '../ticket-prices/ticket-prices';
|
||||
import ServiceDates from './service-dates/service-dates';
|
||||
import { ServiceDateProps } from './service-dates/service-dates-form';
|
||||
import { restBulkCreateRequest } from 'services/queries/directus/billboard';
|
||||
|
||||
interface CreationFormProps {
|
||||
serviceData: {
|
||||
billboard_id: string;
|
||||
service_id: string;
|
||||
has_service_provider: boolean;
|
||||
dates_type: "routine" | "dynamic";
|
||||
sessions_type: "predefined" | "dynamic";
|
||||
};
|
||||
currency: Currencies;
|
||||
ticketTypes: ReservationTicketType[]
|
||||
last_service_id: number;
|
||||
themeStyles: React.CSSProperties;
|
||||
}
|
||||
|
||||
const CreationForm: React.FunctionComponent<CreationFormProps> = ({ serviceData, currency, last_service_id, ticketTypes, themeStyles }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale, router, query } = useGetRouter();
|
||||
|
||||
// states
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [ticketPrices, setTicketPrices] = useState<TicketTypePrice[]>([]);
|
||||
const [serviceDates, setServiceDates] = useState<ServiceDateProps[]>([]);
|
||||
|
||||
// variables
|
||||
const infoTextIconClass = "inline-block size-[14px] -mt-[2px] sm:size-4 text-[var(--brand-color)] fill-current rtl:ml-2 ltr:mr-2 sm:rtl:ml-3 sm:ltr:mr-3"
|
||||
|
||||
const formData = useRef({
|
||||
title: { fa: "", en: "" },
|
||||
session_capacity: -1,
|
||||
session_duration: -1,
|
||||
gap: -1,
|
||||
});
|
||||
const [fieldErrors, setFieldErrors] = useState({
|
||||
title: false,
|
||||
session_capacity: false,
|
||||
session_duration: false,
|
||||
gap: false,
|
||||
price: false,
|
||||
service_dates: false,
|
||||
session_data: false,
|
||||
});
|
||||
const formErrors = {
|
||||
necessaryFields: translate("all-fields-necessary-error"),
|
||||
}
|
||||
|
||||
// methods
|
||||
const validate = () => {
|
||||
const localErrors = safeClone(fieldErrors);
|
||||
|
||||
// required fileds in all scenarios
|
||||
hasValue(formData.current.title.fa) || hasValue(formData.current.title.en) ? localErrors.title = false : localErrors.title = true; // title
|
||||
(hasValue(formData.current.session_capacity) && formData.current.session_capacity > 0) ? localErrors.session_capacity = false : localErrors.session_capacity = true; // capacity
|
||||
(ticketPrices.length > 0) ? localErrors.price = false : localErrors.price = true; // ticket prices
|
||||
|
||||
// if has service providers
|
||||
if (!serviceData.has_service_provider) {
|
||||
(serviceDates.length > 0) ? localErrors.service_dates = false : localErrors.service_dates = true; // service dates
|
||||
}
|
||||
// if service has predefined sessions
|
||||
if (serviceData.sessions_type === "predefined") {
|
||||
(hasValue(formData.current.session_duration) && formData.current.session_duration > 0) ? localErrors.session_duration = false : localErrors.session_duration = true; // duration
|
||||
(hasValue(formData.current.gap) && formData.current.gap > 0) ? localErrors.gap = false : localErrors.gap = true; // gap
|
||||
}
|
||||
// if service has dynamic sessions
|
||||
if (serviceData.sessions_type === "dynamic") {
|
||||
(serviceDates.length > 0 && serviceDates.some(x => x.sessions_data.length > 0)) ? localErrors.session_data = false : localErrors.session_data = true; // session data
|
||||
}
|
||||
|
||||
setFieldErrors(localErrors);
|
||||
const hasErrors = Object.entries(localErrors).find(x => x[1] === true) ? true : false;
|
||||
return hasErrors;
|
||||
}
|
||||
|
||||
const handleFormUpdate = (name: string, value: string | string[] | undefined | { fa: any, en: any }) => {
|
||||
formData.current = { ...formData.current, [name]: value };
|
||||
}
|
||||
|
||||
const handleCreateServiceType = async () => {
|
||||
setUpdating(true);
|
||||
|
||||
const translations = [];
|
||||
if (hasValue(formData.current.title.fa)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "fa-IR"
|
||||
},
|
||||
title: formData.current.title.fa,
|
||||
});
|
||||
}
|
||||
if (hasValue(formData.current.title.en)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "en-US"
|
||||
},
|
||||
title: formData.current.title.en,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await restBulkCreateRequest({
|
||||
collection: 'billboard_service_types',
|
||||
items: [
|
||||
{
|
||||
status: "published",
|
||||
service_id: last_service_id + 1,
|
||||
service: serviceData.service_id,
|
||||
translations: translations,
|
||||
session_duration: formData.current.session_duration,
|
||||
gap: formData.current.gap,
|
||||
session_capacity: formData.current.session_capacity,
|
||||
ticket_types: ticketPrices,
|
||||
service_dates: serviceDates.map(x => ({
|
||||
...x,
|
||||
from: x.from + ":00",
|
||||
to: x.to + ":00",
|
||||
sessions_data: x.sessions_data.map(s => ({
|
||||
...s,
|
||||
from: s.from + ":00",
|
||||
to: s.to + ":00"
|
||||
}))
|
||||
}))
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error("service type creation failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
const handleCreate = async () => {
|
||||
const hasErrors = validate();
|
||||
if (hasErrors) return;
|
||||
|
||||
handleCreateServiceType();
|
||||
}
|
||||
const onAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
alertOpen && router.reload();
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
// return
|
||||
return (
|
||||
<BillboardFormSection wrapperClass="grid grid-cols-2 w-full gap-6 lg:gap-8 bg-white">
|
||||
<EventAlert
|
||||
text={translate("dashboard-changes-successfully-applied")}
|
||||
open={alertOpen}
|
||||
type="success"
|
||||
timeOut={3000}
|
||||
onClose={onAlertClose}
|
||||
hasTime
|
||||
/>
|
||||
{/* title */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "title", title: translate("title"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.title}
|
||||
labelIcon={<CircleQuestionSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={(formData.current.title as any)[locale as any]}
|
||||
maxChar={100}
|
||||
maxLength={100}
|
||||
className={`${textInputClass} ${locale === "en" ? "[direction:ltr]" : ""}`}
|
||||
onInput={(data) => handleFormUpdate("title", ({ ...formData.current.title, [locale as any]: data } as any))}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* session capacity */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "session_capacity", title: translate("capacity"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.session_capacity}
|
||||
labelIcon={<PeopleSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Input
|
||||
id="session_capacity"
|
||||
name="session_capacity"
|
||||
type="number"
|
||||
value={formData.current.session_capacity === -1 ? undefined : formData.current.session_capacity}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("session_capacity", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
|
||||
{/* session_duration */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "session_duration", title: translate("dashboard-billboard-service-type-sessions-duration"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.session_duration}
|
||||
labelIcon={<HourGlassSolid className={infoTextIconClass} />}
|
||||
hidden={serviceData.sessions_type === "dynamic"}
|
||||
>
|
||||
<Input
|
||||
id="session_duration"
|
||||
name="session_duration"
|
||||
type="number"
|
||||
value={formData.current.session_duration === -1 ? undefined : formData.current.session_duration}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("session_duration", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* gap */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "gap", title: translate("dashboard-billboard-service-type-sessions-gap"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.gap}
|
||||
labelIcon={<ClockRotateLeftSolid className={infoTextIconClass} />}
|
||||
hidden={serviceData.sessions_type === "dynamic"}
|
||||
>
|
||||
<Input
|
||||
id="gap"
|
||||
name="gap"
|
||||
type="number"
|
||||
value={formData.current.gap === -1 ? undefined : formData.current.gap}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("gap", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* ticket type prices */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "gap", title: translate("dashboard-billboard-service-type-ticket-prices"), text: translate("dashboard-billboard-service-type-ticket-prices-text"), textClass: "pb-4" }}
|
||||
wrapperClass="max-sm:col-span-2 pb-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.price}
|
||||
labelIcon={<TicketSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<TicketPrices fullWidthTickets currency={currency} ticketTypes={ticketTypes} themeStyles={themeStyles} onChange={setTicketPrices} />
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* service dates */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "service-dates", title: translate("dashboard-billboard-service-type-service-dates-title"), text: translate("dashboard-billboard-service-type-service-dates-text"), textClass: "pb-4" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.service_dates}
|
||||
labelIcon={<CalendarCheckSolid className={infoTextIconClass} />}
|
||||
hidden={serviceData.has_service_provider}
|
||||
>
|
||||
<ServiceDates
|
||||
datesType={serviceData.dates_type}
|
||||
sessionsType={serviceData.sessions_type}
|
||||
currency={currency}
|
||||
ticketTypes={ticketTypes}
|
||||
themeStyles={themeStyles}
|
||||
onChange={setServiceDates}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="grid grid-cols-2 gap-4 w-full col-span-2 pb-2 lg:px-2 lg:pb-4 pt-6 mt-2 lg:max-w-3xl mx-auto">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-xs md:text-base capitalize shadow-none w-full px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
leftIcon={<CheckSolid className="inline-block size-4 text-white fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={handleCreate}
|
||||
loading={updating}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-xs md:text-base capitalize shadow-none w-full px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<ArrowTurnDownLeftSolid className="inline-block size-4 text-[var(--brand-color)] fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
</div>
|
||||
</BillboardFormSection>
|
||||
)
|
||||
}
|
||||
export default CreationForm
|
||||
@ -0,0 +1,661 @@
|
||||
import { fileUpload, getEngTr, getPerTr, hasValue, nt, safeClone, serverAddress, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useRef, useState } from 'react';
|
||||
import { BillboardServiceType, ReservationTicketType, TicketTypePrice } from 'common/types/billboard';
|
||||
import { BillboardFormItem, BillboardFormSection, FormGroup, textInputClass } from 'common/templates/dashboard/billboards/general/fields';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import { TranslatableFieldsInfo } from 'components/general/form-tools';
|
||||
import Input from 'components/input/text';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import { ArrowTurnDownLeftSolid, CalendarCheckSolid, CalendarSolid, CheckSolid, CircleExclamationSolid, CircleQuestionSolid, ClockRotateLeftSolid, ClockSolid, HourGlassSolid, ImageSharpSolid, ImageSolid, PeopleSolid, ScrollSolid, TicketSolid, TriangleExclamationSolid, UserVneckHairSolid } from 'components/icons';
|
||||
import Button from 'components/button/button';
|
||||
import CheckBox from 'components/checkbox/checkbox';
|
||||
import Select from 'components/select/select';
|
||||
import ServiceRules from './service-rules/service-rules';
|
||||
import TicketPrices from '../ticket-prices/ticket-prices';
|
||||
import ServiceDates from './service-dates/service-dates';
|
||||
import { ServiceDateProps } from './service-dates/service-dates-form';
|
||||
import { Currencies } from 'common/types/general';
|
||||
import { restBulkDeleteRequest, restBulkUpdateRequest } from 'services/queries/directus/billboard';
|
||||
import { ServiceRuleProps } from './service-rules/service-rules-form';
|
||||
import ImageUploader, { GalleryItem, GalleryUpdate } from 'components/general/image-uploader/image-uploader';
|
||||
|
||||
interface CreationFormProps {
|
||||
data: BillboardServiceType;
|
||||
serviceData: {
|
||||
billboard_id: string,
|
||||
service_id: string;
|
||||
has_service_provider: boolean;
|
||||
dates_type: "routine" | "dynamic";
|
||||
sessions_type: "predefined" | "dynamic";
|
||||
};
|
||||
currency: Currencies;
|
||||
ticketTypes: ReservationTicketType[]
|
||||
themeStyles: React.CSSProperties;
|
||||
}
|
||||
|
||||
const CreationForm: React.FunctionComponent<CreationFormProps> = ({ data, serviceData, currency, ticketTypes, themeStyles }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale, router, query } = useGetRouter();
|
||||
|
||||
// states
|
||||
const [fieldsTr, setFieldsTr] = useState(locale);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [depositNeeded, setDepositNeeded] = useState(data.deposit_needed);
|
||||
const [hasProvider, setHasProvider] = useState(data.has_service_provider ? translate("logic-yes") : translate("logic-no"));
|
||||
const [datesType, setDatesType] = useState(data.dates_type);
|
||||
const [sessionsType, setSessionsType] = useState(data.sessions_type);
|
||||
const [ticketPrices, setTicketPrices] = useState<TicketTypePrice[]>(data.ticket_types?.length > 0 ? data.ticket_types.map(x => ({ ...x, reservation_ticket_type_id: x.reservation_ticket_type_id.id })) : []);
|
||||
const [serviceDates, setServiceDates] = useState<ServiceDateProps[]>(data.service_dates ? data.service_dates : []);
|
||||
const [sectionOpen, setSectionOpen] = useState({ general: false, details: false });
|
||||
const [picsToRemove, setPicsToRemove] = useState<string[]>([]);
|
||||
|
||||
// variables
|
||||
const editorRef: any = useRef(null);
|
||||
const infoTextIconClass = "inline-block size-[14px] -mt-[2px] sm:size-4 text-[var(--brand-color)] fill-current rtl:ml-2 ltr:mr-2 sm:rtl:ml-3 sm:ltr:mr-3"
|
||||
|
||||
const formData = useRef({
|
||||
// general
|
||||
title: { fa: getPerTr(data).title, en: getEngTr(data).title },
|
||||
description: { fa: getPerTr(data).description, en: getEngTr(data).description },
|
||||
rules: {
|
||||
fa: getPerTr(data).session_rules ? getPerTr(data).session_rules.map((x: any, i: number) => ({ id: i, rule: x.rule })) : [],
|
||||
en: getEngTr(data).session_rules ? getEngTr(data).session_rules.map((x: any, i: number) => ({ id: i, rule: x.rule })) : []
|
||||
},
|
||||
free_cancellation: data.free_cancellation,
|
||||
refundable: data.refundable,
|
||||
deposit_needed: data.deposit_needed,
|
||||
deposit_amount: data.deposit_amount,
|
||||
// service details
|
||||
session_capacity: data.session_capacity,
|
||||
session_duration: data.session_duration,
|
||||
gap: data.gap,
|
||||
});
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState({
|
||||
title: false,
|
||||
deposit_needed: false,
|
||||
session_capacity: false,
|
||||
session_duration: false,
|
||||
gap: false,
|
||||
price: false,
|
||||
service_dates: false,
|
||||
session_data: false,
|
||||
});
|
||||
const formErrors = {
|
||||
necessaryFields: translate("all-fields-necessary-error"),
|
||||
}
|
||||
const inititlaGalleryData = data.profile_pic ? [{ name: data.profile_pic.filename_download, type: data.profile_pic.type, size: data.profile_pic.filesize, src: `${serverAddress() + data.profile_pic.id}` }] : [];
|
||||
const originalGalleryData = useRef<GalleryUpdate>({
|
||||
picsToRemove: [],
|
||||
picsToUpload: [],
|
||||
order: inititlaGalleryData
|
||||
});
|
||||
const galleryData = useRef<GalleryUpdate>({
|
||||
picsToRemove: [],
|
||||
picsToUpload: [],
|
||||
order: inititlaGalleryData
|
||||
});
|
||||
|
||||
// methods
|
||||
const handleLangSelect = (lang: string) => {
|
||||
setFieldsTr(lang);
|
||||
}
|
||||
const validate = () => {
|
||||
const isMultilingual = hasValue(formData.current.title.fa) && hasValue(formData.current.title.en);
|
||||
const localErrors = safeClone(fieldErrors);
|
||||
|
||||
hasValue(formData.current.title.fa) || hasValue(formData.current.title.en) ? localErrors.title = false : localErrors.title = true; // title
|
||||
// isMultilingual ?
|
||||
// (hasValue(formData.current.body.fa) && hasValue(formData.current.body.en) ? localErrors.body = false : localErrors.body = true)
|
||||
// :
|
||||
// (hasValue(formData.current.body.fa) || hasValue(formData.current.body.en) ? localErrors.body = false : localErrors.body = true);
|
||||
|
||||
setFieldErrors(localErrors);
|
||||
const hasErrors = Object.entries(localErrors).find(x => x[1] === true) ? true : false;
|
||||
return hasErrors;
|
||||
}
|
||||
|
||||
const handleFormUpdate = (name: string, value: string | string[] | undefined | { fa: any, en: any }) => {
|
||||
formData.current = { ...formData.current, [name]: value };
|
||||
}
|
||||
const handleOpenSections = (type: string, open: boolean) => {
|
||||
setSectionOpen(prev => (
|
||||
{
|
||||
general: false,
|
||||
details: false,
|
||||
[type]: open
|
||||
}
|
||||
))
|
||||
}
|
||||
const handleUpdateServiceType = async () => {
|
||||
setUpdating(true);
|
||||
|
||||
const translations = [];
|
||||
if (hasValue(formData.current.title.fa)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "fa-IR"
|
||||
},
|
||||
title: formData.current.title.fa,
|
||||
description: formData.current.description.fa,
|
||||
session_rules: formData.current.rules.fa.length > 0 ? formData.current.rules.fa.map((x: ServiceRuleProps) => ({
|
||||
rule: x.rule
|
||||
})) : null
|
||||
});
|
||||
}
|
||||
if (hasValue(formData.current.title.en)) {
|
||||
translations.push({
|
||||
languages_code: {
|
||||
code: "en-US"
|
||||
},
|
||||
title: formData.current.title.en,
|
||||
description: formData.current.description.en,
|
||||
session_rules: formData.current.rules.en.length > 0 ? formData.current.rules.en.map((x: ServiceRuleProps) => ({
|
||||
rule: x.rule
|
||||
})) : null
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await restBulkUpdateRequest({
|
||||
collection: 'billboard_service_types',
|
||||
updates: [
|
||||
{
|
||||
id: String(data.id),
|
||||
data: {
|
||||
translations: translations,
|
||||
profile_pic: galleryData.current.order.length > 0 ? galleryData.current.order[0].src : null,
|
||||
free_cancellation: formData.current.free_cancellation,
|
||||
refundable: formData.current.refundable,
|
||||
deposit_needed: formData.current.deposit_needed,
|
||||
deposit_amount: formData.current.deposit_amount,
|
||||
has_service_provider: hasProvider === translate("logic-yes") ? true : false,
|
||||
dates_type: datesType,
|
||||
sessions_type: sessionsType,
|
||||
session_capacity: formData.current.session_capacity,
|
||||
session_duration: formData.current.session_duration,
|
||||
gap: formData.current.gap,
|
||||
ticket_types: ticketPrices,
|
||||
service_dates: serviceDates.map(x => ({
|
||||
...x,
|
||||
from: x.from + ":00",
|
||||
to: x.to + ":00",
|
||||
sessions_data: x.sessions_data.map(s => ({
|
||||
...s,
|
||||
from: s.from + ":00",
|
||||
to: s.to + ":00"
|
||||
}))
|
||||
}))
|
||||
},
|
||||
},
|
||||
]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error("service type update failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// gallery methods
|
||||
const setGalleryUpdates = (data: GalleryUpdate) => {
|
||||
galleryData.current = data;
|
||||
setPicsToRemove(galleryData.current.picsToRemove);
|
||||
}
|
||||
const deleteOldPics = async () => {
|
||||
try {
|
||||
// Delete old logo if it exists
|
||||
await restBulkDeleteRequest({
|
||||
collection: 'directus_files',
|
||||
ids: picsToRemove
|
||||
});
|
||||
|
||||
galleryData.current.picsToRemove = []; // empty pics to remove, as they're already successfully removed above.
|
||||
} catch (error) {
|
||||
console.error("news pics removal failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
const uploadFiles = async (photos: GalleryItem[], folder: string) => {
|
||||
try {
|
||||
const uploadData = await fileUpload(photos, folder); // returns an array of uploaded file's data
|
||||
let picsToSend: GalleryItem[] = [];
|
||||
const uploadedPics = uploadData.map((x: any) => ({
|
||||
name: x.filename_download,
|
||||
type: x.type,
|
||||
size: x.filesize,
|
||||
src: x.id
|
||||
}));
|
||||
|
||||
picsToSend = galleryData.current.order.map(
|
||||
x => x.src.includes("data") ?
|
||||
uploadedPics.filter((y: any) => (String(y.size) === String(x.size) && y.name === x.name))[0]
|
||||
:
|
||||
{ ...x, src: x.src.slice(x.src.lastIndexOf("/") + 1) }
|
||||
);
|
||||
|
||||
galleryData.current.picsToUpload = [];
|
||||
galleryData.current.order = picsToSend;
|
||||
} catch (error) {
|
||||
console.error("Upload failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
// check for errors & then update
|
||||
const handleUpdate = async () => {
|
||||
const hasErrors = validate();
|
||||
// const isServiceTypeChanged =
|
||||
// JSON.stringify(galleryData.current.order) !== JSON.stringify(originalGalleryData.current.order) ||
|
||||
// JSON.stringify(formData.current) !== JSON.stringify(originalFormData.current)
|
||||
if (hasErrors) return;
|
||||
|
||||
setUpdating(true);
|
||||
|
||||
if (galleryData.current.picsToRemove.length > 0) {
|
||||
await deleteOldPics();
|
||||
}
|
||||
if (galleryData.current.picsToUpload.length > 0) {
|
||||
await uploadFiles(galleryData.current.picsToUpload, "BillboardServiceType");
|
||||
}
|
||||
|
||||
handleUpdateServiceType();
|
||||
}
|
||||
|
||||
const onAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
alertOpen && router.reload();
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
// return
|
||||
return (
|
||||
<BillboardFormSection wrapperClass="bg-white">
|
||||
<EventAlert
|
||||
text={translate("dashboard-changes-successfully-applied")}
|
||||
open={alertOpen}
|
||||
type="success"
|
||||
timeOut={3000}
|
||||
onClose={onAlertClose}
|
||||
hasTime
|
||||
/>
|
||||
{/* title & description */}
|
||||
<FormGroup
|
||||
title={translate("dashboard-product-form-main-data-title")}
|
||||
open={sectionOpen.general}
|
||||
onChange={(open) => handleOpenSections("general", open)}
|
||||
childrenClass="border-b border-dashed border-neutral-200/75 pb-8 mb-2"
|
||||
>
|
||||
<TranslatableFieldsInfo onLangSwitch={handleLangSelect} />
|
||||
|
||||
{/* title */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "title", title: translate("title") }}
|
||||
wrapperClass="col-span-2"
|
||||
translatable
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.title}
|
||||
labelIcon={<CircleQuestionSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Input
|
||||
id="title"
|
||||
name="title"
|
||||
type="text"
|
||||
value={(formData.current.title as any)[fieldsTr as any]}
|
||||
maxChar={100}
|
||||
maxLength={100}
|
||||
className={`${textInputClass} ${fieldsTr === "en" ? "[direction:ltr]" : ""}`}
|
||||
onInput={(data) => handleFormUpdate("title", ({ ...formData.current.title, [fieldsTr as any]: data } as any))}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* description */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "description", title: translate("description") }}
|
||||
wrapperClass="col-span-2 pb-2"
|
||||
childrenClass="[&_div.tox]:rtl:[direction:rtl]"
|
||||
translatable
|
||||
labelIcon={<CircleQuestionSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Editor
|
||||
tinymceScriptSrc={'/tinymce/tinymce.min.js'}
|
||||
onInit={(evt, editor) => editorRef.current = editor}
|
||||
initialValue={(formData.current.description as any)[`${fieldsTr}`]}
|
||||
init={{
|
||||
height: 280,
|
||||
menubar: false,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'image', 'charmap',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen', 'directionality',
|
||||
'insertdatetime', 'media', 'table', 'preview', 'help', 'wordcount',
|
||||
],
|
||||
toolbar: 'undo redo | blocks | ' +
|
||||
'bold italic ltr rtl removeformat | alignleft aligncenter ' +
|
||||
'alignright alignjustify | bullist numlist outdent indent fullscreen|',
|
||||
content_style: `
|
||||
@font-face {
|
||||
font-family: 'IRANSans';
|
||||
src: url('/fonts/IRANSansWeb.woff2') format('woff2'),
|
||||
url('/fonts/IRANSansWeb.woff') format('woff'),
|
||||
url('/fonts/IRANSansWeb.ttf') format('truetype'),
|
||||
url('/fonts/IRANSansWeb.eot'),
|
||||
url('/fonts/IRANSansWeb.eot?#iefix') format('embedded-opentype');
|
||||
unicode-range: 0020-007F, 00A0-00BB, 0600-067F;
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
body {
|
||||
font-family: 'IRANSans', Helvetica, Arial, sans-serif; font-size:14px; line-height: 28px; ${locale === "en" ? "direction:ltr" : "direction:rtl"};
|
||||
}
|
||||
@media only screen and (max-width: 639px) {
|
||||
body {
|
||||
font-size:12px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
`,
|
||||
}}
|
||||
onChange={() => handleFormUpdate("description", { ...formData.current.description, [fieldsTr as any]: editorRef.current?.getContent() })}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* service type rules */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "free_cancellation", title: translate("dashboard-billboard-service-type-rules-title"), text: translate("dashboard-billboard-service-type-rules-text"), textClass: "pb-4" }}
|
||||
wrapperClass="flex flex-col justify-between col-span-2 pb-2"
|
||||
labelIcon={<CircleExclamationSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<ServiceRules
|
||||
data={formData.current.rules}
|
||||
fieldsTr={fieldsTr}
|
||||
themeStyles={themeStyles}
|
||||
onChange={(data) => handleFormUpdate("rules", ({ ...formData.current.rules, [fieldsTr as any]: data } as any))}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
<BillboardFormItem
|
||||
label={{ forId: "service-type-gallery", title: translate("photos-gallery") }}
|
||||
wrapperClass="col-span-2"
|
||||
labelIcon={<ImageSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<ImageUploader
|
||||
maxPhotos={1}
|
||||
photos={galleryData.current.order}
|
||||
onGalleryUpdate={setGalleryUpdates}
|
||||
uploaderId="service-type-gallery"
|
||||
wrapperClass="w-full mt-4 mb-4 col-span-2"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
<BillboardFormItem
|
||||
label={{ forId: "free_cancellation", title: translate("dashboard-billboard-service-type-free-cancelation"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col justify-between"
|
||||
>
|
||||
<CheckBox
|
||||
key={"free_cancellation"}
|
||||
forId={"free_cancellation"}
|
||||
name={"free_cancellation"}
|
||||
title={translate(`logic-yes`)}
|
||||
value={"free_cancellation"}
|
||||
checked={formData.current.free_cancellation}
|
||||
onChange={(value) => formData.current.free_cancellation = value}
|
||||
inputClass="market-checkbox-input"
|
||||
labelClass="market-checkbox-label text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
titleClass="market-checkbox-title text-text-gray text-xs sm:text-sm"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
<BillboardFormItem
|
||||
label={{ forId: "refundable", title: translate("dashboard-billboard-service-type-refundable"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col justify-between"
|
||||
>
|
||||
<CheckBox
|
||||
key={"refundable"}
|
||||
forId={"refundable"}
|
||||
name={"refundable"}
|
||||
title={translate(`logic-yes`)}
|
||||
value={"refundable"}
|
||||
checked={formData.current.refundable}
|
||||
onChange={(value) => formData.current.refundable = value}
|
||||
inputClass="market-checkbox-input"
|
||||
labelClass="market-checkbox-label text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
titleClass="market-checkbox-title text-text-gray text-xs sm:text-sm"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
<BillboardFormItem
|
||||
label={{ forId: "deposit_needed", title: translate("dashboard-billboard-service-type-deposit-needed"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col"
|
||||
>
|
||||
<CheckBox
|
||||
key={"deposit_needed"}
|
||||
forId={"deposit_needed"}
|
||||
name={"deposit_needed"}
|
||||
title={translate(`logic-yes`)}
|
||||
value={"deposit_needed"}
|
||||
checked={depositNeeded}
|
||||
onChange={setDepositNeeded}
|
||||
inputClass="market-checkbox-input"
|
||||
labelClass="market-checkbox-label text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
titleClass="market-checkbox-title text-text-gray text-xs sm:text-sm"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
<BillboardFormItem
|
||||
label={{ forId: "deposit_amount", title: translate("dashboard-billboard-service-type-deposit-amount"), className: "pb-2" }}
|
||||
wrapperClass="col-span-1"
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.deposit_needed}
|
||||
disabled={!depositNeeded}
|
||||
>
|
||||
<Input
|
||||
id="deposit_amount"
|
||||
name="deposit_amount"
|
||||
type="number"
|
||||
value={formData.current.deposit_amount}
|
||||
min={0}
|
||||
className={`${textInputClass} max-lg:py-2`}
|
||||
onInput={(data) => handleFormUpdate("deposit_amount", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
</FormGroup>
|
||||
|
||||
{/* service details */}
|
||||
<FormGroup
|
||||
title={translate("dashboard-billboard-service-type-details")}
|
||||
open={sectionOpen.details}
|
||||
onChange={(open) => handleOpenSections("details", open)}
|
||||
headerClass={sectionOpen.details ? "" : "border-none"}
|
||||
>
|
||||
|
||||
{/* has service provider */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "has-service-provider", title: translate("dashboard-billboard-service-type-has-provider-title"), text: translate("dashboard-billboard-service-type-has-provider-text"), textClass: "pb-4" }}
|
||||
wrapperClass="flex flex-col col-span-2"
|
||||
labelIcon={<UserVneckHairSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Select
|
||||
id="has-service-provider"
|
||||
value={{ label: hasProvider, value: hasProvider, disabled: false }}
|
||||
options={[translate("logic-yes"), translate("logic-no")].map(x => ({ label: x, value: x, disabled: false }))}
|
||||
onChange={(data) => setHasProvider(data.value)}
|
||||
optionsWrapper="inline-block w-full px-2 rounded-lg bg-white capitalize text-xs lg:text-sm max-lg:!py-1"
|
||||
wrapperClass="block !min-w-0 w-40 lg:w-48 bg-[var(--light-brand-color)] rounded-xl p-1"
|
||||
buttonWrapper="!ring-[var(--light-brand-color)] bg-white"
|
||||
buttonText='!text-sm'
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* dates type */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "dates-type", title: translate("dashboard-billboard-service-type-dates-type-title"), text: translate("dashboard-billboard-service-type-dates-type-text"), textClass: "pb-4" }}
|
||||
wrapperClass="flex flex-col justify-between max-sm:col-span-2"
|
||||
labelIcon={<CalendarSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Select
|
||||
id="dates-type"
|
||||
value={{ label: translate(datesType), value: datesType, disabled: false }}
|
||||
options={["routine", "dynamic"].map(x => ({ label: translate(x), value: x, disabled: false }))}
|
||||
onChange={(data) => setDatesType(data.value as any)}
|
||||
optionsWrapper="inline-block w-full px-2 rounded-lg bg-white capitalize text-xs lg:text-sm max-lg:!py-1"
|
||||
wrapperClass="block !min-w-0 w-40 lg:w-48 bg-[var(--light-brand-color)] rounded-xl p-1"
|
||||
buttonWrapper="!ring-[var(--light-brand-color)] bg-white"
|
||||
buttonText='!text-sm'
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* sessions type */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "sessions-type", title: translate("dashboard-billboard-service-type-sessions-type-title"), text: translate("dashboard-billboard-service-type-sessions-type-text"), textClass: "pb-4" }}
|
||||
wrapperClass="flex flex-col max-sm:col-span-2"
|
||||
labelIcon={<ClockSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Select
|
||||
id="sessions-type"
|
||||
value={{ label: translate(sessionsType), value: sessionsType, disabled: false }}
|
||||
options={["predefined", "dynamic"].map(x => ({ label: translate(x), value: x, disabled: false }))}
|
||||
onChange={(data) => setSessionsType(data.value as any)}
|
||||
optionsWrapper="inline-block w-full px-2 rounded-lg bg-white capitalize text-xs lg:text-sm max-lg:!py-1"
|
||||
wrapperClass="block !min-w-0 w-40 lg:w-48 bg-[var(--light-brand-color)] rounded-xl p-1"
|
||||
buttonWrapper="!ring-[var(--light-brand-color)] bg-white"
|
||||
buttonText='!text-sm'
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* session capacity */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "session_capacity", title: translate("capacity"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.session_capacity}
|
||||
labelIcon={<PeopleSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<Input
|
||||
id="session_capacity"
|
||||
name="session_capacity"
|
||||
type="number"
|
||||
value={formData.current.session_capacity === -1 ? undefined : formData.current.session_capacity}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("session_capacity", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
|
||||
{/* session_duration */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "session_duration", title: translate("dashboard-billboard-service-type-sessions-duration"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.session_duration}
|
||||
labelIcon={<HourGlassSolid className={infoTextIconClass} />}
|
||||
hidden={sessionsType === "dynamic"}
|
||||
>
|
||||
<Input
|
||||
id="session_duration"
|
||||
name="session_duration"
|
||||
type="number"
|
||||
value={formData.current.session_duration === -1 ? undefined : formData.current.session_duration}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("session_duration", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* gap */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "gap", title: translate("dashboard-billboard-service-type-sessions-gap"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.gap}
|
||||
labelIcon={<ClockRotateLeftSolid className={infoTextIconClass} />}
|
||||
hidden={sessionsType === "dynamic"}
|
||||
>
|
||||
<Input
|
||||
id="gap"
|
||||
name="gap"
|
||||
type="number"
|
||||
value={formData.current.gap === -1 ? undefined : formData.current.gap}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("gap", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* ticket type prices */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "gap", title: translate("dashboard-billboard-service-type-ticket-prices"), text: translate("dashboard-billboard-service-type-ticket-prices-text"), textClass: "pb-4" }}
|
||||
wrapperClass="max-sm:col-span-2 pb-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.price}
|
||||
labelIcon={<TicketSolid className={infoTextIconClass} />}
|
||||
>
|
||||
<TicketPrices prices={ticketPrices} fullWidthTickets currency={currency} ticketTypes={ticketTypes} themeStyles={themeStyles} onChange={setTicketPrices} />
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* service dates */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "service-dates", title: translate("dashboard-billboard-service-type-service-dates-title"), text: translate("dashboard-billboard-service-type-service-dates-text"), textClass: "pb-4" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
required
|
||||
errorText={formErrors.necessaryFields}
|
||||
errorVisible={fieldErrors.service_dates}
|
||||
labelIcon={<CalendarCheckSolid className={infoTextIconClass} />}
|
||||
hidden={hasProvider === translate("logic-yes")}
|
||||
>
|
||||
<ServiceDates
|
||||
dates={serviceDates.map(x => ({
|
||||
...x,
|
||||
from: x.from ? nt(x.from) : null,
|
||||
to: x.to ? nt(x.to) : null,
|
||||
sessions_data: x.sessions_data ?
|
||||
x.sessions_data.map(x => ({
|
||||
...x,
|
||||
from: x.from ? nt(x.from) : null,
|
||||
to: x.to ? nt(x.to) : null
|
||||
}))
|
||||
:
|
||||
[]
|
||||
}))}
|
||||
datesType={datesType}
|
||||
sessionsType={sessionsType}
|
||||
currency={currency}
|
||||
ticketTypes={ticketTypes}
|
||||
themeStyles={themeStyles}
|
||||
onChange={setServiceDates}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
</FormGroup>
|
||||
|
||||
{/* CTAs */}
|
||||
<div className="grid grid-cols-2 gap-4 pb-2 lg:px-2 lg:pb-4 pt-6 mt-2 lg:max-w-3xl mx-auto">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm md:text-base capitalize shadow-none w-full px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
leftIcon={<CheckSolid className="inline-block size-4 text-white fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={handleUpdate}
|
||||
loading={updating}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm md:text-base capitalize shadow-none w-full px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<ArrowTurnDownLeftSolid className="inline-block size-4 text-[var(--brand-color)] fill-current rtl:ml-3 ltr:mr-3" />}
|
||||
onClick={() => router.back()}
|
||||
/>
|
||||
</div>
|
||||
</BillboardFormSection>
|
||||
)
|
||||
}
|
||||
export default CreationForm
|
||||
@ -0,0 +1,275 @@
|
||||
import { hasValue, htm, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ReservationTicketType, TicketTypePrice } from 'common/types/billboard';
|
||||
import { BillboardFormItem, textInputClass } from 'common/templates/dashboard/billboards/general/fields';
|
||||
import Button from 'components/button/button';
|
||||
import Modal from 'components/modal/modal';
|
||||
import TimePicker from 'components/input/time-picker';
|
||||
import CheckBox from 'components/checkbox/checkbox';
|
||||
import TicketPrices from '../../ticket-prices/ticket-prices';
|
||||
import { Currencies } from 'common/types/general';
|
||||
import { SessionProps } from './service-dates-form';
|
||||
import Input from 'components/input/text';
|
||||
import TextArea from 'components/input/text-area';
|
||||
import { formInputClass } from 'components/general/form-tools';
|
||||
import { TriangleExclamationSolid } from 'components/icons';
|
||||
|
||||
interface Time {
|
||||
startHour: string;
|
||||
startMinute: string;
|
||||
endHour: string;
|
||||
endMinute: string;
|
||||
}
|
||||
interface DynamicSessionsFormProps {
|
||||
data: SessionProps;
|
||||
currentSessions: SessionProps[]
|
||||
currency: Currencies;
|
||||
isEditForm?: boolean;
|
||||
ticketTypes: ReservationTicketType[];
|
||||
open: boolean;
|
||||
themeStyles: React.CSSProperties;
|
||||
onClose: (open: boolean) => void;
|
||||
onSubmit: (data: SessionProps) => void;
|
||||
}
|
||||
|
||||
const DynamicSessionsForm: React.FunctionComponent<DynamicSessionsFormProps> = ({ data, currentSessions, currency, isEditForm = false, ticketTypes, open = false, themeStyles, onClose, onSubmit }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
|
||||
const startHour = data.from.slice(0, data.from.indexOf(":"));
|
||||
const startMinute = data.from.slice(data.from.indexOf(":") + 1);
|
||||
const endHour = data.to.slice(0, data.to.indexOf(":"));
|
||||
const endMinute = data.to.slice(data.to.indexOf(":") + 1);
|
||||
|
||||
// states
|
||||
const [diffrentSessionPrice, setDiffrentSessionPrice] = useState(false);
|
||||
const [hours, setHours] = useState<Time>({
|
||||
startHour: startHour,
|
||||
startMinute: startMinute,
|
||||
endHour: endHour,
|
||||
endMinute: endMinute
|
||||
});
|
||||
const [ticketPrices, setTicketPrices] = useState<TicketTypePrice[]>([]);
|
||||
const [showOverlapError, setShowOverlapError] = useState(false);
|
||||
const [initialTime, setInitialTime] = useState({
|
||||
startHour: Number(startHour),
|
||||
startMinute: Number(startMinute),
|
||||
endHour: Number(endHour),
|
||||
endMinute: Number(endMinute)
|
||||
});
|
||||
|
||||
// variables
|
||||
const formData = useRef({
|
||||
session_capacity: data.capacity,
|
||||
seats_map: data.seats_map,
|
||||
persian_details: data.persian_details,
|
||||
english_details: data.english_details,
|
||||
});
|
||||
|
||||
// methods
|
||||
const resetForm = () => {
|
||||
|
||||
}
|
||||
const handleFormUpdate = (name: string, value: string | string[] | undefined | { fa: any, en: any }) => {
|
||||
formData.current = { ...formData.current, [name]: value };
|
||||
}
|
||||
const handleHoursTimeSelection = (serviceTime: Time) => {
|
||||
setHours(serviceTime);
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
onClose(false);
|
||||
}
|
||||
const checkScheduleOverlaps = (item: SessionProps) => {
|
||||
let hasOverLap = currentSessions.some(x =>
|
||||
(
|
||||
(htm(item.from) >= htm(x.from) && htm(item.from) <= htm(x.to)) ||
|
||||
(htm(item.from) >= htm(x.from) && htm(item.to) <= htm(x.to)) ||
|
||||
(htm(item.to) >= htm(x.from) && htm(item.to) <= htm(x.to))
|
||||
)
|
||||
);
|
||||
return hasOverLap;
|
||||
}
|
||||
const handleSubmit = () => {
|
||||
const sessionData = {
|
||||
from: `${hours.startHour}:${hours.startMinute}`,
|
||||
to: `${hours.endHour}:${hours.endMinute}`,
|
||||
capacity: formData.current.session_capacity,
|
||||
gap: -1,
|
||||
ticket_types: ticketPrices,
|
||||
seats_map: formData.current.seats_map,
|
||||
persian_details: formData.current.persian_details,
|
||||
english_details: formData.current.english_details,
|
||||
}
|
||||
if (!checkScheduleOverlaps(sessionData)) {
|
||||
onSubmit(sessionData);
|
||||
handleCloseModal();
|
||||
setShowOverlapError(false);
|
||||
} else {
|
||||
setShowOverlapError(true);
|
||||
}
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
if (data.from !== `${initialTime.startHour}:${initialTime.startMinute}`) {
|
||||
setInitialTime({
|
||||
startHour: hasValue(data.from) ? Number(data.from.slice(0, data.from.indexOf(":"))) : -1,
|
||||
startMinute: hasValue(data.from) ? Number(data.from.slice(data.from.indexOf(":") + 1)) : -1,
|
||||
endHour: hasValue(data.from) ? Number(data.to.slice(0, data.to.indexOf(":"))) : -1,
|
||||
endMinute: hasValue(data.from) ? Number(data.to.slice(data.to.indexOf(":") + 1)) : -1
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// return
|
||||
return (
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`service-daily-session-modal`}
|
||||
open={open}
|
||||
onClose={handleCloseModal}
|
||||
title={translate("dashboard-billboard-service-type-service-dates-add-sessions")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 w-full sm:w-[500px] p-gi">
|
||||
|
||||
{/* service hours */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "service-hours", title: translate("dashboard-billboard-service-type-service-dates-day-hours"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col justify-between col-span-2 mb-5"
|
||||
disabled={closed}
|
||||
>
|
||||
<TimePicker
|
||||
id="select-service-hours"
|
||||
onChange={handleHoursTimeSelection}
|
||||
wrapperClass={``}
|
||||
initialTime={initialTime}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* diffrent session price */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "diffrent-session-price", title: translate("dashboard-billboard-service-type-service-dates-session-price-text"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col col-span-2"
|
||||
hidden={!isEditForm}
|
||||
>
|
||||
<CheckBox
|
||||
key={"diffrent-session-price"}
|
||||
forId={"diffrent-session-price"}
|
||||
name={"diffrent-session-price"}
|
||||
title={translate(`logic-yes`)}
|
||||
value={"diffrent-session-price"}
|
||||
checked={diffrentSessionPrice}
|
||||
onChange={setDiffrentSessionPrice}
|
||||
inputClass="market-checkbox-input"
|
||||
labelClass="market-checkbox-label text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
titleClass="market-checkbox-title text-text-gray text-xs sm:text-sm"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* ticket type prices */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "ticket-type-prices", title: translate("dashboard-billboard-service-type-ticket-prices"), text: translate("dashboard-billboard-service-type-ticket-prices-text"), textClass: "pb-4" }}
|
||||
wrapperClass="col-span-2"
|
||||
hidden={!isEditForm || !diffrentSessionPrice}
|
||||
>
|
||||
<TicketPrices currency={currency} fullWidthTickets ticketTypes={ticketTypes} themeStyles={themeStyles} onChange={setTicketPrices} />
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* session capacity */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "session_capacity", title: translate("capacity"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="col-span-1"
|
||||
hidden={!isEditForm}
|
||||
>
|
||||
<Input
|
||||
id="session_capacity"
|
||||
name="session_capacity"
|
||||
type="number"
|
||||
value={formData.current.session_capacity === -1 ? undefined : formData.current.session_capacity}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("session_capacity", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* seats map */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "seats-map", title: translate("dashboard-billboard-salon-number"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="col-span-1"
|
||||
hidden={!isEditForm}
|
||||
>
|
||||
<Input
|
||||
id="seats-map"
|
||||
name="seats-map"
|
||||
type="number"
|
||||
value={formData.current.seats_map === -1 ? undefined : formData.current.seats_map}
|
||||
min={1}
|
||||
className={`${textInputClass} max-lg:py-2 [direction:ltr]`}
|
||||
onInput={(data) => handleFormUpdate("seats_map", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* persian details */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "persian-details", title: translate("persian-details"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
hidden={!isEditForm}
|
||||
>
|
||||
<TextArea
|
||||
id="persian-details"
|
||||
name="persian-details"
|
||||
value={formData.current.persian_details}
|
||||
className={`${formInputClass}`}
|
||||
onInput={(data) => handleFormUpdate("persian-details", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* english details */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "english-details", title: translate("english-details"), className: "pb-1 sm:pb-2" }}
|
||||
wrapperClass="max-sm:col-span-2"
|
||||
hidden={!isEditForm}
|
||||
>
|
||||
<TextArea
|
||||
id="english-details"
|
||||
name="english-details"
|
||||
value={formData.current.persian_details}
|
||||
className={`${formInputClass}`}
|
||||
onInput={(data) => handleFormUpdate("persian-details", data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{showOverlapError &&
|
||||
<span className="block col-span-2 text-xs py-3 px-4 rounded-lg bg-error-bg text-error-text mt-4 border border-white ring-2 ring-error-bg">
|
||||
<TriangleExclamationSolid className="inline-block size-4 fill-current me-2" />
|
||||
{translate("dashboard-billboard-service-type-service-dates-session-overlap")}
|
||||
</span>
|
||||
}
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 col-span-2 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default DynamicSessionsForm
|
||||
@ -0,0 +1,388 @@
|
||||
import { hasValue, safeClone, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ReservationTicketType, TicketTypePrice } from 'common/types/billboard';
|
||||
import { BillboardFormItem } from 'common/templates/dashboard/billboards/general/fields';
|
||||
import Button from 'components/button/button';
|
||||
import Select from 'components/select/select';
|
||||
import Modal from 'components/modal/modal';
|
||||
import { weekDays } from 'services/general/general';
|
||||
import TimePicker from 'components/input/time-picker';
|
||||
import CheckBox from 'components/checkbox/checkbox';
|
||||
import TicketPrices from '../../ticket-prices/ticket-prices';
|
||||
import { Currencies } from 'common/types/general';
|
||||
import DayPickerCalendar from 'components/calendars/day-picker-calendar';
|
||||
import { CircleExclamationSolid, PenSolid, PlusSolid, TriangleExclamationSolid, XmarkSolid } from 'components/icons';
|
||||
import DynamicSessionsForm from './dynamic-sessions-form';
|
||||
|
||||
export interface SessionProps {
|
||||
from: string;
|
||||
to: string;
|
||||
capacity: number;
|
||||
gap: number;
|
||||
ticket_types: TicketTypePrice[];
|
||||
seats_map: number;
|
||||
persian_details: string;
|
||||
english_details: string;
|
||||
}
|
||||
export interface ServiceDateProps {
|
||||
weekday: "saturday" | "sunday" | "monday" | "tuesday" | "wednesday" | "thursday" | "friday" | "working days" | "weekend" | "everyday";
|
||||
date: string;
|
||||
closed: boolean;
|
||||
from: string;
|
||||
to: string;
|
||||
ticket_types: TicketTypePrice[];
|
||||
sessions_data: SessionProps[];
|
||||
}
|
||||
interface Time {
|
||||
startHour: string;
|
||||
startMinute: string;
|
||||
endHour: string;
|
||||
endMinute: string;
|
||||
}
|
||||
interface ServiceDatesFormProps {
|
||||
data: ServiceDateProps;
|
||||
currentDates: ServiceDateProps[];
|
||||
datesType: "routine" | "dynamic";
|
||||
sessionsType: "predefined" | "dynamic";
|
||||
currency: Currencies;
|
||||
isEditForm?: boolean;
|
||||
ticketTypes: ReservationTicketType[];
|
||||
open: boolean;
|
||||
themeStyles: React.CSSProperties;
|
||||
onClose: (open: boolean) => void;
|
||||
onSubmit: (data: ServiceDateProps) => void;
|
||||
}
|
||||
|
||||
const ServiceDatesForm: React.FunctionComponent<ServiceDatesFormProps> = ({ data, currentDates, datesType, sessionsType, currency, isEditForm = false, ticketTypes, open = false, themeStyles, onClose, onSubmit }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
|
||||
const startHour = data.from.slice(0, data.from.indexOf(":"));
|
||||
const startMinute = data.from.slice(data.from.indexOf(":") + 1);
|
||||
const endHour = data.to.slice(0, data.to.indexOf(":"));
|
||||
const endMinute = data.to.slice(data.to.indexOf(":") + 1);
|
||||
|
||||
// states
|
||||
const [date, setDate] = useState<Date | null>(null);
|
||||
const [weekDay, setWeekDay] = useState<string>(data.weekday);
|
||||
const [closed, setClosed] = useState(data.closed);
|
||||
const [diffrentDayPrice, setDiffrentDayPrice] = useState(false);
|
||||
const [hours, setHours] = useState<Time>({
|
||||
startHour: startHour,
|
||||
startMinute: startMinute,
|
||||
endHour: endHour,
|
||||
endMinute: endMinute
|
||||
});
|
||||
const [initialTime, setInitialTime] = useState({
|
||||
startHour: Number(startHour),
|
||||
startMinute: Number(startMinute),
|
||||
endHour: Number(endHour),
|
||||
endMinute: Number(endMinute)
|
||||
});
|
||||
const [ticketPrices, setTicketPrices] = useState<TicketTypePrice[]>(data.ticket_types);
|
||||
const [dynamicSessions, setDynamicSessions] = useState(data.sessions_data);
|
||||
const [sessionsFormOpen, setSessionsFormOpen] = useState(false);
|
||||
const [selectedSession, setSelectedSession] = useState<SessionProps | null>(null);
|
||||
const [showOverlapError, setShowOverlapError] = useState(false);
|
||||
|
||||
// variables
|
||||
const daysOptions = [
|
||||
...weekDays,
|
||||
"everyday",
|
||||
"working-days",
|
||||
"weekend"
|
||||
];
|
||||
const emptyFormData: SessionProps = {
|
||||
from: "08:00",
|
||||
to: "10:00",
|
||||
capacity: -1,
|
||||
gap: -1,
|
||||
ticket_types: ticketPrices,
|
||||
seats_map: -1,
|
||||
persian_details: "",
|
||||
english_details: "",
|
||||
}
|
||||
|
||||
// methods
|
||||
const resetForm = () => {
|
||||
|
||||
}
|
||||
const handleHoursTimeSelection = (serviceTime: Time) => {
|
||||
setHours(serviceTime);
|
||||
}
|
||||
const handleDaySelection = (day: string) => {
|
||||
setWeekDay((daysOptions.find(x => x === day) ?? daysOptions[0]));
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
onClose(false);
|
||||
}
|
||||
const checkScheduleOverlaps = (item: ServiceDateProps) => {
|
||||
let hasOverLap = currentDates.some(x =>
|
||||
(
|
||||
(hasValue(x.weekday) && x.weekday === item.weekday) ||
|
||||
(hasValue(x.date) && x.date === item.date)
|
||||
)
|
||||
);
|
||||
return hasOverLap;
|
||||
}
|
||||
const handleSubmit = () => {
|
||||
const serviceDatesSchedule: ServiceDateProps = {
|
||||
weekday: weekDay as any,
|
||||
date: date as any,
|
||||
closed: closed,
|
||||
from: hasValue(hours.startHour) ? `${hours.startHour}:${hours.startMinute}` : "",
|
||||
to: hasValue(hours.startHour) ? `${hours.endHour}:${hours.endMinute}` : "",
|
||||
ticket_types: ticketPrices,
|
||||
sessions_data: dynamicSessions,
|
||||
}
|
||||
if (!checkScheduleOverlaps(serviceDatesSchedule)) {
|
||||
onSubmit(serviceDatesSchedule);
|
||||
handleCloseModal();
|
||||
setShowOverlapError(false);
|
||||
} else {
|
||||
setShowOverlapError(true);
|
||||
}
|
||||
}
|
||||
|
||||
// dynamic sessions methods
|
||||
const handleServiceSessionsSubmit = (sessionData: SessionProps) => {
|
||||
if (selectedSession) {
|
||||
const localeSessions: SessionProps[] = safeClone(dynamicSessions);
|
||||
const dataIndex = localeSessions.findIndex(x => x.from === selectedSession?.from);
|
||||
localeSessions.splice(dataIndex, 1, sessionData);
|
||||
setDynamicSessions(localeSessions);
|
||||
} else {
|
||||
setDynamicSessions(prev => [...prev, sessionData]);
|
||||
}
|
||||
}
|
||||
const handleCreateServiceSession = () => {
|
||||
setSelectedSession(null);
|
||||
setSessionsFormOpen(true);
|
||||
}
|
||||
const handleEditSession = (sessionData: SessionProps) => {
|
||||
setSelectedSession(sessionData);
|
||||
setSessionsFormOpen(true);
|
||||
}
|
||||
const handleDeleteSession = (sessionData: SessionProps) => {
|
||||
const localeSessions: SessionProps[] = safeClone(dynamicSessions);
|
||||
const dataIndex = localeSessions.findIndex(x => x.from === sessionData.from);
|
||||
localeSessions.splice(dataIndex, 1);
|
||||
setDynamicSessions(localeSessions);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
setWeekDay(data.weekday);
|
||||
setClosed(data.closed);
|
||||
setHours({
|
||||
startHour: data.from.slice(0, data.from.indexOf(":")),
|
||||
startMinute: data.from.slice(data.from.indexOf(":") + 1),
|
||||
endHour: data.to.slice(0, data.to.indexOf(":")),
|
||||
endMinute: data.to.slice(data.to.indexOf(":") + 1)
|
||||
});
|
||||
setTicketPrices(data.ticket_types);
|
||||
setDynamicSessions(data.sessions_data);
|
||||
if (data.from !== `${initialTime.startHour}:${initialTime.startMinute}`) {
|
||||
setInitialTime({
|
||||
startHour: hasValue(data.from) ? Number(data.from.slice(0, data.from.indexOf(":"))) : -1,
|
||||
startMinute: hasValue(data.from) ? Number(data.from.slice(data.from.indexOf(":") + 1)) : -1,
|
||||
endHour: hasValue(data.from) ? Number(data.to.slice(0, data.to.indexOf(":"))) : -1,
|
||||
endMinute: hasValue(data.from) ? Number(data.to.slice(data.to.indexOf(":") + 1)) : -1
|
||||
});
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// return
|
||||
return (
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`edit-ticket-modal`}
|
||||
open={open}
|
||||
onClose={handleCloseModal}
|
||||
title={translate("dashboard-billboard-service-type-service-dates-cta")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 w-full sm:w-[500px] p-gi">
|
||||
{/* weekday */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "weekday", title: translate("dashboard-billboard-service-type-service-dates-select-day"), className: "pb-1" }}
|
||||
wrapperClass="flex flex-col justify-between col-span-1 mb-5"
|
||||
>
|
||||
{datesType === "routine" ?
|
||||
<Select
|
||||
id="weekday"
|
||||
value={{ label: translate(weekDay), value: weekDay, disabled: false }}
|
||||
options={daysOptions.map(x => ({ label: translate(x), value: x, disabled: false }))}
|
||||
onChange={(data) => handleDaySelection(data.value)}
|
||||
optionsWrapper="inline-block w-full px-2 rounded-lg bg-white capitalize text-xs lg:text-sm max-lg:!py-1"
|
||||
wrapperClass="block !min-w-0 w-40 lg:w-48 bg-[var(--light-brand-color)] rounded-xl p-1"
|
||||
buttonWrapper="!ring-[var(--light-brand-color)] bg-white"
|
||||
buttonText='!text-sm'
|
||||
/>
|
||||
:
|
||||
<DayPickerCalendar
|
||||
id="event-date"
|
||||
mode="single"
|
||||
value={date ?? new Date()}
|
||||
datesToShow={date ? [date] : [new Date()]}
|
||||
noClear
|
||||
navLayout="around"
|
||||
onChange={(dates) => setDate(dates[0])}
|
||||
labelClass="!bg-[var(--light-brand-color)] !text-[var(--brand-color)]"
|
||||
todayClass="!text-[var(--brand-color)]"
|
||||
chevronClass="!fill-[var(--brand-color)]"
|
||||
selectedClass="bg-market-title-light !text-white hover:!bg-market-title-light"
|
||||
rangeStartClass="!bg-[var(--brand-color)] hover:!bg-[var(--brand-color)]"
|
||||
rangeMiddleClass="!bg-[var(--light-brand-color)] !text-[var(--brand-color)]"
|
||||
rangeEndClass="!bg-[var(--brand-color)] hover:!bg-[var(--brand-color)]"
|
||||
/>
|
||||
}
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* is closed */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "closed", title: translate("is-closed"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col"
|
||||
>
|
||||
<CheckBox
|
||||
key={"closed"}
|
||||
forId={"closed"}
|
||||
name={"closed"}
|
||||
title={translate(`logic-yes`)}
|
||||
value={"closed"}
|
||||
checked={closed}
|
||||
onChange={setClosed}
|
||||
inputClass="market-checkbox-input"
|
||||
labelClass="market-checkbox-label text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
titleClass="market-checkbox-title text-text-gray text-xs sm:text-sm"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* service hours */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "service-hours", title: translate("dashboard-billboard-service-type-service-dates-day-hours"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col justify-between col-span-2 mb-5"
|
||||
disabled={closed}
|
||||
hidden={sessionsType === "dynamic"}
|
||||
>
|
||||
<TimePicker
|
||||
id="select-service-hours"
|
||||
onChange={handleHoursTimeSelection}
|
||||
wrapperClass={``}
|
||||
initialTime={initialTime}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* diffrent day price */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "diffrent-day-price", title: translate("dashboard-billboard-service-type-service-dates-day-price-text"), className: "pb-2" }}
|
||||
wrapperClass="flex flex-col col-span-2"
|
||||
disabled={closed}
|
||||
hidden={!isEditForm}
|
||||
>
|
||||
<CheckBox
|
||||
key={"diffrent-day-price"}
|
||||
forId={"diffrent-day-price"}
|
||||
name={"diffrent-day-price"}
|
||||
title={translate(`logic-yes`)}
|
||||
value={"diffrent-day-price"}
|
||||
checked={diffrentDayPrice}
|
||||
onChange={setDiffrentDayPrice}
|
||||
inputClass="market-checkbox-input"
|
||||
labelClass="market-checkbox-label text-[var(--brand-color)] [&>span.checkmark]:hover:bg-[var(--light-brand-color)]"
|
||||
titleClass="market-checkbox-title text-text-gray text-xs sm:text-sm"
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* ticket type prices */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "gap", title: translate("dashboard-billboard-service-type-ticket-prices"), text: translate("dashboard-billboard-service-type-ticket-prices-text"), textClass: "pb-4" }}
|
||||
wrapperClass="col-span-2"
|
||||
hidden={!diffrentDayPrice}
|
||||
>
|
||||
<TicketPrices currency={currency} fullWidthTickets ticketTypes={ticketTypes} themeStyles={themeStyles} onChange={setTicketPrices} />
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* dynamic sessions */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "dynamic-sessions", title: translate("dashboard-billboard-service-type-service-dates-day-sessions"), text: translate("dashboard-billboard-service-type-service-dates-day-sessions-text"), textClass: "pb-4" }}
|
||||
wrapperClass="col-span-2"
|
||||
disabled={closed}
|
||||
hidden={sessionsType === "predefined"}
|
||||
>
|
||||
<div className={``}>
|
||||
<div className="block w-full pt-2 pb-6">
|
||||
{!hasValue(dynamicSessions) ?
|
||||
<span className="text-xs py-2 px-4 rounded-lg bg-warning-bg text-warning-text">
|
||||
<CircleExclamationSolid className="inline-block size-4 fill-current me-2" />
|
||||
{translate("dashboard-billboard-service-type-service-dates-no-sessions")}
|
||||
</span>
|
||||
:
|
||||
<ul className="flex flex-col gap-2 w-full lg:w-3/4">
|
||||
{dynamicSessions.map((x, i) => (
|
||||
<li key={i} className="flex items-center justify-between w-full py-2 px-2 rounded-xl bg-white ring-1 ring-gray-100">
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 select-none font-semibold bg-gray-50 text-text-color">{`${x.from} - ${x.to}`}</span>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<PenSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-warning-bg hover:fill-warning-text lg:cursor-pointer" onClick={() => handleEditSession(x)} />
|
||||
<XmarkSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-error-bg hover:fill-error-text lg:cursor-pointer" onClick={() => handleDeleteSession(x)} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-service-type-service-dates-add-sessions")}
|
||||
className="bg-[var(--light-brand-color)] text-[var(--brand-color)] text-xs capitalize shadow-none w-44 px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<PlusSolid className="inline-block size-4 fill-current me-3" />}
|
||||
onClick={handleCreateServiceSession}
|
||||
/>
|
||||
}
|
||||
<DynamicSessionsForm
|
||||
data={selectedSession ?? emptyFormData}
|
||||
currentSessions={selectedSession ? dynamicSessions.filter(x => x.from !== selectedSession.from) : dynamicSessions}
|
||||
currency={currency}
|
||||
open={sessionsFormOpen}
|
||||
ticketTypes={ticketTypes}
|
||||
themeStyles={themeStyles}
|
||||
onClose={setSessionsFormOpen}
|
||||
onSubmit={handleServiceSessionsSubmit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{showOverlapError &&
|
||||
<span className="block col-span-2 text-xs py-3 px-4 rounded-lg bg-error-bg text-error-text mt-6 border border-white ring-2 ring-error-bg">
|
||||
<TriangleExclamationSolid className="inline-block size-4 fill-current me-2" />
|
||||
{translate("dashboard-billboard-service-type-service-dates-session-overlap")}
|
||||
</span>
|
||||
}
|
||||
</BillboardFormItem>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 col-span-2 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={handleSubmit}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default ServiceDatesForm
|
||||
@ -0,0 +1,131 @@
|
||||
import { safeClone, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useState } from 'react';
|
||||
import { ReservationTicketType } from 'common/types/billboard';
|
||||
import Button from 'components/button/button';
|
||||
import { CircleExclamationSolid, PenSolid, PlusSolid, XmarkSolid } from 'components/icons';
|
||||
import { Currencies } from 'common/types/general';
|
||||
import ServiceDatesForm, { ServiceDateProps } from './service-dates-form';
|
||||
|
||||
interface ServiceDatesProps {
|
||||
isEditForm?: boolean;
|
||||
currency: Currencies;
|
||||
dates: ServiceDateProps[]
|
||||
datesType: "routine" | "dynamic";
|
||||
sessionsType: "predefined" | "dynamic";
|
||||
ticketTypes: ReservationTicketType[];
|
||||
themeStyles: React.CSSProperties;
|
||||
onChange: (data: ServiceDateProps[]) => void;
|
||||
}
|
||||
|
||||
const ServiceDates: React.FunctionComponent<ServiceDatesProps> = ({ dates, isEditForm = false, datesType, sessionsType, currency, ticketTypes, themeStyles, onChange }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
|
||||
// states
|
||||
const [serviceDatesOpen, setServiceDatesOpen] = useState(false);
|
||||
const [serviceDates, setServiceDates] = useState<ServiceDateProps[]>(dates ?? []);
|
||||
const [selectedServiceDate, setSelectedServiceDate] = useState<ServiceDateProps | null>(null);
|
||||
|
||||
// variables
|
||||
const emptyFormData: ServiceDateProps = {
|
||||
weekday: "monday",
|
||||
date: "",
|
||||
closed: false,
|
||||
from: "08:00",
|
||||
to: "10:00",
|
||||
ticket_types: [],
|
||||
sessions_data: []
|
||||
}
|
||||
|
||||
// methods
|
||||
const handleServiceDateSubmit = (ServiceDateProps: ServiceDateProps) => {
|
||||
if (selectedServiceDate) {
|
||||
const localeServiceDates: ServiceDateProps[] = safeClone(serviceDates);
|
||||
const dataIndex = localeServiceDates.findIndex(x => x.weekday === selectedServiceDate.weekday);
|
||||
localeServiceDates.splice(dataIndex, 1, ServiceDateProps);
|
||||
setServiceDates(localeServiceDates);
|
||||
onChange(localeServiceDates);
|
||||
} else {
|
||||
setServiceDates(prev => [...prev, ServiceDateProps]);
|
||||
onChange([...serviceDates, ServiceDateProps]);
|
||||
}
|
||||
}
|
||||
const handleCreateServiceDate = () => {
|
||||
setSelectedServiceDate(null);
|
||||
setServiceDatesOpen(true);
|
||||
}
|
||||
const handleEditSession = (serviceData: ServiceDateProps) => {
|
||||
setSelectedServiceDate(serviceData);
|
||||
setServiceDatesOpen(true);
|
||||
}
|
||||
const handleDeleteSession = (serviceData: ServiceDateProps) => {
|
||||
const localeServiceDates: ServiceDateProps[] = safeClone(serviceDates);
|
||||
const dataIndex = localeServiceDates.findIndex(x => x.weekday === serviceData.weekday);
|
||||
localeServiceDates.splice(dataIndex, 1);
|
||||
setServiceDates(localeServiceDates);
|
||||
onChange(localeServiceDates);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
// return
|
||||
return (
|
||||
<div className={``}>
|
||||
<div className="block w-full pt-2 pb-6">
|
||||
{serviceDates.length === 0 ?
|
||||
<span className="text-xs py-2 px-4 rounded-lg bg-warning-bg text-warning-text">
|
||||
<CircleExclamationSolid className="inline-block size-4 fill-current me-2" />
|
||||
{translate("dashboard-billboard-service-type-service-dates-no-item")}
|
||||
</span>
|
||||
:
|
||||
<ul className="flex flex-col gap-2 w-full">
|
||||
{serviceDates.map((x, i) => (
|
||||
<li key={i} className="flex items-center justify-between w-full py-2 px-2 rounded-xl bg-white ring-1 ring-gray-100">
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 select-none font-semibold bg-[var(--light-brand-color)] text-[var(--brand-color)]">{`${translate(x.weekday)}`}</span>
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 select-none font-semibold bg-gray-50 text-text-color">
|
||||
{x.closed ?
|
||||
translate("is-closed")
|
||||
:
|
||||
(sessionsType === "predefined" ?
|
||||
`${x.from} - ${x.to}`
|
||||
:
|
||||
`${x.sessions_data.length} ${translate("session")}${(locale === "en" && x.sessions_data.length > 1) ? "s" : ""}`
|
||||
)
|
||||
}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<PenSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-warning-bg hover:fill-warning-text lg:cursor-pointer" onClick={() => handleEditSession(x)} />
|
||||
<XmarkSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-error-bg hover:fill-error-text lg:cursor-pointer" onClick={() => handleDeleteSession(x)} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
{
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-service-type-service-dates-cta")}
|
||||
className="bg-[var(--light-brand-color)] text-[var(--brand-color)] text-xs capitalize shadow-none w-44 px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<PlusSolid className="inline-block size-4 fill-current me-3" />}
|
||||
onClick={handleCreateServiceDate}
|
||||
/>
|
||||
}
|
||||
<ServiceDatesForm
|
||||
data={selectedServiceDate ?? emptyFormData}
|
||||
currentDates={selectedServiceDate ? serviceDates.filter(x => x.from !== selectedServiceDate.from) : serviceDates}
|
||||
datesType={datesType}
|
||||
sessionsType={sessionsType}
|
||||
currency={currency}
|
||||
open={serviceDatesOpen}
|
||||
ticketTypes={ticketTypes}
|
||||
themeStyles={themeStyles}
|
||||
onClose={setServiceDatesOpen}
|
||||
onSubmit={handleServiceDateSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ServiceDates
|
||||
@ -0,0 +1,104 @@
|
||||
import { hasValue } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { BillboardFormItem } from 'common/templates/dashboard/billboards/general/fields';
|
||||
import Input from 'components/input/text';
|
||||
import Button from 'components/button/button';
|
||||
import Modal from 'components/modal/modal';
|
||||
import { formInputClass } from 'components/general/form-tools';
|
||||
|
||||
export interface ServiceRuleProps {
|
||||
id: number;
|
||||
rule: string;
|
||||
}
|
||||
interface ServiceRulesFormProps {
|
||||
data: ServiceRuleProps | null;
|
||||
lastRuleId: number;
|
||||
fieldsTr: string | undefined;
|
||||
open: boolean;
|
||||
themeStyles: React.CSSProperties;
|
||||
onClose: (open: boolean) => void;
|
||||
onSubmit: (rule: ServiceRuleProps) => void;
|
||||
}
|
||||
|
||||
const ServiceRulesForm: React.FunctionComponent<ServiceRulesFormProps> = ({ data, lastRuleId, fieldsTr, open = false, themeStyles, onClose, onSubmit }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
// states
|
||||
const [rule, setRule] = useState(data ? data.rule : "");
|
||||
|
||||
// variables
|
||||
|
||||
// methods
|
||||
const resetForm = () => {
|
||||
setRule("");
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
onClose(false);
|
||||
}
|
||||
const handleSubmit = () => {
|
||||
onSubmit({ id: data ? data.id : lastRuleId + 1, rule: rule });
|
||||
onClose(false);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
data ? setRule(data.rule) : setRule("");
|
||||
}, [data]);
|
||||
|
||||
// return
|
||||
return (
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`edit-ticket-modal`}
|
||||
open={open}
|
||||
onClose={handleCloseModal}
|
||||
title={translate("dashboard-billboard-service-type-rules-cta")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="block w-full sm:w-[500px] p-gi">
|
||||
{/* rule */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "rule", title: translate("dashboard-billboard-service-type-rules-rule") }}
|
||||
wrapperClass="mb-5"
|
||||
required
|
||||
translatable
|
||||
>
|
||||
<Input
|
||||
id="rule"
|
||||
name="rule"
|
||||
type="text"
|
||||
maxChar={100}
|
||||
value={rule}
|
||||
className={`${formInputClass}`}
|
||||
onInput={(data) => setRule(data)}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={handleSubmit}
|
||||
disabled={!hasValue(rule)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default ServiceRulesForm
|
||||
@ -0,0 +1,108 @@
|
||||
import { safeClone } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useState } from 'react';
|
||||
import Button from 'components/button/button';
|
||||
import { ServiceRuleProps } from './service-rules-form';
|
||||
import { CircleExclamationSolid, PenSolid, PlusSolid, XmarkSolid } from 'components/icons';
|
||||
import ServiceRulesForm from './service-rules-form';
|
||||
|
||||
export interface RulesProps {
|
||||
fa: ServiceRuleProps[];
|
||||
en: ServiceRuleProps[];
|
||||
}
|
||||
interface ServiceRulesProps {
|
||||
data: RulesProps;
|
||||
fieldsTr: string | undefined;
|
||||
themeStyles: React.CSSProperties;
|
||||
onChange: (data: ServiceRuleProps[]) => void;
|
||||
}
|
||||
|
||||
const ServiceRules: React.FunctionComponent<ServiceRulesProps> = ({ data, fieldsTr, themeStyles, onChange }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
|
||||
// states
|
||||
const [serviceRulesOpen, setServiceRulesOpen] = useState(false);
|
||||
const [serviceRules, setServiceRules] = useState<ServiceRuleProps[]>((data as any)[fieldsTr as any]);
|
||||
const [selectedServiceRule, setSelectedServiceRule] = useState<ServiceRuleProps | null>(null);
|
||||
|
||||
// variables
|
||||
|
||||
// methods
|
||||
const handleServiceRuleSubmit = (data: ServiceRuleProps) => {
|
||||
const localServiceRules: ServiceRuleProps[] = safeClone(serviceRules);
|
||||
const dataIndex = localServiceRules.findIndex(x => x.id === data.id);
|
||||
if (localServiceRules.some(x => x.id === data.id)) {
|
||||
localServiceRules.splice(dataIndex, 1, data);
|
||||
} else {
|
||||
localServiceRules.push(data);
|
||||
}
|
||||
|
||||
setServiceRules(localServiceRules);
|
||||
onChange(localServiceRules);
|
||||
}
|
||||
|
||||
const handleServiceRuleCreate = () => {
|
||||
setSelectedServiceRule(null);
|
||||
setServiceRulesOpen(true);
|
||||
}
|
||||
|
||||
const handleServiceRuleEdit = (item: ServiceRuleProps) => {
|
||||
setSelectedServiceRule(item);
|
||||
setServiceRulesOpen(true);
|
||||
}
|
||||
|
||||
const handleServiceRuleDelete = (item: ServiceRuleProps) => {
|
||||
const localServiceRules: ServiceRuleProps[] = safeClone(serviceRules);
|
||||
localServiceRules.splice(localServiceRules.findIndex(x => x.id === item.id), 1);
|
||||
setServiceRules(localServiceRules);
|
||||
onChange(localServiceRules);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
// return
|
||||
return (
|
||||
<div className={``}>
|
||||
<div className="block w-full pt-2 pb-6">
|
||||
{serviceRules.length === 0 ?
|
||||
<span className="text-xs py-2 px-4 rounded-lg bg-warning-bg text-warning-text">
|
||||
<CircleExclamationSolid className="inline-block size-4 fill-current me-2" />
|
||||
{translate("dashboard-billboard-service-type-rules-no-rules")}
|
||||
</span>
|
||||
:
|
||||
<ul className={`grid grid-cols-2 gap-x-6 gap-y-2 w-full`}>
|
||||
{serviceRules.map(x => (
|
||||
<li key={x.id} className="flex items-center justify-between w-full py-2 px-2 rounded-xl bg-white ring-1 ring-gray-100">
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 select-none font-semibold bg-[var(--light-brand-color)] text-[var(--brand-color)]">
|
||||
{x.rule}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<PenSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-warning-bg hover:fill-warning-text lg:cursor-pointer" onClick={() => handleServiceRuleEdit(x)} />
|
||||
<XmarkSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-error-bg hover:fill-error-text lg:cursor-pointer" onClick={() => handleServiceRuleDelete(x)} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-service-type-rules-cta")}
|
||||
className="bg-[var(--light-brand-color)] text-[var(--brand-color)] text-xs capitalize shadow-none w-44 px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<PlusSolid className="inline-block size-4 fill-current me-3" />}
|
||||
onClick={handleServiceRuleCreate}
|
||||
/>
|
||||
<ServiceRulesForm
|
||||
data={selectedServiceRule}
|
||||
lastRuleId={serviceRules.length > 0 ? serviceRules[serviceRules.length - 1].id : 0}
|
||||
fieldsTr={fieldsTr}
|
||||
open={serviceRulesOpen}
|
||||
themeStyles={themeStyles}
|
||||
onClose={setServiceRulesOpen}
|
||||
onSubmit={handleServiceRuleSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default ServiceRules
|
||||
@ -0,0 +1,146 @@
|
||||
import { getLocaleTr, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { PenSolid, PlusSolid, SplitSolid, XmarkSolid } from 'components/icons';
|
||||
import { useState } from 'react';
|
||||
import Button from 'components/button/button';
|
||||
import Modal from 'components/modal/modal';
|
||||
import { restBulkDeleteRequest } from 'services/queries/directus/billboard';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import { BillboardServiceType } from 'common/types/billboard';
|
||||
import BillboardSection from '../../../../billboard-page/section';
|
||||
|
||||
interface ServiceTypesProps {
|
||||
serviceTypes: BillboardServiceType[];
|
||||
themeStyles: React.CSSProperties;
|
||||
}
|
||||
|
||||
const ServiceTypes: React.FunctionComponent<ServiceTypesProps> = ({ serviceTypes, themeStyles }) => {
|
||||
|
||||
// states
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
|
||||
const [alertOpen, setAlertOpen] = useState(false);
|
||||
const [serviceTypeToDelete, setServiceTypeToDelete] = useState<BillboardServiceType | null>(null);
|
||||
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const { locale, query, router } = useGetRouter();
|
||||
|
||||
// methods
|
||||
const handleServiceTypeDelete = async () => {
|
||||
try {
|
||||
setUpdating(true);
|
||||
await restBulkDeleteRequest({
|
||||
collection: 'billboard_service_types',
|
||||
ids: [serviceTypeToDelete!.id]
|
||||
});
|
||||
|
||||
setUpdating(false);
|
||||
setDeleteModalOpen(false);
|
||||
setAlertOpen(true);
|
||||
} catch (error) {
|
||||
console.error("service type delete failed:", error);
|
||||
setUpdating(false);
|
||||
}
|
||||
}
|
||||
|
||||
const onAlertClose = () => {
|
||||
setAlertOpen(false);
|
||||
alertOpen && router.reload();
|
||||
}
|
||||
|
||||
const handleCreate = () => {
|
||||
router.push(`/dashboard/billboards/${query.id}/products/${query.product_id}/service-type`);
|
||||
}
|
||||
const handleEdit = (serviceTypeId: string) => {
|
||||
router.push(`/dashboard/billboards/${query.id}/products/${query.product_id}/service-type?id=${serviceTypeId}`);
|
||||
}
|
||||
const handleDelete = (serviceType: BillboardServiceType) => {
|
||||
setServiceTypeToDelete(serviceType);
|
||||
setDeleteModalOpen(true);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
// return
|
||||
return (
|
||||
<BillboardSection
|
||||
title={translate("dashboard-billboard-service-types-title")}
|
||||
icon={<SplitSolid className="inline-block size-4 text-[var(--brand-color)] fill-current" />}
|
||||
wrapperClass=""
|
||||
>
|
||||
<EventAlert
|
||||
text={translate("dashboard-changes-successfully-applied")}
|
||||
open={alertOpen}
|
||||
type="success"
|
||||
timeOut={3000}
|
||||
onClose={onAlertClose}
|
||||
hasTime
|
||||
/>
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`delete-service-type-modal`}
|
||||
open={deleteModalOpen}
|
||||
onClose={() => setDeleteModalOpen(false)}
|
||||
title={translate("dashboard-billboard-service-types-delete-title")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="block w-full sm:w-[500px] p-gi">
|
||||
<p className="text-xs/6 sm:text-sm/7">{translate("dashboard-billboard-service-types-delete-text")}</p>
|
||||
<span className="block w-max py-1 px-4 rounded-lg bg-warning-bg text-warning-text mt-4 mx-auto">{serviceTypeToDelete ? getLocaleTr(serviceTypes.filter(x => x.id === serviceTypeToDelete.id)[0], locale).title : ""}</span>
|
||||
<div className="grid grid-cols-2 gap-4 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={`${translate("dashboard-billboard-service-types-delete-cta")}`}
|
||||
className="bg-[var(--brand-color)] text-white text-xs lg:text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={handleServiceTypeDelete}
|
||||
loading={updating}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-xs lg:text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={() => setDeleteModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
<p className="block text-xs/6">{translate("dashboard-billboard-service-types-text")}</p>
|
||||
{serviceTypes.length > 0 ?
|
||||
<>
|
||||
<ul className="block w-full list-none space-y-2 mt-6 max-h-72 p-2 rounded-xl overflow-y-scroll hide-scrollbar bg-gray-50">
|
||||
{serviceTypes.map(x => (
|
||||
<li key={x.id} className="flex items-center justify-between w-full py-2 px-2 rounded-xl bg-white ring-1 ring-gray-100">
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 select-none font-semibold bg-gray-50 text-text-color">{getLocaleTr(x, locale).title}</span>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<PenSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-warning-bg hover:fill-warning-text lg:cursor-pointer" onClick={() => handleEdit(String(x.service_id))} />
|
||||
<XmarkSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-error-bg hover:fill-error-text lg:cursor-pointer" onClick={() => handleDelete(x)} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
:
|
||||
<div className="flex flex-col items-center justify-center w-full mt-4 pt-4 border-t border-dashed border-neutral-100">
|
||||
<span className="flex items-center text-secondary-light text-xs/6 mb-4 font-semibold">
|
||||
{translate("dashboard-billboard-page-price-units-no-data")}
|
||||
</span>
|
||||
<img src="/pics/folder-no-data-blue.svg" className="block w-48" />
|
||||
</div>
|
||||
}
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-service-types-create-cta")}
|
||||
className="bg-[var(--light-brand-color)] text-[var(--brand-color)] text-xs capitalize shadow-none w-max px-[10px] pt-[6px] pb-[6px] md:px-4 md:py-2 inline-flex justify-center items-center rounded-lg mt-4"
|
||||
onClick={handleCreate}
|
||||
leftIcon={<PlusSolid className="inline-block size-3 fill-[var(--brand-color)] rtl:ml-2 ltr:mr-2" />}
|
||||
/>
|
||||
</BillboardSection>
|
||||
)
|
||||
}
|
||||
export default ServiceTypes
|
||||
@ -0,0 +1,155 @@
|
||||
import { getLocaleTr, hasValue, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ReservationTicketType, TicketTypePrice } from 'common/types/billboard';
|
||||
import { BillboardFormItem } from 'common/templates/dashboard/billboards/general/fields';
|
||||
import Input from 'components/input/text';
|
||||
import Button from 'components/button/button';
|
||||
import Select from 'components/select/select';
|
||||
import Modal from 'components/modal/modal';
|
||||
import { formInputClass } from 'components/general/form-tools';
|
||||
import { UUID } from 'crypto';
|
||||
|
||||
interface TicketPriceFormProps {
|
||||
data: TicketTypePrice[];
|
||||
ticketTypes: ReservationTicketType[];
|
||||
remainingTicketTypes: ReservationTicketType[];
|
||||
open: boolean;
|
||||
themeStyles: React.CSSProperties;
|
||||
onClose: (open: boolean) => void;
|
||||
onSubmit: (data: TicketTypePrice) => void;
|
||||
}
|
||||
|
||||
const TicketPriceForm: React.FunctionComponent<TicketPriceFormProps> = ({ data, ticketTypes, remainingTicketTypes, open = false, themeStyles, onClose, onSubmit }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
const initialTicketType = data.length > 0 ? ticketTypes.find(x => x.id === data[0].reservation_ticket_type_id)! : null;
|
||||
const initialSelectedTicket = {
|
||||
label: initialTicketType ? getLocaleTr(initialTicketType, locale).title : translate("select"),
|
||||
value: initialTicketType ? initialTicketType.id : "",
|
||||
disabled: false
|
||||
};
|
||||
|
||||
// states
|
||||
const [selectedTicket, setSelectedTicket] = useState<{ label: string, value: string, disabled: boolean }>(initialSelectedTicket);
|
||||
const [price, setPrice] = useState<number>(data.length > 0 ? data[0].price : -1);
|
||||
const [discountPrice, setDiscountPrice] = useState<number>(data.length > 0 ? data[0].discount_price : -1);
|
||||
|
||||
// variables
|
||||
|
||||
// methods
|
||||
const resetForm = () => {
|
||||
setSelectedTicket(initialSelectedTicket);
|
||||
setPrice(-1);
|
||||
setDiscountPrice(-1);
|
||||
}
|
||||
const handleCloseModal = () => {
|
||||
onClose(false);
|
||||
}
|
||||
const handleSubmit = () => {
|
||||
onSubmit({
|
||||
reservation_ticket_type_id: selectedTicket.value as UUID,
|
||||
price: Number(price),
|
||||
discount_price: Number(discountPrice)
|
||||
});
|
||||
onClose(false);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
useEffect(() => {
|
||||
data.length === 0 && resetForm();
|
||||
if (data.length > 0 && selectedTicket.value !== initialSelectedTicket.value) {
|
||||
setSelectedTicket(initialSelectedTicket);
|
||||
setPrice(data[0].price);
|
||||
setDiscountPrice(data[0].discount_price);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
// return
|
||||
return (
|
||||
<Modal
|
||||
header={true}
|
||||
wrapperId={`edit-ticket-modal`}
|
||||
open={open}
|
||||
onClose={handleCloseModal}
|
||||
title={translate("dashboard-billboard-page-price-units-create")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
headerClass="!h-12"
|
||||
titleClass="!text-sm"
|
||||
closeClass="!size-6"
|
||||
style={themeStyles}
|
||||
>
|
||||
<div className="block w-full sm:w-[500px] p-gi">
|
||||
{/* ticket type */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "has-service-provider", title: translate("dashboard-billboard-service-type-has-provider-title"), text: translate("dashboard-billboard-service-type-has-provider-text"), textClass: "pb-4" }}
|
||||
wrapperClass="flex flex-col justify-between col-span-1 mb-5"
|
||||
>
|
||||
<Select
|
||||
id="ticket-type"
|
||||
value={selectedTicket}
|
||||
options={remainingTicketTypes.map(x => ({ label: getLocaleTr(x, locale).title, value: x.id, disabled: false }))}
|
||||
onChange={(data) => setSelectedTicket({ label: String(data.label), value: data.value, disabled: data.disabled })}
|
||||
allValue={translate("select")}
|
||||
optionsWrapper="inline-block w-full px-2 rounded-lg bg-white capitalize text-xs lg:text-sm max-lg:!py-1"
|
||||
wrapperClass="block !min-w-0 w-40 lg:w-48 bg-[var(--light-brand-color)] rounded-xl p-1"
|
||||
buttonWrapper="!ring-[var(--light-brand-color)] bg-white"
|
||||
buttonText='!text-sm'
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* price */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "price", title: translate("price") }}
|
||||
wrapperClass="mb-5"
|
||||
required
|
||||
>
|
||||
<Input
|
||||
id="price"
|
||||
name="price"
|
||||
type="number"
|
||||
min={0}
|
||||
value={price === -1 ? undefined : price}
|
||||
className={`${formInputClass} [direction:ltr]`}
|
||||
onInput={setPrice}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
{/* discount price */}
|
||||
<BillboardFormItem
|
||||
label={{ forId: "discount-price", title: translate("discount-price") }}
|
||||
wrapperClass="mb-2"
|
||||
>
|
||||
<Input
|
||||
id="discount-price"
|
||||
name="discount-price"
|
||||
type="number"
|
||||
min={0}
|
||||
value={discountPrice === -1 ? undefined : discountPrice}
|
||||
className={`${formInputClass} [direction:ltr]`}
|
||||
onInput={setDiscountPrice}
|
||||
/>
|
||||
</BillboardFormItem>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 px-2 pt-8">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("apply-changes")}
|
||||
className="bg-[var(--brand-color)] text-white text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl rtl:ml-2 ltr:mr-2 rtl:lg:ml-3 ltr:lg:mr-3"
|
||||
onClick={handleSubmit}
|
||||
disabled={price === -1 || !price || selectedTicket.value === ""}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("cancel")}
|
||||
className="bg-white text-[var(--brand-color)] ring-1 ring-[var(--brand-color)] text-sm capitalize shadow-none w-full px-[10px] pt-[6px] pb-[6px] md:px-3 md:py-2 inline-flex justify-center items-center rounded-xl"
|
||||
onClick={handleCloseModal}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
export default TicketPriceForm
|
||||
@ -0,0 +1,113 @@
|
||||
import { getLocaleTr, safeClone, useGetRouter } from 'services/general/general';
|
||||
import useTranslate from 'services/translation/translation';
|
||||
import { useState } from 'react';
|
||||
import { ReservationTicketType, TicketTypePrice } from 'common/types/billboard';
|
||||
import Button from 'components/button/button';
|
||||
import TicketPriceForm from './ticket-price-form';
|
||||
import { CircleExclamationSolid, PenSolid, PlusSolid, XmarkSolid } from 'components/icons';
|
||||
import { Currencies } from 'common/types/general';
|
||||
|
||||
interface TicketPricesProps {
|
||||
prices?: TicketTypePrice[];
|
||||
currency: Currencies;
|
||||
fullWidthTickets?: boolean;
|
||||
ticketTypes: ReservationTicketType[];
|
||||
themeStyles: React.CSSProperties;
|
||||
onChange: (data: TicketTypePrice[]) => void;
|
||||
}
|
||||
|
||||
const TicketPrices: React.FunctionComponent<TicketPricesProps> = ({ prices, currency, fullWidthTickets = false, ticketTypes, themeStyles, onChange }) => {
|
||||
|
||||
const translate = useTranslate();
|
||||
const { locale } = useGetRouter();
|
||||
|
||||
// states
|
||||
const [ticketPricesOpen, setTicketPricesOpen] = useState(false);
|
||||
const [ticketPrices, setTicketPrices] = useState<TicketTypePrice[]>(prices ?? []);
|
||||
const [selectedTicketPrice, setSelectedTicketPrice] = useState<TicketTypePrice[]>([]);
|
||||
|
||||
// variables
|
||||
const remainingTicketTypes = ticketTypes.filter(x => !ticketPrices.some(p => p.reservation_ticket_type_id === x.id));
|
||||
|
||||
// methods
|
||||
const handleTicketPriceSubmit = (data: TicketTypePrice) => {
|
||||
const localeTicketPrices: TicketTypePrice[] = safeClone(ticketPrices);
|
||||
const dataIndex = localeTicketPrices.findIndex(x => x.reservation_ticket_type_id === data.reservation_ticket_type_id);
|
||||
if (localeTicketPrices.some(x => x.reservation_ticket_type_id === data.reservation_ticket_type_id)) {
|
||||
localeTicketPrices.splice(dataIndex, 1, data);
|
||||
} else {
|
||||
localeTicketPrices.push(data);
|
||||
}
|
||||
|
||||
setTicketPrices(localeTicketPrices);
|
||||
onChange(localeTicketPrices);
|
||||
}
|
||||
|
||||
const handleTicketPriceCreate = () => {
|
||||
setSelectedTicketPrice([]);
|
||||
setTicketPricesOpen(true);
|
||||
}
|
||||
|
||||
const handleTicketPriceEdit = (item: TicketTypePrice) => {
|
||||
setSelectedTicketPrice([item]);
|
||||
setTicketPricesOpen(true);
|
||||
}
|
||||
|
||||
const handleTicketPriceDelete = (item: TicketTypePrice) => {
|
||||
const localeTicketPrices: TicketTypePrice[] = safeClone(ticketPrices);
|
||||
localeTicketPrices.splice(localeTicketPrices.indexOf(item), 1);
|
||||
setTicketPrices(localeTicketPrices);
|
||||
onChange(localeTicketPrices);
|
||||
}
|
||||
|
||||
// useEffects
|
||||
|
||||
// return
|
||||
return (
|
||||
<div className={``}>
|
||||
<div className="block w-full pt-2 pb-6">
|
||||
{ticketPrices.length === 0 ?
|
||||
<span className="text-xs py-2 px-4 rounded-lg bg-warning-bg text-warning-text">
|
||||
<CircleExclamationSolid className="inline-block size-4 fill-current me-2" />
|
||||
{translate("dashboard-billboard-service-type-ticket-prices-no-items")}
|
||||
</span>
|
||||
:
|
||||
<ul className={`flex flex-col gap-2 w-full ${fullWidthTickets ? "" : "lg:w-1/2"}`}>
|
||||
{ticketPrices.map(x => (
|
||||
<li key={x.reservation_ticket_type_id} className="flex items-center justify-between w-full py-2 px-2 rounded-xl bg-white ring-1 ring-gray-100">
|
||||
<span className="inline-block shrink-0 py-1 px-3 rounded-lg text-xs/5 select-none font-semibold bg-[var(--light-brand-color)] text-[var(--brand-color)]">
|
||||
{getLocaleTr(ticketTypes.find(t => t.id === x.reservation_ticket_type_id)!, locale).title}
|
||||
</span>
|
||||
<span className="inline-block shrink-0 text-xs/5 xl:text-sm/6 text-secondary-light">
|
||||
{`${x.price} ${getLocaleTr(currency, locale).name}`}
|
||||
</span>
|
||||
<div className="flex items-center space-x-2 rtl:space-x-reverse">
|
||||
<PenSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-warning-bg hover:fill-warning-text lg:cursor-pointer" onClick={() => handleTicketPriceEdit(x)} />
|
||||
<XmarkSolid className="reactive-button inline-block size-7 p-2 rounded-lg bg-gray-100 fill-text-gray hover:bg-error-bg hover:fill-error-text lg:cursor-pointer" onClick={() => handleTicketPriceDelete(x)} />
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
{remainingTicketTypes.length > 0 && <Button
|
||||
type="button"
|
||||
text={translate("dashboard-billboard-service-type-ticket-prices-cta")}
|
||||
className="bg-[var(--light-brand-color)] text-[var(--brand-color)] text-xs capitalize shadow-none w-44 px-[10px] pt-2 pb-[6px] md:px-3 md:py-3 inline-flex justify-center items-center rounded-xl"
|
||||
leftIcon={<PlusSolid className="inline-block size-4 fill-current me-3" />}
|
||||
onClick={handleTicketPriceCreate}
|
||||
/>
|
||||
}
|
||||
<TicketPriceForm
|
||||
data={selectedTicketPrice}
|
||||
open={ticketPricesOpen}
|
||||
ticketTypes={ticketTypes}
|
||||
remainingTicketTypes={remainingTicketTypes}
|
||||
themeStyles={themeStyles}
|
||||
onClose={setTicketPricesOpen}
|
||||
onSubmit={handleTicketPriceSubmit}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
export default TicketPrices
|
||||
@ -6,11 +6,12 @@ import { BoxArchiveSolid, BrushSolid, CheckSolid, EllipsisSolid, ImageSlashSolid
|
||||
import Button from 'components/button/button';
|
||||
import { MutableRefObject, useRef, useState } from 'react';
|
||||
import { StockDataProps } from 'pages/api/billboard/product-stock-data';
|
||||
import { getProductTypeParentId, getVariantSize, variantsOrder } from 'services/billboard/general';
|
||||
import { getProductTypeParentId, getVariantSize, variantsOrder } from 'services/billboard/general';
|
||||
import { deleteProductVariationItems, mutationItemsRequest, updateProductVariationItem } from 'services/queries/directus/billboard';
|
||||
import Modal from 'components/modal/modal';
|
||||
import EventAlert from 'components/popover/event-alert';
|
||||
import ClickOutside from 'components/click-outside/click-outside';
|
||||
import Image from 'components/image/image';
|
||||
|
||||
interface ProductVariantsProps {
|
||||
product: BillboardProduct;
|
||||
@ -53,10 +54,12 @@ const MainAttribute: React.FunctionComponent<MainAttributeProps> = ({ index, tot
|
||||
// variables
|
||||
const translate = useTranslate();
|
||||
const menuItemIconClass = "inline-block size-5 p-1 rounded text-current rtl:ml-2 ltr:mr-2 lg:rtl:ml-2 lg:ltr:mr-2 bg-gray-100 fill-text-gray";
|
||||
const mainAtrrClass = "inline-block text-xs/5 sm:text-sm/6 text-secondary-light uppercase text-current col-span-5 sm:col-span-6 pr-3 sm:pr-8";
|
||||
const mainAtrrClass = "inline-block text-xs/5 sm:text-sm/6 text-secondary-light uppercase text-current col-span-5 sm:col-span-6 pr-7 sm:pr-8";
|
||||
const isLast = index + 1 === totalVariants;
|
||||
const isSecondToLast = index + 1 === totalVariants - 1;
|
||||
|
||||
const productTitle = "";
|
||||
const variantPic = variant.pics.length > 0 ? variant.pics[0].variant_galleries_id.gallery[0].directus_files_id : null;
|
||||
|
||||
const handleEdit = (id: number) => {
|
||||
router.push(`/dashboard/billboards/${router.query.id}/products/${router.query.product_id}/create-variant?variant_id=${id}`);
|
||||
}
|
||||
@ -70,7 +73,7 @@ const MainAttribute: React.FunctionComponent<MainAttributeProps> = ({ index, tot
|
||||
return <li className={`block relative bg-white py-[6px] px-[6px] rounded-xl ring-1 ring-gray-100/75 ${className}`}>
|
||||
<div className="grid grid-cols-12 items-center justify-between relative">
|
||||
{/* main variant distincted by a star */}
|
||||
{variant.main_variant && <StarSolid className= "inline-block absolute -top-2 -right-3 size-3 lg:size-4 p-[3px] fill-white bg-[var(--brand-color)] rounded-full border border-white ring-1 ring-[var(--brand-color)]" />}
|
||||
{variant.main_variant && <StarSolid className="inline-block absolute -top-2 -right-3 size-3 lg:size-4 p-[3px] fill-white bg-[var(--brand-color)] rounded-full border border-white ring-1 ring-[var(--brand-color)]" />}
|
||||
{/* {mainAttr === "color" &&
|
||||
<div className="flex space-x-2 sm:space-x-3 rtl:space-x-reverse items-center col-span-5">
|
||||
<span style={{ "--bg-color": `#${variant.color.hex_code}`} as React.CSSProperties} className="inline-block size-5 sm:size-6 bg-[var(--bg-color)] rounded-full border-2 border-white ring-1 ring-neutral-200"></span>
|
||||
@ -92,32 +95,45 @@ const MainAttribute: React.FunctionComponent<MainAttributeProps> = ({ index, tot
|
||||
className={`variant-handle-${variant.id} reactive-button inline-block w-8 h-6 lg:w-10 lg:h-6 py-[2px] px-2 fill-text-gray bg-gray-50 hover:bg-[var(--light-brand-color)] hover:fill-[var(--brand-color)] rounded-lg lg:cursor-pointer`}
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
/>
|
||||
{menuOpen &&
|
||||
<ClickOutside
|
||||
handleClass={`variant-handle-${variant.id}`}
|
||||
onClickOutside={() => setMenuOpen(false)}
|
||||
{menuOpen &&
|
||||
<ClickOutside
|
||||
handleClass={`variant-handle-${variant.id}`}
|
||||
onClickOutside={() => setMenuOpen(false)}
|
||||
className={`absolute ${totalVariants > 3 && (isLast || isSecondToLast) ? "bottom-[120%]" : "top-[120%]"} rtl:left-0 ltr:right-0 z-10`}
|
||||
>
|
||||
<ul className={`variant-${variant.id} list-none w-44 rounded-lg py-1 px-1 bg-white shadow`}>
|
||||
<MenuItem
|
||||
label={`${variant.status === "archived" ? translate("unarchive") : translate("edit") + " " + translate("billboard-product-page-variant") }`}
|
||||
onClick={() => variant.status === "archived" ? handleArchive(Number(variant.id)) : handleEdit(variant.variant_id)}
|
||||
icon={variant.status === "archived" ? <InboxOutSolid className={`${menuItemIconClass}`} /> : <PenSolid className={`${menuItemIconClass}`} />}
|
||||
wrapperClass="[&_svg]:hover:bg-warning-bg [&_svg]:hover:fill-warning-text hover:text-warning-text"
|
||||
<MenuItem
|
||||
label={`${variant.status === "archived" ? translate("unarchive") : translate("edit") + " " + translate("billboard-product-page-variant")}`}
|
||||
onClick={() => variant.status === "archived" ? handleArchive(Number(variant.id)) : handleEdit(variant.variant_id)}
|
||||
icon={variant.status === "archived" ? <InboxOutSolid className={`${menuItemIconClass}`} /> : <PenSolid className={`${menuItemIconClass}`} />}
|
||||
wrapperClass="[&_svg]:hover:bg-warning-bg [&_svg]:hover:fill-warning-text hover:text-warning-text"
|
||||
/>
|
||||
<MenuItem
|
||||
label={`${variant.status === "archived" ? translate("delete") : translate("archive")}`}
|
||||
<MenuItem
|
||||
label={`${variant.status === "archived" ? translate("delete") : translate("archive")}`}
|
||||
onClick={() => variant.status === "archived" ? handleDelete(Number(variant.id)) : handleArchive(Number(variant.id))}
|
||||
icon={variant.status === "archived" ? <XmarkSolid className={`${menuItemIconClass}`} /> : <BoxArchiveSolid className={`${menuItemIconClass}`} />}
|
||||
wrapperClass="[&_svg]:hover:bg-error-bg [&_svg]:hover:fill-error-text hover:text-error-text"
|
||||
icon={variant.status === "archived" ? <XmarkSolid className={`${menuItemIconClass}`} /> : <BoxArchiveSolid className={`${menuItemIconClass}`} />}
|
||||
wrapperClass="[&_svg]:hover:bg-error-bg [&_svg]:hover:fill-error-text hover:text-error-text"
|
||||
/>
|
||||
</ul>
|
||||
</ClickOutside>
|
||||
}
|
||||
</div>
|
||||
<div className="flex items-center justify-center absolute top-1/2 -right-3 sm:right-1 -translate-y-1/2 rounded-full bg-warning-bg text-warning-text">
|
||||
{variant.pics.length > 0 ?
|
||||
<ImageSolid className="inline-block fill-current size-5 p-1" />
|
||||
<div className="flex items-center justify-center absolute top-1/2 size-5 right-0 sm:right-1 -translate-y-1/2 rounded-full bg-warning-bg text-warning-text">
|
||||
{variantPic ?
|
||||
<Image
|
||||
src={`${variantPic.id}/${variantPic.filename_download}`}
|
||||
alt={translate("pic-of") + productTitle}
|
||||
width={variantPic.width}
|
||||
height={variantPic.height}
|
||||
quality={75}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[50, 50, 50, 50]}
|
||||
priority={true}
|
||||
noPreload={true}
|
||||
fetchPriority="low"
|
||||
className={`rounded-full will-change-transform !object-contain`}
|
||||
figureClass="rounded-full w-full transition-transform duration-200 select-none [&>div]:!bg-transparent"
|
||||
/>
|
||||
:
|
||||
<ImageSlashSolid className="inline-block fill-current size-5 p-1" />
|
||||
}
|
||||
@ -140,7 +156,7 @@ const ProductVariants: React.FunctionComponent<ProductVariantsProps> = ({ produc
|
||||
const productTitle = getLocaleTr(product, locale).title;
|
||||
const variants = product.variations;
|
||||
const productCatId = getProductTypeParentId(product.product_type);
|
||||
const attrOrder = (variantsOrder as any)[Number(productCatId)];
|
||||
const attrOrder = Number(productCatId) === 245 ? (variantsOrder as any)[1] : (variantsOrder as any)[Number(productCatId)];
|
||||
const mainAttr = attrOrder[0];
|
||||
const getAtrrOrder = (attr: string) => attrOrder.indexOf(attr);
|
||||
const selectedVariant: MutableRefObject<{ variant: string; type: "archive" | "delete" }> = useRef({
|
||||
@ -155,10 +171,10 @@ const ProductVariants: React.FunctionComponent<ProductVariantsProps> = ({ produc
|
||||
const editPayload = `{
|
||||
status: "${variantStatus === "archived" ? "published" : "archived"}",
|
||||
}`;
|
||||
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await mutationItemsRequest({ apiRoute: "billboard/private-mutation", mutation: updateProductVariationItem(selectedVariant.current.variant, editPayload)});
|
||||
try {
|
||||
const result = await mutationItemsRequest({ apiRoute: "billboard/private-mutation", mutation: updateProductVariationItem(selectedVariant.current.variant, editPayload) });
|
||||
setUpdating(false);
|
||||
setDeleteModalOpen(false);
|
||||
setAlertOpen(true);
|
||||
@ -170,8 +186,8 @@ const ProductVariants: React.FunctionComponent<ProductVariantsProps> = ({ produc
|
||||
|
||||
const handleVariantDelete = async () => {
|
||||
setUpdating(true);
|
||||
try {
|
||||
const result = await mutationItemsRequest({ apiRoute: "billboard/private-mutation", mutation: deleteProductVariationItems, variables: { ids: [selectedVariant.current.variant] }});
|
||||
try {
|
||||
const result = await mutationItemsRequest({ apiRoute: "billboard/private-mutation", mutation: deleteProductVariationItems, variables: { ids: [selectedVariant.current.variant] } });
|
||||
setUpdating(false);
|
||||
setDeleteModalOpen(false);
|
||||
setAlertOpen(true);
|
||||
@ -228,7 +244,7 @@ const ProductVariants: React.FunctionComponent<ProductVariantsProps> = ({ produc
|
||||
// case "scent":
|
||||
// sortedVariants.sort((a, b) => (a.scent[0].scents_id.id - b.scent[0].scents_id.id));
|
||||
// break;
|
||||
|
||||
|
||||
// default:
|
||||
// break;
|
||||
// }
|
||||
@ -243,13 +259,13 @@ const ProductVariants: React.FunctionComponent<ProductVariantsProps> = ({ produc
|
||||
// useEffects
|
||||
const finalVariants = sortVariantsByMainAttr().filter(x => archiveIsVisible ? x.status === "archived" : x.status === "published");
|
||||
|
||||
|
||||
|
||||
// return
|
||||
return (
|
||||
<ProductSection
|
||||
title={translate("dashboard-product-page-variants-cta")}
|
||||
<ProductSection
|
||||
title={translate("dashboard-product-page-variants-cta")}
|
||||
icon={<BrushSolid className="inline-block size-3 lg:size-4 fill-current rtl:ml-1 ltr:mr-1" />}
|
||||
contentClass="!py-2 !px-0"
|
||||
contentClass="!py-2 !px-0"
|
||||
wrapperClass="max-xl:col-span-2"
|
||||
>
|
||||
<div className="block px-1 sm:px-2 mt-2">
|
||||
@ -272,24 +288,24 @@ const ProductVariants: React.FunctionComponent<ProductVariantsProps> = ({ produc
|
||||
{getAtrrOrder("packaging") < 2 && <span style={{ "--order": getAtrrOrder("packaging") } as React.CSSProperties} className={`${variantTable}`}>{translate("dashboard-product-page-variants-form-packagings")}</span>}
|
||||
{getAtrrOrder("scent") < 2 && <span style={{ "--order": getAtrrOrder("scent") } as React.CSSProperties} className={`${variantTable}`}>{translate("dashboard-product-page-variants-form-scents")}</span>}
|
||||
<span className={`inline-block text-xs/6 col-span-3 order-7`}>{translate("billboard-product-stock-count")}</span>
|
||||
<span className={`inline-block text-xs/6 col-span-2 invisible order-8`}>{}</span>
|
||||
<span className={`inline-block text-xs/6 col-span-2 invisible order-8`}>{ }</span>
|
||||
</div>
|
||||
<ul className={`grid grid-cols-1 gap-2 bg-gray-50/75 p-2 rounded-xl overflow-y-scroll hide-scrollbar max-h-80 mb-4 ${finalVariants.length < 4 ? "pb-24" : ""}`}>
|
||||
{finalVariants.map((x, i) => (
|
||||
<MainAttribute
|
||||
key={x.id}
|
||||
<MainAttribute
|
||||
key={x.id}
|
||||
index={i}
|
||||
totalVariants={finalVariants.length}
|
||||
locale={locale}
|
||||
locale={locale}
|
||||
router={router}
|
||||
variant={x}
|
||||
mainAttr={mainAttr}
|
||||
stockUnitType={getLocaleTr(product.stock_unit_type, locale).name}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
variant={x}
|
||||
mainAttr={mainAttr}
|
||||
stockUnitType={getLocaleTr(product.stock_unit_type, locale).name}
|
||||
onDelete={handleDelete}
|
||||
onArchive={handleArchive}
|
||||
/>
|
||||
))}
|
||||
{finalVariants.length === 0 &&
|
||||
{finalVariants.length === 0 &&
|
||||
<span className="p-2 bg-white text-xs/6 rounded-xl text-center">{translate("dashboard-product-page-variants-no-variants")}</span>
|
||||
}
|
||||
</ul>
|
||||
|
||||
@ -49,7 +49,7 @@ const Links: React.FunctionComponent<Links> = ({ }) => {
|
||||
{linksArray.map(x => (
|
||||
<Link href={x.link} key={x.id} className={`${(x.id === 1 && uData.data.policies.some((x: any) => x.policy.name.toLowerCase() === "private")) ? "" : ""} reactive-button flex flex-col items-center justify-center p-3 rounded-2xl aspect-square lg:cursor-pointer border-2 border-white shadow ${itemClass}`}>
|
||||
{x.icon}
|
||||
<span className={`block text-xs lg:text-sm pt-4 lg:pt-6 font-semibold text-current ${x.className}`}>{x.text}</span>
|
||||
<span className={`block text-xs lg:text-sm pt-4 lg:pt-6 font-semibold text-current capitalize ${x.className}`}>{x.text}</span>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@ -72,8 +72,8 @@ const GeneralLayout: React.FunctionComponent<GeneralLayout> = ({ children, bodyC
|
||||
|
||||
const { data: messagesData, error: messageFetchError, mutate } = useSWR(isUserDataAvailable ? [`${messagesDataQuery + contactListQuery}`, ""] : null, ([query, route]) => fetchPrivateData(route, query));
|
||||
|
||||
const { data: globalData, error: globalDataError } = useCachedGlobalData();
|
||||
// const { data: globalData, error: globalDataError } = process.env.NODE_ENV === 'production' ? useCachedGlobalData() : useSWRImmutable([globalDataQuery, ""], ([query, route]) => fetchPublicData(route, query));
|
||||
// const { data: globalData, error: globalDataError } = useCachedGlobalData();
|
||||
const { data: globalData, error: globalDataError } = process.env.NODE_ENV === 'production' ? useCachedGlobalData() : useSWRImmutable([globalDataQuery, ""], ([query, route]) => fetchPublicData(route, query));
|
||||
const loadingText = translate("general-loading");
|
||||
|
||||
if (messageFetchError) {
|
||||
|
||||
@ -45,6 +45,13 @@ const Header: React.FunctionComponent<Header> = ({ open, onClose }) => {
|
||||
if (pathname === "/dashboard/billboards/[id]/news") title = translate("news");
|
||||
if (pathname === "/dashboard/billboards/[id]/faqs") title = translate("faq");
|
||||
if (pathname === "/dashboard/billboards/[id]/reviews") title = translate("reviews");
|
||||
if (pathname === "/dashboard/billboards/[id]/products/[product_id]/service-type") title = translate("dashboard-billboard-create-service-type-title");
|
||||
if (asPath.includes("/service-type?id=")) title = translate("dashboard-billboard-edit-service-type-title");
|
||||
// business settings
|
||||
if (pathname === "/dashboard/billboards/[id]/business-settings") title = translate("dashboard-billboard-page-business-settings");
|
||||
if (pathname === "/dashboard/billboards/[id]/business-settings/service-providers") title = translate("dashboard-billboard-page-service-providers-title");
|
||||
if (pathname === "/dashboard/billboards/[id]/business-settings/service-providers/create") title = translate("dashboard-billboard-create-service-provider");
|
||||
// orders
|
||||
if (pathname === "/dashboard/billboards/[id]/orders") title = translate("dashboard-billboard-orders-title");
|
||||
if (pathname === "/dashboard/billboards/[id]/orders/[order_id]") title = translate("dashboard-order-page-title");
|
||||
if (pathname === "/dashboard/billboards/[id]/order-policies") title = translate("dashboard-billboard-cancel-return-page-title");
|
||||
|
||||
@ -33,7 +33,7 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ item, orderDetails, cur
|
||||
|
||||
return <li className="flex items-center justify-between w-full border-b border-dashed border-neutral-200/50 last:border-none py-4">
|
||||
<div className="flex items-center gap-4 sm:gap-6">
|
||||
<div className="relative shrink-0 ring-market-input rounded-full">
|
||||
<div className="relative shrink-0 rounded-full">
|
||||
{hasValue(gallery) ?
|
||||
<Image
|
||||
src={gallery.id}
|
||||
@ -42,7 +42,7 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ item, orderDetails, cur
|
||||
height={gallery.height}
|
||||
ar={[1 / 1, 1 / 1, 1 / 1, 1 / 1]}
|
||||
imageSizes={[250, 250, 250, 250]}
|
||||
className="block size-16 lg:size-24 rounded-full !object-contain p-2 bg-neutral-50"
|
||||
className="block size-16 lg:size-24 rounded-full !object-contain p-[6px] bg-neutral-50"
|
||||
figureClass={`block size-16 lg:size-24 rounded-full shrink-0`}
|
||||
/>
|
||||
:
|
||||
@ -58,7 +58,7 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ item, orderDetails, cur
|
||||
<div className="flex items-center text-xs text-text-gray pt-1 gap-2">
|
||||
{item.variations[0].color &&
|
||||
<>
|
||||
<span style={{ backgroundColor: `#${item.variations[0].color.hex_code}` } as React.CSSProperties} className="inline-block size-4 rounded-full"></span>
|
||||
<span style={{ backgroundColor: `#${item.variations[0].color.hex_code}` } as React.CSSProperties} className="inline-block size-4 rounded-full ring-1 ring-gray-100"></span>
|
||||
<span className="block text-xs text-text-color">{color}</span>
|
||||
</>
|
||||
}
|
||||
@ -75,8 +75,8 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ item, orderDetails, cur
|
||||
</div>
|
||||
</div>
|
||||
<div className="hidden sm:flex items-center flex-col gap-3">
|
||||
<span className="block text text-sm [direction:ltr]">{countAndPrice}</span>
|
||||
<span className="block text font-semibold py-1 px-2 rounded-lg bg-warning-bg text-warning-text">{totalPrice}</span>
|
||||
<span className="block text-sm [direction:ltr]">{countAndPrice}</span>
|
||||
<span className="block text-sm font-semibold py-1 px-2 rounded-lg bg-[#f1f5f9] text-[#121a2e]">{totalPrice}</span>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@ -83,7 +83,7 @@ const OrderSummary: React.FunctionComponent<OrderSummary> = ({ order, onPayment,
|
||||
</span>
|
||||
<div className="flex max-sm:flex-col sm:items-end justify-between max-sm:space-y-6">
|
||||
<div className="flex flex-col items-center shrink-0">
|
||||
<span className="flex items-center w-full text-lg sm:text-xl font-semibold pb-5">{`${translate("dashboard-user-orders-table-titles-order")} : # ${order.id}`}</span>
|
||||
<span className="flex items-center w-full text-lg sm:text-xl font-semibold pb-5">{`${translate("dashboard-user-orders-table-titles-order")} : # BH${order.billboard_id}${order.id}`}</span>
|
||||
{/* date */}
|
||||
<OrderDetailItem
|
||||
icon={<CalendarLinesSolid className={`${iconClass}`} />}
|
||||
|
||||
@ -40,7 +40,8 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ order }) => {
|
||||
const iconClass = "inline-block size-[14px] fill-cool-gray me-2";
|
||||
const thumbPic = order.products[0].billboard_products_id.variations[0].pics[0].variant_galleries_id.gallery[0].directus_files_id;
|
||||
const productName = getLocaleTr(order.products[0].billboard_products_id, locale).title;
|
||||
const orderId = `# ${order.id}`;
|
||||
const rawOrderId = `bh-${order.billboard_id}-${order.id}`;
|
||||
const orderId = `# BH${order.billboard_id}${order.id}`;
|
||||
const orderDate = mtd(new Date(order.date_created));
|
||||
const businessName = getLocaleTr(order.products[0].billboard_products_id.billboard[0].Billboards_id, locale).title;
|
||||
const isPaid = hasValue(order.payment_id);
|
||||
@ -111,7 +112,7 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ order }) => {
|
||||
value={orderDate}
|
||||
valueClass=""
|
||||
/>
|
||||
<Link href={`/dashboard/orders/${order.id}`} className={`${itemClass} w-fit shrink-0 md:hidden reactive-button justify-center text-xs bg-market-input text-market-title-light px-3 py-2 mt-3 rounded-lg`}>
|
||||
<Link href={`/dashboard/orders/${rawOrderId}`} className={`${itemClass} w-fit shrink-0 md:hidden reactive-button justify-center text-xs bg-market-input text-market-title-light px-3 py-2 mt-3 rounded-lg`}>
|
||||
{translate("dashboard-order-details-cta")}
|
||||
<ArrowLeftSolid className="inline-block size-[14px] fill-current ms-2" />
|
||||
</Link>
|
||||
@ -134,7 +135,7 @@ const OrderItem: React.FunctionComponent<OrderItem> = ({ order }) => {
|
||||
{/* total order amount */}
|
||||
<div className={`${itemClass} hidden 2xl:flex w-[10%] text-sm font-semibold`}>{`${orderCurrency}${orderTotal}`}</div>
|
||||
{/* see details */}
|
||||
<Link href={`/dashboard/orders/${order.id}`} className={`${itemClass} hidden md:flex md:w-[20%] 2xl:w-[15%] reactive-button justify-center text-xs bg-market-input text-market-title-light px-3 py-[6px] rounded-lg`}>
|
||||
<Link href={`/dashboard/orders/${rawOrderId}`} className={`${itemClass} hidden md:flex md:w-[20%] 2xl:w-[15%] reactive-button justify-center text-xs bg-market-input text-market-title-light px-3 py-[6px] rounded-lg`}>
|
||||
{translate("dashboard-order-details-cta")}
|
||||
</Link>
|
||||
</li>
|
||||
|
||||
@ -97,12 +97,12 @@ const Balance: React.FunctionComponent<Balance> = ({ balance }) => {
|
||||
open={selectionOpen}
|
||||
onClose={() => setSelectionOpen(false)}
|
||||
title={translate("ads-wallet-topup-amount-title")}
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] lg:max-w-[90vw] lg:max-h-[90vh] rounded-2xl"
|
||||
className="flex flex-col bg-white w-[90vw] h-auto max-h-[80vh] lg:w-auto lg:h-auto lg:min-w-[500px] max-w-[600px] lg:max-w-[90vw] lg:max-h-[90vh] rounded-2xl"
|
||||
childrenClass="lg:flierland-scrollbar"
|
||||
titleClass="text-lg"
|
||||
titleClass="text-sm lg:text-lg"
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center relative w-full max-sm:px-6 sm:w-4/5 max-w-[500px] mx-auto py-4">
|
||||
<span className="block bg-warning-bg text-warning-text text-xs/6 sm:text-sm/7 py-2 px-4 mt-4 rounded-xl text-center">{translate("ads-wallet-topup-guide")}</span>
|
||||
<div className="flex flex-col items-center justify-center relative w-full px-4 lg:px-8 max-w-[500px] mx-auto py-4">
|
||||
<span className="block bg-warning-bg text-warning-text text-xs/5 sm:text-sm/6 py-2 px-4 lg:mt-4 rounded-xl text-center first-letter:capitalize">{translate("ads-wallet-topup-guide")}</span>
|
||||
<div className="block w-full mt-4">
|
||||
{topupAmounts.map(x => (
|
||||
<RadioButton
|
||||
@ -113,18 +113,18 @@ const Balance: React.FunctionComponent<Balance> = ({ balance }) => {
|
||||
value={x.text}
|
||||
checked={paymentAmount === x.amount}
|
||||
onChange={() => setPaymentAmount(x.amount)}
|
||||
labelClass="market-radio-label justify-center px-4 py-4 border-b border-gray-100 last:border-none rounded-xl hover:bg-market-input"
|
||||
inputClass="market-radio-input right-4"
|
||||
titleClass="market-radio-title text-secondary-light"
|
||||
labelClass="market-radio-label justify-center px-4 py-2 sm:py-3 lg:py-4 border-b border-gray-100 last:border-none rounded-xl hover:bg-market-input"
|
||||
inputClass="market-radio-input start-4 lg:start-8"
|
||||
titleClass="text-secondary-light text-xs/5 lg:text-sm/6 ps-10"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<span className="block bg-success-bg text-success-text text-sm/7 sm:text-base/7 py-2 px-4 mt-6 sm:mt-4 rounded-xl text-center">{`${translate("ads-wallet-topup-selected-amount")} ${paymentAmount} ${translate("cad")}`}</span>
|
||||
<div className="flex flex-col space-y-3 w-full sm:px-4 py-4 mt-4">
|
||||
<span className="block bg-success-bg text-success-text text-sm/7 sm:text-base/7 first-letter:capitalize py-2 px-4 mt-4 lg:mt-6 rounded-xl text-center">{`${translate("ads-wallet-topup-selected-amount")} ${paymentAmount} ${translate("cad")}`}</span>
|
||||
<div className="flex flex-col space-y-3 w-full sm:px-4 pt-4 pb-2 mt-2 lg:mt-4">
|
||||
<Button
|
||||
type="button"
|
||||
text={translate("ads-table-payment-popup-gateway-cta")}
|
||||
className="w-full flex items-center justify-center bg-market-input text-market-title-light text-[13px] md:text-sm text-center hover:lg:bg-market-title-light shadow-none hover:lg:text-white py-3 px-4 md:py-3 md:px-5 rounded-xl"
|
||||
className="w-full flex items-center justify-center capitalize bg-market-input text-market-title-light text-[13px] md:text-sm text-center hover:lg:bg-market-title-light shadow-none hover:lg:text-white py-3 px-4 md:py-3 md:px-5 rounded-xl"
|
||||
onClick={handleTopUp}
|
||||
leftIcon={<CreditCardSolid className="size-4 md:size-5 fill-current ltr:mr-3 rtl:ml-3 ltr:md:mr-3 rtl:md:ml-3" />}
|
||||
/>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user