diff --git a/menuPreviewProxy.ts b/menuPreviewProxy.ts new file mode 100644 index 0000000..08a8a65 --- /dev/null +++ b/menuPreviewProxy.ts @@ -0,0 +1,75 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { ClientRequest, IncomingMessage as ProxyIncomingMessage } from "node:http"; +import type { ProxyOptions } from "vite"; +import { MENU_SITE_ORIGIN, isMenuSlugProxyPath, stripMenuPreviewCacheParam, toMenuSitePath } from "./src/config/menuPreview"; + +const stripFrameHeaders = (_proxyRes: ProxyIncomingMessage, _req: IncomingMessage, res: ServerResponse) => { + res.removeHeader("x-frame-options"); + res.removeHeader("content-security-policy"); +}; + +const stripPreviewPageHeaders = (_proxyRes: ProxyIncomingMessage, _req: IncomingMessage, res: ServerResponse) => { + stripFrameHeaders(_proxyRes, _req, res); + res.removeHeader("cache-control"); + res.removeHeader("etag"); + res.removeHeader("last-modified"); + res.setHeader("cache-control", "no-store, no-cache, must-revalidate"); + res.setHeader("pragma", "no-cache"); + res.setHeader("expires", "0"); +}; + +const menuAssetProxy = (): ProxyOptions => ({ + target: MENU_SITE_ORIGIN, + changeOrigin: true, + secure: true, + configure: (proxy) => { + proxy.on("proxyRes", stripFrameHeaders); + }, +}); + +export const createMenuPreviewProxy = (): Record => ({ + "/assets/fonts": menuAssetProxy(), + "/assets/images": menuAssetProxy(), + "/_next": menuAssetProxy(), + "/icons": menuAssetProxy(), +}); + +export const shouldProxyMenuSlugRequest = (url: string | undefined) => { + if (!url) return false; + + const pathname = url.split("?")[0] ?? ""; + if (pathname.startsWith("/@") || pathname.startsWith("/src/") || pathname.startsWith("/node_modules/")) { + return false; + } + + if ( + pathname.startsWith("/assets/fonts/") || + pathname.startsWith("/assets/images/") || + pathname.startsWith("/_next/") || + pathname.startsWith("/icons/") + ) { + return false; + } + + if (pathname.startsWith("/assets/")) { + return false; + } + + return isMenuSlugProxyPath(pathname); +}; + +export const getMenuSiteTargetUrl = (url: string) => { + const [pathname, search = ""] = url.split("?"); + const menuPath = toMenuSitePath(pathname ?? ""); + if (!menuPath) return null; + return `${MENU_SITE_ORIGIN}${menuPath}${stripMenuPreviewCacheParam(search)}`; +}; + +export const createMenuSlugProxyAgent = () => ({ + configure: (proxy: { on: (event: string, handler: (...args: unknown[]) => void) => void }) => { + proxy.on("proxyReq", (proxyReq: ClientRequest) => { + proxyReq.removeHeader("origin"); + }); + proxy.on("proxyRes", stripPreviewPageHeaders); + }, +}); diff --git a/nginx.conf b/nginx.conf index edad655..b73e383 100644 --- a/nginx.conf +++ b/nginx.conf @@ -5,17 +5,92 @@ server { root /usr/share/nginx/html; index index.html; - # SPA fallback - location / { + # Public menu site assets (Next.js) + location ^~ /_next/ { + proxy_pass https://dmenu.danakcorp.com/_next/; + proxy_ssl_server_name on; + proxy_set_header Host dmenu.danakcorp.com; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + } + + location ^~ /icons/ { + proxy_pass https://dmenu.danakcorp.com/icons/; + proxy_ssl_server_name on; + proxy_set_header Host dmenu.danakcorp.com; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + } + + location ^~ /assets/fonts/ { + proxy_pass https://dmenu.danakcorp.com/assets/fonts/; + proxy_ssl_server_name on; + proxy_set_header Host dmenu.danakcorp.com; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + } + + location ^~ /assets/images/ { + proxy_pass https://dmenu.danakcorp.com/assets/images/; + proxy_ssl_server_name on; + proxy_set_header Host dmenu.danakcorp.com; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + } + + # Vite build outputs to /assets by default + location ^~ /assets/ { + expires 1y; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + # Admin SPA routes + location ^~ /auth/ { try_files $uri $uri/ /index.html; add_header Cache-Control "no-store, no-cache, must-revalidate"; } - # Vite build outputs to /assets by default - location /assets/ { - expires 1y; - add_header Cache-Control "public, immutable"; - try_files $uri =404; + location = /dashboard { + try_files $uri /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + location = /setting { + try_files $uri /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + location = /statistics { + try_files $uri /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + location ~ ^/(foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications)/ { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; + } + + # Restaurant menu preview (same-origin iframe; strips X-Frame-Options from upstream) + location ~ ^/(?!auth|dashboard|setting|statistics|foods|orders|customers|schedule|payment_methods|discounts|announcements|reports|comments|roles|admins|shipment_methods|coupons|reviews|pagers|notifications|assets|icons|_next)([a-zA-Z0-9][a-zA-Z0-9_-]*)(/.*)?$ { + proxy_pass https://dmenu.danakcorp.com/$1$2$is_args$args; + proxy_ssl_server_name on; + proxy_set_header Host dmenu.danakcorp.com; + proxy_hide_header X-Frame-Options; + proxy_hide_header Content-Security-Policy; + proxy_hide_header Cache-Control; + proxy_hide_header ETag; + proxy_hide_header Last-Modified; + proxy_hide_header Expires; + add_header Cache-Control "no-store, no-cache, must-revalidate" always; + add_header Pragma "no-cache" always; + add_header Expires "0" always; + } + + # SPA fallback + location / { + try_files $uri $uri/ /index.html; + add_header Cache-Control "no-store, no-cache, must-revalidate"; } # optional: favicon (avoid noisy logs if missing) 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/config/menuPreview.ts b/src/config/menuPreview.ts new file mode 100644 index 0000000..9e9dc23 --- /dev/null +++ b/src/config/menuPreview.ts @@ -0,0 +1,65 @@ +export const MENU_SITE_ORIGIN = "https://dmenu.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", + "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) => `/${slug}`; + +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/components/DesignSettings.tsx b/src/pages/settings/components/DesignSettings.tsx index 40290dc..2cb76f7 100644 --- a/src/pages/settings/components/DesignSettings.tsx +++ b/src/pages/settings/components/DesignSettings.tsx @@ -1,3 +1,6 @@ +import { buildMenuPreviewUrl } from "@/config/menuPreview"; +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"; @@ -19,6 +22,28 @@ type BackgroundType = "pattern" | "custom"; const UPLOAD_BOX_CLASS = "w-[200px] h-[362px] rounded-2xl border border-dashed border-description"; +const PHONE_FRAME_WIDTH = 375; + +/** 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) => { @@ -45,6 +70,7 @@ const DesignSettings: FC = () => { 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(); @@ -52,6 +78,11 @@ const DesignSettings: FC = () => { 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(() => { @@ -127,6 +158,12 @@ const DesignSettings: FC = () => { setOverlayDarkness(0); }; + const handleSaveSuccess = useCallback(() => { + toast.success("تنظیمات طراحی با موفقیت ذخیره شد"); + refetch(); + setPreviewReloadKey(Date.now()); + }, [refetch]); + const saveCustomBackground = (bgUrl: string) => { const backgroundPayload: SetBackgroundType = { bgUrl, @@ -140,10 +177,7 @@ const DesignSettings: FC = () => { } setBackground(backgroundPayload, { - onSuccess: () => { - toast.success("تنظیمات طراحی با موفقیت ذخیره شد"); - refetch(); - }, + onSuccess: handleSaveSuccess, onError: (error: unknown) => { toast.error(extractErrorMessage(error as ErrorType, "خطا در ذخیره تنظیمات طراحی")); }, @@ -174,10 +208,7 @@ const DesignSettings: FC = () => { bgType: resolveBgType(backgroundType, selectedPatternId), }, { - onSuccess: () => { - toast.success("تنظیمات طراحی با موفقیت ذخیره شد"); - refetch(); - }, + onSuccess: handleSaveSuccess, onError: (error: unknown) => { toast.error(extractErrorMessage(error as ErrorType, "خطا در ذخیره تنظیمات طراحی")); }, @@ -225,8 +256,8 @@ const DesignSettings: FC = () => {
تنظیمات طراحی
-
-
+
+
@@ -322,6 +353,40 @@ const DesignSettings: FC = () => {
+ +
+
+ +
+ {menuPreviewUrl ? ( +