new features + bug fixes

This commit is contained in:
Deltora72 2026-04-28 15:56:05 +03:30
parent 177e54445c
commit a5f0faf242
128 changed files with 10098 additions and 3090 deletions

View File

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 82 KiB

View File

@ -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">

View File

@ -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"

View File

@ -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>
)}

View File

@ -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"

View File

@ -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;
}

View 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

View File

@ -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])

View File

@ -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>
)
}

View File

@ -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

View File

@ -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

View File

@ -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",

View File

@ -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;

View File

@ -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;

View File

@ -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>

View File

@ -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>

View 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

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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}

View File

@ -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

View File

@ -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 />

View File

@ -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

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
}
`

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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: [],
});
}

View File

@ -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
});
}
}

View File

@ -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;
}

View File

@ -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
})
}

View File

@ -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
});
}

View File

@ -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

View File

@ -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>
)
}

View File

@ -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

View File

@ -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>

View File

@ -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>
)
}

View File

@ -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}

View 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

View File

@ -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>
))}

View File

@ -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;
}

View File

@ -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>

View File

@ -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"}`}

View File

@ -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>
)

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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>
)
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View 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
}
}
`
}

View 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();
}

View 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

View File

@ -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>

View File

@ -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>

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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");

View File

@ -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}

View File

@ -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}

View File

@ -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}`}

View File

@ -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} />
</>

View File

@ -34,7 +34,7 @@ interface BatchVariationFormProps {
};
productId?: string;
mainAttr: string;
attrOrder: string;
attrOrder: string[];
fieldsData: {
size_types: string[];
sizes: Size[];

View File

@ -38,7 +38,7 @@ const ProductGalleryThumbs: React.FunctionComponent<ProductGalleryThumbsProps> =
return <>
<ImageUploader
maxPhotos={8}
maxPhotos={10}
photos={data.gallery}
onGalleryUpdate={setGalleryUpdates}
uploaderId="cover-photo"

View File

@ -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">

View File

@ -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}`}

View File

@ -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

View File

@ -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 (

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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) {

View File

@ -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");

View File

@ -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>
}

View File

@ -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}`} />}

View File

@ -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>

View File

@ -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