menu: UI enhancements in desktop sizes

- add product dialog
- enhance product page layout for desktop size
This commit is contained in:
2025-10-11 20:34:22 +03:00
parent c4d8c3f883
commit cbc64b6e38
13 changed files with 456 additions and 189 deletions

View File

@@ -4,7 +4,7 @@ import ProText from "components/ProText";
import { Dispatch, SetStateAction } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "redux/hooks";
import { Extra4, TheExtrasGroup } from "utils/types/appTypes";
import { TheExtrasGroup } from "utils/types/appTypes";
import styles from "../product.module.css";
export default function ExtraGroups({
@@ -89,7 +89,7 @@ export default function ExtraGroups({
<div className={styles.productContainer}>
<ProCheckboxGroups
options={group.extras.map((extra: Extra4) => ({
options={group.extras.map((extra: any) => ({
value: extra.id.toString(),
label: isRTL ? extra.name : extra.nameAR,
price: `+${extra.price}`,
@@ -99,7 +99,7 @@ export default function ExtraGroups({
// Check if the new selection would exceed the limit
if (values.length > group.limit) {
message.error(
`You can only select up to ${group.limit} option${group.limit > 1 ? "s" : ""} from this group.`
`You can only select up to ${group.limit} option${group.limit > 1 ? "s" : ""} from this group.`,
);
return;
}

View File

@@ -1,20 +1,18 @@
import { ShoppingCartOutlined } from "@ant-design/icons";
import { Button, Grid, message, Row } from "antd";
import { BottomSheet } from "pages/cart/components/specialRequest/BottomSheet.tsx";
import { Button, message, Row } from "antd";
import {
addItem,
selectCart,
updateSpecialRequest,
} from "features/order/orderSlice";
import { useState } from "react";
import useBreakPoint from "hooks/useBreakPoint";
import { BottomSheet } from "pages/cart/components/specialRequest/BottomSheet.tsx";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom";
import { useAppDispatch, useAppSelector } from "redux/hooks";
import { colors, ProBlack2 } from "ThemeConstants";
import { Product } from "utils/types/appTypes";
const { useBreakpoint } = Grid;
export default function ProductFooter({
product,
isValid = true,
@@ -30,14 +28,25 @@ export default function ProductFooter({
selectedGroups: string[];
quantity: number;
}) {
const { id } = useParams();
const { t } = useTranslation();
const dispatch = useAppDispatch();
const { themeName } = useAppSelector((state) => state.theme);
const { specialRequest } = useAppSelector(selectCart);
const [isSpecialRequestOpen, setIsSpecialRequestOpen] = useState(false);
const { xs } = useBreakpoint();
const navigate = useNavigate();
const { isMobile, isDesktop } = useBreakPoint();
// Check if product has any customization options
const hasCustomizationOptions = useMemo(() => {
const hasVariants = product?.variants?.length > 0;
const hasExtras = product.extras.length > 0;
const hasExtraGroups = product?.theExtrasGroups?.length > 0;
return hasVariants || hasExtras || hasExtraGroups;
}, [
product?.variants?.length,
product.extras.length,
product?.theExtrasGroups?.length,
]);
const handleAddToCart = () => {
if (!isValid) {
@@ -75,17 +84,27 @@ export default function ProductFooter({
setIsSpecialRequestOpen(false);
};
//
return (
<>
<Row
style={{
width: "100%",
padding: "16px 16px 0",
position: "fixed",
bottom: 0,
left: 0,
...(!isDesktop
? {
position: "fixed",
bottom: 0,
left: 0,
width: "100%",
backgroundColor: themeName === "light" ? "white" : ProBlack2,
}
: {
position: "absolute",
bottom: 0,
left: 0,
width: hasCustomizationOptions ? "50%" : "100%",
}),
height: "10vh",
backgroundColor: themeName === "light" ? "white" : ProBlack2,
}}
>
<div style={{ width: "100%" }}>
@@ -103,8 +122,8 @@ export default function ProductFooter({
disabled={!isValid}
style={{
flex: 1,
height: xs ? "48px" : "48px",
fontSize: xs ? "1rem" : "16px",
height: isMobile ? "48px" : "48px",
fontSize: isMobile ? "1rem" : "16px",
transition: "all 0.3s ease",
width: "100%",
borderRadius: 888,
@@ -116,12 +135,12 @@ export default function ProductFooter({
cursor: isValid ? "pointer" : "not-allowed",
}}
onMouseEnter={(e) => {
if (!xs && isValid) {
if (!isMobile && isValid) {
e.currentTarget.style.transform = "translateY(-2px)";
}
}}
onMouseLeave={(e) => {
if (!xs) {
if (!isMobile) {
e.currentTarget.style.transform = "translateY(0)";
}
}}
@@ -132,12 +151,14 @@ export default function ProductFooter({
</div>
</Row>
<BottomSheet
isOpen={isSpecialRequestOpen}
onClose={handleSpecialRequestClose}
initialValue={specialRequest}
onSave={handleSpecialRequestSave}
/>
{!isDesktop && (
<BottomSheet
isOpen={isSpecialRequestOpen}
onClose={handleSpecialRequestClose}
initialValue={specialRequest}
onSave={handleSpecialRequestSave}
/>
)}
</>
);
}

View File

@@ -1,6 +1,7 @@
import { Divider } from "antd";
import ProRatioGroups from "components/ProRatioGroups/ProRatioGroups";
import ProText from "components/ProText";
import useBreakPoint from "hooks/useBreakPoint";
import { Dispatch, SetStateAction, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAppSelector } from "redux/hooks";
@@ -18,6 +19,7 @@ export default function Variants({
}) {
const { isRTL } = useAppSelector((state) => state.locale);
const { t } = useTranslation();
const { isDesktop } = useBreakPoint();
// Determine variant levels based on options array length
const variantLevels = useMemo(() => {
@@ -108,7 +110,7 @@ export default function Variants({
<>
{variantsList?.length > 0 && variantLevels.length > 0 && (
<>
<Divider style={{ margin: "0" }} />
{!isDesktop && <Divider style={{ margin: "0" }} />}
<div>
<div
style={{

View File

@@ -7,8 +7,8 @@ import { default_image } from "utils/constants";
// import PageTransition from "components/PageTransition/PageTransition";
import { Space } from "antd";
import ArabicPrice from "components/ArabicPrice";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import useBreakPoint from "hooks/useBreakPoint";
import { useCallback, useMemo, useState } from "react";
import { colors } from "ThemeConstants";
import { Product } from "utils/types/appTypes";
import BackButton from "../menu/components/BackButton";
@@ -20,11 +20,11 @@ import styles from "./product.module.css";
export default function ProductDetailPage() {
const { isRTL } = useAppSelector((state) => state.locale);
const { t } = useTranslation();
const { isDesktop } = useBreakPoint();
const [quantity, setQuantity] = useState(1);
const product = JSON.parse(
localStorage.getItem("product") || "null"
localStorage.getItem("product") || "null",
) as Product;
// State for variant selections
@@ -45,7 +45,7 @@ export default function ProductDetailPage() {
if (!product?.variants || product.variants.length === 0) return [];
const maxOptionsLength = Math.max(
...product.variants.map((v) => v.options.length)
...product.variants.map((v) => v.options.length),
);
const levels: Array<{
level: number;
@@ -64,7 +64,7 @@ export default function ProductDetailPage() {
product.variants
.filter((v) => v.options[i]?.option === optionKey)
.map((v) => v.options[i]?.value)
.filter(Boolean)
.filter(Boolean),
),
];
@@ -84,7 +84,7 @@ export default function ProductDetailPage() {
}, [product?.variants]);
// Get the final selected variant ID (the variant that matches all current selections)
const getFinalSelectedVariantId = () => {
const getFinalSelectedVariantId = useCallback(() => {
if (!product?.variants || Object.keys(selectedVariants).length === 0)
return "";
@@ -98,26 +98,26 @@ export default function ProductDetailPage() {
// Convert to string only if defined, otherwise return empty string
return matchingVariant?.id?.toString() || "";
};
}, [product?.variants, selectedVariants]);
const getExtras = () => {
const getExtras = useCallback(() => {
const finalSelectedVariantId = getFinalSelectedVariantId();
if (finalSelectedVariantId) {
const variant = product?.variants?.find(
(variant) => variant.id === Number(finalSelectedVariantId)
(variant) => variant.id === Number(finalSelectedVariantId),
);
if (variant?.extras?.length && variant.extras.length > 0) {
return variant.extras;
}
}
return product?.extras;
};
}, [product?.variants, product?.extras, getFinalSelectedVariantId]);
// Validation function to check if all required selections are made
const validateRequiredSelections = () => {
// Check if all variant levels are selected
const allVariantsSelected = variantLevels.every(
(level) => selectedVariants[level.level] !== undefined
(level) => selectedVariants[level.level] !== undefined,
);
// Check if all required extra groups are satisfied
@@ -128,7 +128,7 @@ export default function ProductDetailPage() {
return selectedCount === group.limit;
}
return true; // Optional groups are always satisfied
}
},
);
return allVariantsSelected && allRequiredExtraGroupsSatisfied;
@@ -136,6 +136,21 @@ export default function ProductDetailPage() {
const isValid = validateRequiredSelections();
// Check if product has any customization options
const hasCustomizationOptions = useMemo(() => {
const hasVariants =
product?.variants?.length > 0 && variantLevels.length > 0;
const hasExtras = getExtras().length > 0;
const hasExtraGroups = product?.theExtrasGroups?.length > 0;
return hasVariants || hasExtras || hasExtraGroups;
}, [
product?.variants,
product?.theExtrasGroups,
variantLevels.length,
getExtras,
]);
if (!product) {
return (
<div style={{ padding: "40px", textAlign: "center" }}>
@@ -152,141 +167,166 @@ export default function ProductDetailPage() {
scrollbarWidth: "none",
}}
>
<div
style={{
position: "absolute",
zIndex: 999,
left: 20,
top: 70,
backgroundColor: "#FFF",
borderRadius: "50%",
boxShadow: "0 2px 8px rgba(0, 0, 0, 0.1)",
}}
>
<BackButton />
</div>
<div
style={{
overflow: "hidden",
transition: "all 0.3s ease",
}}
>
<ImageWithFallback
src={product?.image}
fallbackSrc={default_image}
height={240}
style={{
width: "100vw",
objectFit: "cover",
transition: "transform 0.3s ease",
}}
loadingContainerStyle={{ borderRadius: 0, width: "100vw" }}
/>
</div>
{!isDesktop && (
<div className={styles.backButtonContainer}>
<BackButton />
</div>
)}
<div className={styles.productDetailContainer}>
<div
style={{
display: "grid",
gridTemplateColumns: "1fr", // isMobile ? "1fr" : "1fr 1fr",
gap: 5,
}}
className={
isDesktop && hasCustomizationOptions
? styles.desktopLayout
: isDesktop && !hasCustomizationOptions
? styles.desktopLayoutFullWidth
: styles.mobileLayout
}
>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "space-between",
}}
>
<div
{/* Left Column - Product Info & Image */}
<div className={isDesktop ? styles.leftColumn : styles.fullWidth}>
<div className={styles.productImageSection}>
<ImageWithFallback
key={`product-${product?.id}-${product?.image}`}
src={product?.image}
fallbackSrc={default_image}
height={isDesktop ? 300 : 240}
width={"100%"}
style={{
display: "flex",
flexDirection: "column",
justifyContent: "space-between",
gap: "0.5rem",
marginTop: 16,
width: "100%",
objectFit: "cover",
}}
>
<ProText strong style={{ fontSize: "1.25rem" }}>
{isRTL ? product?.nameOther : product?.name}{" "}
</ProText>
{product?.description && (
loadingContainerStyle={{
width: "100%",
}}
/>
</div>
<div className={styles.productInfoSection}>
<div className={styles.productHeader}>
<div className={styles.productDetails}>
<ProText
type="secondary"
style={{ fontSize: "1rem", lineHeight: "1.25rem" }}
strong
style={{ fontSize: isDesktop ? "1.5rem" : "1.25rem" }}
>
{isRTL ? product?.descriptionAR : product?.description}
{isRTL ? product?.nameOther : product?.name}
</ProText>
)}
<ArabicPrice
price={product?.price}
style={{ fontSize: "1rem", color: colors.primary }}
/>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "start",
gap: 20,
...(isRTL ? { marginRight: -5 } : {}),
}} // Item Description Icons
>
<ItemDescriptionIcons />
<br />
{product?.description && (
<ProText
type="secondary"
style={{
lineClamp: 1,
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: 1,
overflow: "hidden",
textOverflow: "ellipsis",
wordWrap: "break-word",
overflowWrap: "break-word",
lineHeight: "1.4",
maxHeight: "2.8em",
fontWeight: "500",
letterSpacing: "0.01em",
fontSize: "1rem",
}}
>
{isRTL ? product?.descriptionAR : product?.description}
</ProText>
)}
<ArabicPrice
price={product?.price}
style={{
fontSize: isDesktop ? "1.2rem" : "1rem",
color: colors.primary,
marginTop: "12px",
}}
/>
<div
style={{
display: "flex",
flexDirection: "row",
justifyContent: "start",
gap: 20,
marginTop: "16px",
...(isRTL ? { marginRight: -5 } : {}),
}}
>
<ItemDescriptionIcons className={styles.itemDescriptionIcons} />
</div>
</div>
</div>
<div
style={{
display: "flex",
flexDirection: "column",
justifyContent: "end",
}}
>
<ActionsButtons
quantity={quantity}
setQuantity={(quantity) => setQuantity(quantity)}
max={100}
min={1}
/>
{isDesktop && (
<div className={styles.quantitySection}>
<ActionsButtons
quantity={quantity}
setQuantity={(quantity) => setQuantity(quantity)}
max={100}
min={1}
/>
</div>
)}
</div>
</div>
{product?.variants?.length > 0 && variantLevels.length > 0 && (
<Variants
selectedVariants={selectedVariants}
setSelectedVariants={setSelectedVariants}
variantsList={product?.variants}
/>
)}
{getExtras().length > 0 && (
<Extra
extrasList={getExtras()}
{isDesktop && (
<ProductFooter
product={product}
isValid={isValid}
variantId={getFinalSelectedVariantId()}
selectedExtras={selectedExtras}
setSelectedExtras={setSelectedExtras}
selectedGroups={Object.values(selectedExtrasByGroup).flat()}
quantity={quantity}
/>
)}
</div>
{product.theExtrasGroups.length > 0 && (
<ExtraGroups
groupsList={product.theExtrasGroups}
selectedExtrasByGroup={selectedExtrasByGroup}
setSelectedExtrasByGroup={setSelectedExtrasByGroup}
/>
)}
</Space>
{/* Right Column - Variants, Extras, and Extra Groups */}
{hasCustomizationOptions && (
<div className={isDesktop ? styles.rightColumn : styles.fullWidth}>
<Space
direction="vertical"
size="middle"
style={{ width: "100%", padding: "0 1rem" }}
>
{product?.variants?.length > 0 && variantLevels.length > 0 && (
<Variants
selectedVariants={selectedVariants}
setSelectedVariants={setSelectedVariants}
variantsList={product?.variants}
/>
)}
{getExtras().length > 0 && (
<Extra
extrasList={getExtras()}
selectedExtras={selectedExtras}
setSelectedExtras={setSelectedExtras}
/>
)}
{product.theExtrasGroups.length > 0 && (
<ExtraGroups
groupsList={product.theExtrasGroups}
selectedExtrasByGroup={selectedExtrasByGroup}
setSelectedExtrasByGroup={setSelectedExtrasByGroup}
/>
)}
</Space>
</div>
)}
</div>
<ProductFooter
product={product}
isValid={isValid}
variantId={getFinalSelectedVariantId()}
selectedExtras={selectedExtras}
selectedGroups={Object.values(selectedExtrasByGroup).flat()}
quantity={quantity}
/>
{!isDesktop && (
<ProductFooter
product={product}
isValid={isValid}
variantId={getFinalSelectedVariantId()}
selectedExtras={selectedExtras}
selectedGroups={Object.values(selectedExtrasByGroup).flat()}
quantity={quantity}
/>
)}
</div>
</div>
);

View File

@@ -1,7 +1,7 @@
.productDetailContainer {
overflow: auto;
scrollbar-width: none;
padding: 0 16px !important;
height: 100%;
}
.productContainer :global(.ant-radio-wrapper:last-child) {
@@ -152,8 +152,135 @@
color: var(--primary2);
}
/* Desktop Layout Styles */
.desktopLayout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 32px;
max-width: 1200px;
margin: 0 auto;
padding: 24px 0;
}
.desktopLayoutFullWidth {
display: grid;
grid-template-columns: 1fr;
gap: 32px;
padding: 24px 0 0 0;
}
.mobileLayout {
display: block;
width: 100%;
}
.leftColumn {
display: flex;
flex-direction: column;
gap: 24px;
}
.rightColumn {
display: flex;
flex-direction: column;
gap: 24px;
padding: 16px;
background: rgba(0, 0, 0, 0.01);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.06);
overflow: auto;
scrollbar-width: none;
}
.fullWidth {
width: 100%;
}
.productImageSection {
position: relative;
border-radius: 0px;
overflow: hidden;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
transition:
transform 0.3s ease,
box-shadow 0.3s ease;
}
.productImageSection:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
}
.productInfoSection {
padding: 0 1rem;
}
.productHeader {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 16px;
margin-bottom: 24px;
}
.productDetails {
flex: 1;
}
.quantitySection {
display: flex;
align-items: center;
justify-content: flex-end;
position: relative;
top: 5px
}
/* Dark mode desktop styles */
:global(.darkApp) .productImageSection {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
}
:global(.darkApp) .productImageSection:hover {
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.4);
}
:global(.darkApp) .rightColumn {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.08);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.desktopLayout {
grid-template-columns: 1fr;
gap: 16px;
padding: 16px 0;
}
.desktopLayoutFullWidth {
grid-template-columns: 1fr;
gap: 16px;
padding: 16px 0;
max-width: 100%;
}
.rightColumn {
padding: 0;
background: transparent;
border: none;
gap: 16px;
}
.productHeader {
flex-direction: column;
align-items: stretch;
gap: 16px;
}
.quantitySection {
justify-content: center;
}
.descriptionSection {
padding: 16px 16px 0 16px;
}
@@ -167,3 +294,17 @@
line-height: 1.6;
}
}
.backButtonContainer {
position: absolute;
z-index: 999;
left: 20px;
top: 70px;
background-color: #fff;
border-radius: 50%;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
:global(.darkApp) .itemDescriptionIcons path {
fill: #ffffff !important;
}