From b5653906dc285ba8842d370b15a44ca04cb4a972 Mon Sep 17 00:00:00 2001 From: hamid zarghami Date: Sun, 21 Jun 2026 12:58:29 +0330 Subject: [PATCH] glass mode --- .../(Main)/components/CategoryScroll.tsx | 3 +- .../[name]/(Main)/components/MenuIndex.tsx | 7 +- src/app/[name]/RestaurantLayoutClient.tsx | 11 +- src/app/globals.css | 2 +- src/components/background/AppBackground.tsx | 85 +-------- .../background/PatternBackgroundProvider.tsx | 126 +++++++++++++ src/components/input/SearchBox.tsx | 8 +- .../listview/CategoryItemRenderer.tsx | 3 +- .../listview/CategorySmallItemRenderer.tsx | 3 +- src/components/listview/MenuItemRenderer.tsx | 6 +- src/components/menu/SideMenu.tsx | 3 +- src/components/navigation/BottomNavBar.tsx | 175 +++++++----------- src/components/navigation/bottomNavShape.ts | 20 ++ .../wrapper/ClientMenuRouteWrapper.tsx | 3 +- src/lib/helpers/backgroundUtils.ts | 10 +- src/lib/styles/glassSurface.ts | 22 +++ src/styles/glass.css | 49 +++++ 17 files changed, 325 insertions(+), 211 deletions(-) create mode 100644 src/components/background/PatternBackgroundProvider.tsx create mode 100644 src/components/navigation/bottomNavShape.ts create mode 100644 src/lib/styles/glassSurface.ts create mode 100644 src/styles/glass.css diff --git a/src/app/[name]/(Main)/components/CategoryScroll.tsx b/src/app/[name]/(Main)/components/CategoryScroll.tsx index 4bcca65..c6465ee 100644 --- a/src/app/[name]/(Main)/components/CategoryScroll.tsx +++ b/src/app/[name]/(Main)/components/CategoryScroll.tsx @@ -6,6 +6,7 @@ import CategorySmallItemRenderer from "@/components/listview/CategorySmallItemRe import HorizontalScrollView from "@/components/listview/HorizontalScrollView"; import clsx from "clsx"; import { usePathname } from "next/navigation"; +import { GLASS_SURFACE_SELECTED } from "@/lib/styles/glassSurface"; /** TODO: remove when backend sends avatarRenderMode */ const COLORED_SVG_RESTAURANT_SLUGS = new Set(["havasone"]); @@ -110,7 +111,7 @@ const CategoryScroll = ({ categories, selectedCategory, onSelect, variant = "lar const isSelected = item.id === selectedCategory; return ( - + {item.title} diff --git a/src/app/[name]/(Main)/components/MenuIndex.tsx b/src/app/[name]/(Main)/components/MenuIndex.tsx index b226c44..44304b8 100644 --- a/src/app/[name]/(Main)/components/MenuIndex.tsx +++ b/src/app/[name]/(Main)/components/MenuIndex.tsx @@ -12,6 +12,7 @@ import useToggle from "@/hooks/helpers/useToggle"; import clsx from "clsx"; import { motion } from "framer-motion"; import { Candle2 } from "iconsax-react"; +import { glassSurfaceFlat } from "@/lib/styles/glassSurface"; import { useQueryState } from "next-usequerystate"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; @@ -175,11 +176,11 @@ const MenuIndex = () => {
{selectedCategory === "0" ? "" : categories.find((c) => c.id === selectedCategory)?.title || ""}
- - @@ -196,7 +197,7 @@ const MenuIndex = () => { opacity: smallCategoriesVisible ? 1 : 0, }} transition={{ duration: 0.1 }} - className={clsx("fixed left-0 z-10 top-0 px-4 pt-16 bg-[#F4F5F9CC] dark:bg-background/70 backdrop-blur-[44px] right-0 xl:pr-72 xl:pt-20", !smallCategoriesVisible && "pointer-events-none")} + className={clsx(glassSurfaceFlat('fixed left-0 z-10 top-0 px-4 pt-16 right-0 xl:pr-72 xl:pt-20 border-x-0 border-t-0 rounded-none'), !smallCategoriesVisible && "pointer-events-none")} > diff --git a/src/app/[name]/RestaurantLayoutClient.tsx b/src/app/[name]/RestaurantLayoutClient.tsx index ce64dbc..ec27e37 100644 --- a/src/app/[name]/RestaurantLayoutClient.tsx +++ b/src/app/[name]/RestaurantLayoutClient.tsx @@ -6,6 +6,7 @@ import CartChecker from "@/components/CartChecker"; import RestaurantHeadManager from "@/components/RestaurantHeadManager"; import RestaurantNotFoundGuard from "@/components/RestaurantNotFoundGuard"; import PreferenceWrapper from "@/components/wrapper/PreferenceWrapper"; +import { PatternBackgroundProvider } from "@/components/background/PatternBackgroundProvider"; type Props = { children: React.ReactNode; @@ -16,10 +17,12 @@ export default function RestaurantLayoutClient({ children }: Props) { <> - - - {children} - + + + + {children} + + diff --git a/src/app/globals.css b/src/app/globals.css index fa3270b..750181f 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "../../public/assets/css/fonts.css"; @import "tw-animate-css"; +@import "../styles/glass.css"; /* @custom-variant dark (&:is(.dark *)); */ @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @@ -400,7 +401,6 @@ html[data-theme="dark"] { html[data-pattern-bg="true"] #root { position: relative; z-index: 1; - isolation: isolate; } html[data-pattern-bg="true"] [data-slot="dialog-content"] { diff --git a/src/components/background/AppBackground.tsx b/src/components/background/AppBackground.tsx index 1778b55..484ece8 100644 --- a/src/components/background/AppBackground.tsx +++ b/src/components/background/AppBackground.tsx @@ -1,87 +1,17 @@ "use client"; -import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData"; -import { - applyPatternCssVariables, - clearPatternCssVariables, - colorizeSvg, - hexToRgba, - isPatternBackground, - PATTERN_BACKGROUND_OPACITY, -} from "@/lib/helpers/backgroundUtils"; -import { useParams } from "next/navigation"; -import { useEffect, useRef, useState } from "react"; +import { usePatternBackground } from "@/components/background/PatternBackgroundProvider"; +import { useEffect, useState } from "react"; import { createPortal } from "react-dom"; -const DEFAULT_MENU_COLOR = "#1E3A8A"; - export default function AppBackground() { - const { name: restaurantSlug } = useParams<{ name: string }>(); - const { data: aboutData } = useGetAbout(); - const restaurant = aboutData?.data; + const { isPattern, backgroundStyle } = usePatternBackground(); const [mounted, setMounted] = useState(false); - const [patternImageUrl, setPatternImageUrl] = useState(null); - const blobUrlRef = useRef(null); - - const isPattern = restaurant ? isPatternBackground(restaurant) : false; - const menuColor = restaurant?.menuColor || DEFAULT_MENU_COLOR; - const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY); useEffect(() => { setMounted(true); }, []); - useEffect(() => { - const revokeBlobUrl = () => { - if (blobUrlRef.current) { - URL.revokeObjectURL(blobUrlRef.current); - blobUrlRef.current = null; - } - setPatternImageUrl(null); - }; - - if (!isPattern || !restaurant?.bgUrl || !restaurantSlug) { - clearPatternCssVariables(); - revokeBlobUrl(); - return; - } - - applyPatternCssVariables(menuColor); - - let cancelled = false; - const proxyUrl = `/${restaurantSlug}/api/proxy-svg?url=${encodeURIComponent(restaurant.bgUrl)}`; - - fetch(proxyUrl) - .then((response) => { - if (!response.ok) { - throw new Error("Failed to fetch background pattern"); - } - return response.text(); - }) - .then((svg) => { - if (cancelled) return; - - revokeBlobUrl(); - const blob = new Blob([colorizeSvg(svg, menuColor)], { - type: "image/svg+xml;charset=utf-8", - }); - const blobUrl = URL.createObjectURL(blob); - blobUrlRef.current = blobUrl; - setPatternImageUrl(blobUrl); - }) - .catch(() => { - if (!cancelled) { - revokeBlobUrl(); - } - }); - - return () => { - cancelled = true; - clearPatternCssVariables(); - revokeBlobUrl(); - }; - }, [isPattern, menuColor, restaurant?.bgUrl, restaurantSlug]); - if (!mounted || !isPattern) { return null; } @@ -92,14 +22,7 @@ export default function AppBackground() { className="fixed inset-0 pointer-events-none" style={{ zIndex: 0, - backgroundColor: baseTint, - ...(patternImageUrl - ? { - backgroundImage: `url("${patternImageUrl}")`, - backgroundRepeat: "repeat", - backgroundSize: "auto", - } - : {}), + ...backgroundStyle, }} />, document.body, diff --git a/src/components/background/PatternBackgroundProvider.tsx b/src/components/background/PatternBackgroundProvider.tsx new file mode 100644 index 0000000..dbec80f --- /dev/null +++ b/src/components/background/PatternBackgroundProvider.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useGetAbout } from "@/app/[name]/(Main)/about/hooks/useAboutData"; +import { + applyPatternCssVariables, + clearPatternCssVariables, + colorizeSvg, + hexToRgba, + isPatternBackground, + PATTERN_BACKGROUND_OPACITY, +} from "@/lib/helpers/backgroundUtils"; +import { useParams } from "next/navigation"; +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + type CSSProperties, + type ReactNode, +} from "react"; + +const DEFAULT_MENU_COLOR = "#1E3A8A"; + +type PatternBackgroundContextValue = { + isPattern: boolean; + backgroundStyle: CSSProperties; +}; + +const PatternBackgroundContext = createContext({ + isPattern: false, + backgroundStyle: {}, +}); + +export function PatternBackgroundProvider({ children }: { children: ReactNode }) { + const { name: restaurantSlug } = useParams<{ name: string }>(); + const { data: aboutData } = useGetAbout(); + const restaurant = aboutData?.data; + const [patternImageUrl, setPatternImageUrl] = useState(null); + const blobUrlRef = useRef(null); + + const isPattern = restaurant ? isPatternBackground(restaurant) : false; + const menuColor = restaurant?.menuColor || DEFAULT_MENU_COLOR; + const baseTint = hexToRgba(menuColor, PATTERN_BACKGROUND_OPACITY); + + useEffect(() => { + const revokeBlobUrl = () => { + if (blobUrlRef.current) { + URL.revokeObjectURL(blobUrlRef.current); + blobUrlRef.current = null; + } + setPatternImageUrl(null); + }; + + if (!isPattern || !restaurant?.bgUrl || !restaurantSlug) { + clearPatternCssVariables(); + revokeBlobUrl(); + return; + } + + applyPatternCssVariables(menuColor); + + let cancelled = false; + const proxyUrl = `/${restaurantSlug}/api/proxy-svg?url=${encodeURIComponent(restaurant.bgUrl)}`; + + fetch(proxyUrl) + .then((response) => { + if (!response.ok) { + throw new Error("Failed to fetch background pattern"); + } + return response.text(); + }) + .then((svg) => { + if (cancelled) return; + + revokeBlobUrl(); + const blob = new Blob([colorizeSvg(svg, menuColor)], { + type: "image/svg+xml;charset=utf-8", + }); + const blobUrl = URL.createObjectURL(blob); + blobUrlRef.current = blobUrl; + setPatternImageUrl(blobUrl); + }) + .catch(() => { + if (!cancelled) { + revokeBlobUrl(); + } + }); + + return () => { + cancelled = true; + clearPatternCssVariables(); + revokeBlobUrl(); + }; + }, [isPattern, menuColor, restaurant?.bgUrl, restaurantSlug]); + + const backgroundStyle = useMemo( + () => ({ + backgroundColor: baseTint, + ...(patternImageUrl + ? { + backgroundImage: `url("${patternImageUrl}")`, + backgroundRepeat: "repeat", + backgroundSize: "auto", + } + : {}), + }), + [baseTint, patternImageUrl], + ); + + const value = useMemo( + () => ({ isPattern, backgroundStyle }), + [backgroundStyle, isPattern], + ); + + return ( + + {children} + + ); +} + +export function usePatternBackground() { + return useContext(PatternBackgroundContext); +} diff --git a/src/components/input/SearchBox.tsx b/src/components/input/SearchBox.tsx index c242a68..f9c54a6 100644 --- a/src/components/input/SearchBox.tsx +++ b/src/components/input/SearchBox.tsx @@ -2,7 +2,7 @@ import React from 'react' import SearchIcon from '../icons/SearchIcon' -import clsx from 'clsx' +import { glassSurfaceFlat } from '@/lib/styles/glassSurface' interface SearchboxProps extends Omit, 'results'> { @@ -19,9 +19,9 @@ export default function SearchBox({ }: SearchboxProps) { return (
diff --git a/src/components/listview/CategoryItemRenderer.tsx b/src/components/listview/CategoryItemRenderer.tsx index afa2190..db1cf14 100644 --- a/src/components/listview/CategoryItemRenderer.tsx +++ b/src/components/listview/CategoryItemRenderer.tsx @@ -1,5 +1,6 @@ 'use client'; +import { glassSurface } from '@/lib/styles/glassSurface'; import React from 'react'; type Props = { @@ -8,7 +9,7 @@ type Props = { function CategoryItemRenderer({ children, ...rest }: Props) { return ( -
+
+
+ className={glassSurface( + rest.className, + '!pb-4 py-4 transition-all duration-200 ease-out w-full rounded-3xl px-4 flex items-center justify-between gap-4', + )}> {children}
diff --git a/src/components/menu/SideMenu.tsx b/src/components/menu/SideMenu.tsx index cae2e4c..3ab33f1 100644 --- a/src/components/menu/SideMenu.tsx +++ b/src/components/menu/SideMenu.tsx @@ -19,6 +19,7 @@ import { useGetAbout } from '@/app/[name]/(Main)/about/hooks/useAboutData'; import Image from 'next/image'; import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { toast, toastLoginRequired } from '../Toast'; +import { glassSurface } from '@/lib/styles/glassSurface'; type MenuItemType = { href: string | undefined; @@ -344,7 +345,7 @@ function SideMenu({ menuState, toggleMenuState, nightModeState, togglenightModeS diff --git a/src/components/navigation/BottomNavBar.tsx b/src/components/navigation/BottomNavBar.tsx index aafa1a7..e645b73 100644 --- a/src/components/navigation/BottomNavBar.tsx +++ b/src/components/navigation/BottomNavBar.tsx @@ -13,6 +13,8 @@ import { useTranslation } from 'react-i18next'; import { useCart } from '@/app/[name]/(Main)/cart/hook/useCart'; import { Building } from 'iconsax-react'; import clsx from 'clsx'; +import { glassSurfaceNav } from '@/lib/styles/glassSurface'; +import { getBottomNavMaskStyle } from './bottomNavShape'; import { useGetProfile } from '@/app/[name]/(Profile)/profile/hooks/userProfileData'; import { toastLoginRequired } from '../Toast'; @@ -37,6 +39,8 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) { }); const { items } = useCart(); + const navMaskStyle = React.useMemo(() => getBottomNavMaskStyle(), []); + const cartItemsCount = React.useMemo(() => { return Object.values(items).reduce((total, item) => { return total + (item?.quantity || 0); @@ -56,119 +60,72 @@ function BottomNavBar({ onPagerClick }: BottomNavBarProps) { return (
- + + - - - - - - - - - - - - - - - {/* Badge Layer - خارج از SVG - دقیقاً مثل foreignObject */} {cartItemsCount > 0 && (
`; + const maskUrl = `url("data:image/svg+xml,${encodeURIComponent(maskSvg)}")`; + + return { + WebkitMaskImage: maskUrl, + maskImage: maskUrl, + WebkitMaskSize: "100% 100%", + maskSize: "100% 100%", + WebkitMaskRepeat: "no-repeat", + maskRepeat: "no-repeat", + }; +} diff --git a/src/components/wrapper/ClientMenuRouteWrapper.tsx b/src/components/wrapper/ClientMenuRouteWrapper.tsx index 8157be9..7ff3f96 100644 --- a/src/components/wrapper/ClientMenuRouteWrapper.tsx +++ b/src/components/wrapper/ClientMenuRouteWrapper.tsx @@ -9,6 +9,7 @@ import { usePathname, useRouter } from 'next/navigation'; import usePreference from '@/hooks/helpers/usePreference'; import PagerModal from '../pager/PagerModal'; import clsx from 'clsx'; +import { glassSurface } from '@/lib/styles/glassSurface'; type Props = {} & React.AllHTMLAttributes @@ -54,7 +55,7 @@ function ClientMenuRouteWrapper({ children }: Props) { return (
-
+
Boolean(settings.bgUrl) && !isCustomImageBackground(settings); -export const colorizeSvg = (svg: string, color: string): string => { +export const colorizeSvg = ( + svg: string, + color: string, + vectorOpacity: number = PATTERN_VECTOR_OPACITY, +): string => { let result = svg.replace(/fill="(?!black|none)[^"]+"/g, `fill="${color}"`); result = result.replace( /fill-opacity="[^"]+"/g, - `fill-opacity="${PATTERN_VECTOR_OPACITY}"`, + `fill-opacity="${vectorOpacity}"`, ); result = result.replace( /(<(?:path|circle|rect|ellipse|polygon)[^>]*fill="(?!black|none)[^"]+")(?![^>]*fill-opacity)/g, - `$1 fill-opacity="${PATTERN_VECTOR_OPACITY}"`, + `$1 fill-opacity="${vectorOpacity}"`, ); return result; }; diff --git a/src/lib/styles/glassSurface.ts b/src/lib/styles/glassSurface.ts new file mode 100644 index 0000000..3bd53d0 --- /dev/null +++ b/src/lib/styles/glassSurface.ts @@ -0,0 +1,22 @@ +import clsx, { type ClassValue } from "clsx"; + +export const GLASS_SURFACE = "glass-surface"; +export const GLASS_SURFACE_FLAT = "glass-surface--flat"; +export const GLASS_SURFACE_SELECTED = "glass-surface--selected"; +export const GLASS_SURFACE_NAV = "glass-surface-nav"; + +export function glassSurfaceNav(...extra: ClassValue[]) { + return clsx(GLASS_SURFACE_NAV, extra); +} + +export function glassSurface(...extra: ClassValue[]) { + return clsx(GLASS_SURFACE, extra); +} + +export function glassSurfaceFlat(...extra: ClassValue[]) { + return clsx(GLASS_SURFACE, GLASS_SURFACE_FLAT, extra); +} + +export function glassSurfaceSelected(...extra: ClassValue[]) { + return clsx(GLASS_SURFACE, GLASS_SURFACE_SELECTED, extra); +} diff --git a/src/styles/glass.css b/src/styles/glass.css new file mode 100644 index 0000000..ad55309 --- /dev/null +++ b/src/styles/glass.css @@ -0,0 +1,49 @@ +/* Glass surface — Figma: white 60%, border, shadow, backdrop blur */ + +.glass-surface { + background-color: rgba(255, 255, 255, 0.6); + border: 1px solid rgba(255, 255, 255, 0.8); + -webkit-backdrop-filter: blur(3px); + backdrop-filter: blur(3px); + box-shadow: 0px 2px 16px 0px rgba(0, 0, 0, 0.08); +} + +html[data-pattern-bg="true"] .glass-surface { + -webkit-backdrop-filter: blur(1px); + backdrop-filter: blur(1px); +} + +html[data-theme="dark"] .glass-surface { + background-color: color-mix(in oklch, var(--container) 60%, transparent); + border-color: rgba(255, 255, 255, 0.2); +} + +.glass-surface--flat { + box-shadow: none; +} + +.glass-surface--selected { + background-color: rgba(255, 255, 255, 0.85) !important; +} + +html[data-theme="dark"] .glass-surface--selected { + background-color: color-mix(in oklch, var(--container) 85%, transparent) !important; +} + +.glass-surface-nav { + background-color: rgba(255, 255, 255, 0.32); + border: 1px solid rgba(255, 255, 255, 0.8); + -webkit-backdrop-filter: blur(8px); + backdrop-filter: blur(8px); + box-shadow: 0px -2px 14px 0px rgba(0, 0, 0, 0.15); +} + +html[data-pattern-bg="true"] .glass-surface-nav { + -webkit-backdrop-filter: blur(4px); + backdrop-filter: blur(4px); +} + +html[data-theme="dark"] .glass-surface-nav { + background-color: color-mix(in oklch, var(--container) 32%, transparent); + border-color: rgba(255, 255, 255, 0.2); +}