diff --git a/src/assets/images/iphone_camera.png b/src/assets/images/iphone_camera.png new file mode 100644 index 0000000..7660b10 Binary files /dev/null and b/src/assets/images/iphone_camera.png differ diff --git a/src/assets/images/iphonr.png b/src/assets/images/iphonr.png new file mode 100644 index 0000000..e5524dd Binary files /dev/null and b/src/assets/images/iphonr.png differ diff --git a/src/assets/images/pattern/1.svg b/src/assets/images/pattern/1.svg new file mode 100644 index 0000000..dbbbfe7 --- /dev/null +++ b/src/assets/images/pattern/1.svg @@ -0,0 +1,469 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/pattern/2.svg b/src/assets/images/pattern/2.svg new file mode 100644 index 0000000..b34efd2 --- /dev/null +++ b/src/assets/images/pattern/2.svg @@ -0,0 +1,150 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/pattern/3.svg b/src/assets/images/pattern/3.svg new file mode 100644 index 0000000..8829725 --- /dev/null +++ b/src/assets/images/pattern/3.svg @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/pattern/4.svg b/src/assets/images/pattern/4.svg new file mode 100644 index 0000000..c8ac17c --- /dev/null +++ b/src/assets/images/pattern/4.svg @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/pattern/5.svg b/src/assets/images/pattern/5.svg new file mode 100644 index 0000000..aa35f9a --- /dev/null +++ b/src/assets/images/pattern/5.svg @@ -0,0 +1,181 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/assets/images/pattern/6.svg b/src/assets/images/pattern/6.svg new file mode 100644 index 0000000..9ee23ad --- /dev/null +++ b/src/assets/images/pattern/6.svg @@ -0,0 +1,354 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/config/menuPreview.ts b/src/config/menuPreview.ts new file mode 100644 index 0000000..0b39e81 --- /dev/null +++ b/src/config/menuPreview.ts @@ -0,0 +1,70 @@ +export const MENU_SITE_ORIGIN = "https://dkala.danakcorp.com"; + +/** Admin routes whose first segment must not be proxied to the public menu site. */ +export const MENU_PREVIEW_RESERVED_SEGMENTS = new Set([ + "auth", + "dashboard", + "setting", + "statistics", + "schedule", + "payment_methods", + "foods", + "orders", + "customers", + "discounts", + "announcements", + "reports", + "comments", + "roles", + "admins", + "shipment_methods", + "coupons", + "reviews", + "pagers", + "notifications", + "sliders", + "wallet", + "assets", + "menu-preview", +]); + +export const isMenuPreviewProxyPath = (pathname: string) => { + if (!pathname.startsWith("/menu-preview/")) return false; + const rest = pathname.slice("/menu-preview".length); + return rest.length > 0; +}; + +export const isMenuSlugProxyPath = (pathname: string) => { + const [firstSegment] = pathname.split("/").filter(Boolean); + if (!firstSegment) return false; + return !MENU_PREVIEW_RESERVED_SEGMENTS.has(firstSegment); +}; + +export const toMenuPreviewProxyPath = (slug: string) => { + const normalizedOrigin = MENU_SITE_ORIGIN.replace(/\/+$/, ""); + const normalizedSlug = slug.replace(/^\/+/, ""); + return `${normalizedOrigin}/${normalizedSlug}`; +}; + +export const MENU_PREVIEW_CACHE_PARAM = "_preview"; + +export const buildMenuPreviewUrl = (slug: string, cacheKey: number) => `${toMenuPreviewProxyPath(slug)}?${MENU_PREVIEW_CACHE_PARAM}=${cacheKey}`; + +export const stripMenuPreviewCacheParam = (search: string) => { + const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search); + params.delete(MENU_PREVIEW_CACHE_PARAM); + const query = params.toString(); + return query ? `?${query}` : ""; +}; + +export const toMenuSitePath = (pathname: string) => { + if (isMenuPreviewProxyPath(pathname)) { + return pathname.replace(/^\/menu-preview/, "") || "/"; + } + + if (isMenuSlugProxyPath(pathname)) { + return pathname; + } + + return null; +}; diff --git a/src/pages/settings/Setting.tsx b/src/pages/settings/Setting.tsx index 174836e..0c124aa 100644 --- a/src/pages/settings/Setting.tsx +++ b/src/pages/settings/Setting.tsx @@ -1,56 +1,56 @@ -import Tabs from '@/components/Tabs' -import { Money2, NotificationStatus, Setting4 } from 'iconsax-react' -import { useState, type FC } from 'react' -import { SettingTabEnum } from './enum/Enum' -import GeneralSettings from './GeneralSettings' -import ScoreSetting from './components/ScoreSetting' -import NotificationsList from '../notifications/List' +import Tabs from "@/components/Tabs"; +import { Brush, Money2, NotificationStatus, Setting4 } from "iconsax-react"; +import { useState, type FC } from "react"; +import NotificationsList from "../notifications/List"; +import DesignSettings from "./components/DesignSettings"; +import ScoreSetting from "./components/ScoreSetting"; +import { SettingTabEnum } from "./enum/Enum"; +import GeneralSettings from "./GeneralSettings"; const Setting: FC = () => { + const [activeTab, setActiveTab] = useState(SettingTabEnum.GENERAL); - const [activeTab, setActiveTab] = useState(SettingTabEnum.GENERAL) + return ( +
+

تنظیمات

- return ( -
-

تنظیمات

+
+ , + label: "عمومی", + value: SettingTabEnum.GENERAL, + }, + { + icon: , + label: "تنظیمات امتیاز", + value: SettingTabEnum.FINANCIAL, + }, + { + icon: , + label: "تنظیمات اعلان", + value: SettingTabEnum.NOTIFICATION, + }, + { + icon: , + label: "تنظیمات طراحی", + value: SettingTabEnum.BACKGROUND, + }, + ]} + onChange={(value: string) => setActiveTab(value as SettingTabEnum)} + active={activeTab} + /> +
-
- , - label: 'عمومی', - value: SettingTabEnum.GENERAL, - }, - { - icon: , - label: 'تنظیمات امتیاز', - value: SettingTabEnum.FINANCIAL, - }, - { - icon: , - label: 'تنظیمات اعلان', - value: SettingTabEnum.NOTIFICATION, - }, - ]} - onChange={(value: string) => setActiveTab(value as SettingTabEnum)} - active={activeTab} - /> -
+
+ {activeTab === SettingTabEnum.GENERAL && } + {activeTab === SettingTabEnum.FINANCIAL && } + {activeTab === SettingTabEnum.NOTIFICATION && } + {activeTab === SettingTabEnum.BACKGROUND && } +
+
+ ); +}; -
- { - activeTab === SettingTabEnum.GENERAL && - } - { - activeTab === SettingTabEnum.FINANCIAL && - } - { - activeTab === SettingTabEnum.NOTIFICATION && - } -
-
- ) -} - -export default Setting \ No newline at end of file +export default Setting; diff --git a/src/pages/settings/components/DesignSettings.tsx b/src/pages/settings/components/DesignSettings.tsx new file mode 100644 index 0000000..fba74b4 --- /dev/null +++ b/src/pages/settings/components/DesignSettings.tsx @@ -0,0 +1,392 @@ +import iphoneCamera from "@/assets/images/iphone_camera.png"; +import iphoneFrame from "@/assets/images/iphonr.png"; +import Button from "@/components/Button"; +import ColorPicker from "@/components/ColorPicker"; +import Radio from "@/components/Radio"; +import { extractErrorMessage } from "@/config/func"; +import { buildMenuPreviewUrl } from "@/config/menuPreview"; +import type { ErrorType } from "@/helpers/types"; +import { clx } from "@/helpers/utils"; +import DesignSlider from "@/pages/settings/components/DesignSlider"; +import { getBackgroundPatternUrl, PATTERN_BACKGROUND_OPACITY, useBackgroundPatterns } from "@/pages/settings/hooks/usePatterns"; +import { useGetBackgrounds, useGetRestaurant, useSetBackground, useUpdateRestaurant } from "@/pages/settings/hooks/useSettingData"; +import type { BackgroundItemType, SetBackgroundType } from "@/pages/settings/types/Types"; +import { useSingleUpload } from "@/pages/uploader/hooks/useUploaderData"; +import { DocumentUpload, Gallery, TickCircle, Trash } from "iconsax-react"; +import { useCallback, useEffect, useMemo, useState, type FC } from "react"; +import { useDropzone } from "react-dropzone"; +import { toast } from "react-toastify"; + +const COLOR_APPLY_DELAY_MS = 500; +type BackgroundType = "pattern" | "custom"; + +const UPLOAD_BOX_CLASS = "w-[200px] h-[362px] rounded-2xl border border-dashed border-description"; + +const PHONE_FRAME_WIDTH = 375; +const PHONE_PREVIEW_SCALE = 0.8; + +/** Measured from `iphonr.png` (839×1759) — white screen rect inside the bezel. */ +const PHONE_FRAME_IMAGE = { width: 839, height: 1759 } as const; +const PHONE_SCREEN_RECT = { left: 33, top: 36, right: 806, bottom: 1756, radius: 89 } as const; + +const toFramePercent = (value: number, axis: "width" | "height") => `${(value / PHONE_FRAME_IMAGE[axis]) * 100}%`; + +const PHONE_SCREEN_STYLE = { + top: toFramePercent(PHONE_SCREEN_RECT.top, "height"), + left: toFramePercent(PHONE_SCREEN_RECT.left, "width"), + right: toFramePercent(PHONE_FRAME_IMAGE.width - 1 - PHONE_SCREEN_RECT.right, "width"), + bottom: toFramePercent(PHONE_FRAME_IMAGE.height - 1 - PHONE_SCREEN_RECT.bottom, "height"), + borderRadius: (PHONE_SCREEN_RECT.radius / PHONE_FRAME_IMAGE.width) * PHONE_FRAME_WIDTH, +} as const; + +const PHONE_CAMERA_STYLE = { + top: toFramePercent(18, "height"), + width: toFramePercent(178, "width"), +} as const; + +const getPatternBgUrl = (item: BackgroundItemType) => item.url ?? item.bgUrl ?? ""; + +const normalizeBgUrl = (url: string) => { + if (!url) return ""; + if (url.startsWith("http")) return url; + return `${import.meta.env.VITE_BASE_URL}${url.startsWith("/") ? url : `/${url}`}`; +}; + +const resolveBgType = (type: BackgroundType, selectedPatternId: string | null): SetBackgroundType["bgType"] => { + if (type === "custom") return "custom"; + if (selectedPatternId === "0") return "color"; + return "pattern"; +}; + +const DesignSettings: FC = () => { + const [menuColor, setMenuColor] = useState("#000000"); + const [appliedColor, setAppliedColor] = useState("#000000"); + const [selectedPatternId, setSelectedPatternId] = useState(null); + const [backgroundType, setBackgroundType] = useState("pattern"); + const [customImage, setCustomImage] = useState(null); + const [customImagePreview, setCustomImagePreview] = useState(null); + const [existingBgUrl, setExistingBgUrl] = useState(null); + const [imageOpacity, setImageOpacity] = useState(100); + const [imageBlur, setImageBlur] = useState(0); + const [overlayDarkness, setOverlayDarkness] = useState(0); + const [isInitialized, setIsInitialized] = useState(false); + const [previewReloadKey, setPreviewReloadKey] = useState(0); + const { data: backgrounds } = useGetBackgrounds(); + const { mutate: setBackground, isPending: isSavingBackground } = useSetBackground(); + const { mutate: updateRestaurant, isPending: isUpdatingRestaurant } = useUpdateRestaurant(); + const { mutate: singleUpload, isPending: isUploadingImage } = useSingleUpload(); + const { patterns, isLoading: isPatternsLoading, isError: isPatternsError } = useBackgroundPatterns(appliedColor); + const { data: restaurant, refetch } = useGetRestaurant(); + const backgroundItems = useMemo(() => backgrounds?.data ?? [], [backgrounds]); + const menuPreviewUrl = useMemo(() => { + const slug = restaurant?.data?.slug; + if (!slug) return ""; + return buildMenuPreviewUrl(slug, previewReloadKey); + }, [restaurant?.data?.slug, previewReloadKey]); + const isSaving = isSavingBackground || isUploadingImage || isUpdatingRestaurant; + + useEffect(() => { + if (!restaurant?.data || isInitialized) return; + + const restaurantData = restaurant.data; + const savedBgUrl = restaurantData.bgUrl; + + if (savedBgUrl && backgroundItems.length === 0) return; + + const color = restaurantData.menuColor || "#000000"; + setMenuColor(color); + setAppliedColor(color); + + const savedBgType = restaurantData.bgType; + + if (savedBgType === "color" || (!savedBgUrl && savedBgType !== "custom" && savedBgType !== "image")) { + setBackgroundType("pattern"); + setSelectedPatternId("0"); + } else if (savedBgUrl) { + const matchingPattern = backgroundItems.find((item) => getBackgroundPatternUrl(item) === normalizeBgUrl(savedBgUrl) || getPatternBgUrl(item) === savedBgUrl); + + if (matchingPattern && savedBgType !== "custom" && savedBgType !== "image") { + setBackgroundType("pattern"); + setSelectedPatternId(matchingPattern.id); + } else { + setBackgroundType("custom"); + setExistingBgUrl(savedBgUrl); + setCustomImagePreview(normalizeBgUrl(savedBgUrl)); + setImageOpacity(Number(restaurantData.bgOpacity) || 100); + setImageBlur(Number(restaurantData.bgBlur) || 0); + setOverlayDarkness(Number(restaurantData.bgOverlay) || 0); + } + } else { + setSelectedPatternId("0"); + } + + setIsInitialized(true); + }, [restaurant, backgroundItems, isInitialized]); + + useEffect(() => { + const timer = setTimeout(() => setAppliedColor(menuColor), COLOR_APPLY_DELAY_MS); + return () => clearTimeout(timer); + }, [menuColor]); + + useEffect(() => { + if (!customImage) return; + + const previewUrl = URL.createObjectURL(customImage); + setCustomImagePreview(previewUrl); + + return () => URL.revokeObjectURL(previewUrl); + }, [customImage]); + + const onDrop = useCallback((acceptedFiles: File[]) => { + if (acceptedFiles[0]) { + setCustomImage(acceptedFiles[0]); + } + }, []); + + const { getRootProps, getInputProps } = useDropzone({ + onDrop, + accept: { "image/*": [] }, + multiple: false, + }); + + const handleRemoveImage = () => { + setCustomImage(null); + setExistingBgUrl(null); + setCustomImagePreview(null); + setImageOpacity(100); + setImageBlur(0); + setOverlayDarkness(0); + }; + + const handleSaveSuccess = useCallback(() => { + toast.success("تنظیمات طراحی با موفقیت ذخیره شد"); + refetch(); + setPreviewReloadKey(Date.now()); + }, [refetch]); + + const saveCustomBackground = (bgUrl: string) => { + const backgroundPayload: SetBackgroundType = { + bgUrl, + bgType: "custom", + bgBlur: String(imageBlur ?? 0), + bgOverlay: String(overlayDarkness ?? 0), + }; + + if (imageOpacity) { + backgroundPayload.bgOpacity = String(imageOpacity); + } + + setBackground(backgroundPayload, { + onSuccess: handleSaveSuccess, + onError: (error: unknown) => { + toast.error(extractErrorMessage(error as ErrorType, "خطا در ذخیره تنظیمات طراحی")); + }, + }); + }; + + const handleApplyChanges = () => { + updateRestaurant( + { menuColor }, + { + onSuccess: () => { + if (backgroundType === "pattern") { + let bgUrl = ""; + + if (selectedPatternId !== "0") { + const selectedItem = backgroundItems.find((item) => item.id === selectedPatternId); + bgUrl = selectedItem ? getPatternBgUrl(selectedItem) : ""; + + if (!bgUrl) { + toast.error("لطفاً یک پترن انتخاب کنید"); + return; + } + } + + setBackground( + { + bgUrl, + bgType: resolveBgType(backgroundType, selectedPatternId), + }, + { + onSuccess: handleSaveSuccess, + onError: (error: unknown) => { + toast.error(extractErrorMessage(error as ErrorType, "خطا در ذخیره تنظیمات طراحی")); + }, + }, + ); + return; + } + + if (customImage) { + singleUpload(customImage, { + onSuccess: (response) => { + const bgUrl = response?.data?.url ?? ""; + + if (!bgUrl) { + toast.error("خطا در آپلود تصویر"); + return; + } + + saveCustomBackground(bgUrl); + }, + onError: (error: ErrorType) => { + toast.error(extractErrorMessage(error, "خطا در آپلود تصویر")); + }, + }); + return; + } + + if (existingBgUrl) { + saveCustomBackground(existingBgUrl); + return; + } + + toast.error("لطفاً تصویر پس‌زمینه را آپلود کنید"); + }, + onError: (error: unknown) => { + toast.error(extractErrorMessage(error as ErrorType, "خطا در ذخیره تنظیمات طراحی")); + }, + }, + ); + }; + + const isApplyDisabled = isSaving || (backgroundType === "pattern" ? !selectedPatternId : !customImage && !existingBgUrl); + + return ( +
+
تنظیمات طراحی
+ +
+
+ + +
+
+ setBackgroundType(value as BackgroundType)} /> +
انتخاب پس زمینه
+
+ + {backgroundType === "pattern" && ( +
+ {isPatternsLoading && Array.from({ length: 6 }).map((_, index) =>
)} + {!isPatternsLoading && isPatternsError &&
خطا در بارگذاری پترن‌ها
} + {!isPatternsLoading && !isPatternsError && patterns.length === 0 &&
پترنی یافت نشد
} + + {patterns.map((pattern) => ( + + ))} +
+ )} +
+ +
+
+ setBackgroundType(value as BackgroundType)} /> +
تصویر پس زمینه دلخواه
+
+ + {backgroundType === "custom" && ( +
+ {!customImagePreview ? ( +
+ + +
تصویر مورد نظر را آپلود کنید
+
+ +
آپلود
+
+
+ ) : ( +
+
+ پس‌زمینه دلخواه + {overlayDarkness > 0 &&
} + +
+ +
+
تنظیمات
+ + + +
+
+ )} +
+ )} +
+
+ +
+
+ +
+
+
+ +
+ {menuPreviewUrl ? ( +