diff --git a/.dockerignore b/.dockerignore index 9010dc5..7e4ea4c 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,45 @@ +# ────────────────────────────────────────────────────────────────────────── +# Docker Build Ignore Configuration for Marriage WebApp +# ────────────────────────────────────────────────────────────────────────── + +# Build artifacts & dependencies node_modules .next +dist +out + +# Version control & git metadata .git .gitignore -README.md -.env* -npm-debug.log* + +# Environment files (injected at runtime/build via env_file / args) +.env +.env*.local +.env.development + +# Documentation, reports & scratch files +*.md +*.log +*.log.* +next-dev-*.log +next-dev-*.err.log +proxy-debug.log +scratch +docs +.playwright-mcp +.agents +.codex +.claude +.vscode +.idea + +# Tests +**/*.test.ts +**/*.test.tsx +**/*.spec.ts +**/*.spec.tsx +vitest.config.ts + +# Docker files (not needed in context) +docker-compose.yml +docker-compose.*.yml diff --git a/Dockerfile b/Dockerfile index 8b17faa..eef210c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,30 +1,60 @@ -FROM node:20-alpine AS base +# syntax=docker/dockerfile:1.7 -FROM base AS deps +# ────────────────────────────────────────────────────────────────────────── +# مرحلهٔ ۱ — base & deps: آماده‌سازی وابستگی‌ها با کش npm +# ────────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS deps RUN apk add --no-cache libc6-compat WORKDIR /app + COPY package.json package-lock.json* ./ -RUN npm ci +RUN npm install -FROM base AS builder +# ────────────────────────────────────────────────────────────────────────── +# مرحلهٔ ۲ — builder: ساخت خروجی بهینه‌شده standalone برای Next.js +# ────────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS builder WORKDIR /app + COPY --from=deps /app/node_modules ./node_modules COPY . . -ARG NEXT_PUBLIC_API_BASE_URL -ARG NEXT_PUBLIC_SECURITY_KEY + +# آرگومان‌های بیلد برای فرانت‌اند +ARG NEXT_PUBLIC_API_BASE_URL=https://habibapp.com +ARG NEXT_PUBLIC_SECURITY_KEY=t5yugymks5458fd4ghfg6h6fg ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL ENV NEXT_PUBLIC_SECURITY_KEY=$NEXT_PUBLIC_SECURITY_KEY +ENV NEXT_TELEMETRY_DISABLED=1 +ENV NODE_ENV=production + RUN npm run build -FROM base AS runner +# ────────────────────────────────────────────────────────────────────────── +# مرحلهٔ ۳ — runner: سرور مینیمال و امن standalone در محیط پروداکشن +# ────────────────────────────────────────────────────────────────────────── +FROM node:22-alpine AS runner WORKDIR /app + ENV NODE_ENV=production +ENV NEXT_TELEMETRY_DISABLED=1 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +# ایجاد کاربر غیر روت برای امنیت کانتینر RUN addgroup --system --gid 1001 nodejs && \ adduser --system --uid 1001 nextjs + +# کپی دارایی‌های استاتیک و خروجی standalone COPY --from=builder /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + USER nextjs + EXPOSE 3000 -ENV PORT=3000 HOSTNAME="0.0.0.0" + +# هلث‌چک داخلی و سبک کانتینر +HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \ + CMD wget -qO- http://localhost:3000/healthz || exit 1 + CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml index 7d3681f..a1b3e33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,3 +1,6 @@ +# docker-compose برای وب‌اپ ازدواج (Marriage WebApp) +# منبع واحد متغیرها در پروداکشن: فایل .env.production + services: web: build: @@ -5,19 +8,29 @@ services: dockerfile: Dockerfile args: NEXT_PUBLIC_API_BASE_URL: https://habibapp.com + NEXT_PUBLIC_SECURITY_KEY: t5yugymks5458fd4ghfg6h6fg + image: marriage-web:latest + container_name: marriage-web + restart: unless-stopped + + env_file: + - .env.production + ports: - "5561:3000" + environment: - NODE_ENV=production - NEXT_PUBLIC_API_BASE_URL=https://habibapp.com - NEXT_PUBLIC_SECURITY_KEY=t5yugymks5458fd4ghfg6h6fg - restart: unless-stopped + healthcheck: - test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"] + test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"] interval: 30s - timeout: 10s + timeout: 5s retries: 3 - start_period: 40s + start_period: 15s + networks: - najm_najm diff --git a/next.config.ts b/next.config.ts index 1401522..0d9f95f 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,8 @@ import path from "path"; const nextConfig: NextConfig = { output: "standalone", + poweredByHeader: false, + reactStrictMode: true, allowedDevOrigins: ["192.168.1.64"], turbopack: {}, devIndicators: false, @@ -10,9 +12,10 @@ const nextConfig: NextConfig = { // Compression compress: true, - // Image optimization + // Image optimization for ultra-fast WebView media loading images: { formats: ["image/avif", "image/webp"], + minimumCacheTTL: 31536000, remotePatterns: [ { protocol: "https", diff --git a/src/app/api/healthz/route.ts b/src/app/api/healthz/route.ts new file mode 100644 index 0000000..7000d07 --- /dev/null +++ b/src/app/api/healthz/route.ts @@ -0,0 +1,11 @@ +export const dynamic = "force-dynamic"; + +export async function GET() { + return new Response("ok", { + status: 200, + headers: { + "Content-Type": "text/plain", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); +} diff --git a/src/app/healthz/route.ts b/src/app/healthz/route.ts new file mode 100644 index 0000000..7000d07 --- /dev/null +++ b/src/app/healthz/route.ts @@ -0,0 +1,11 @@ +export const dynamic = "force-dynamic"; + +export async function GET() { + return new Response("ok", { + status: 200, + headers: { + "Content-Type": "text/plain", + "Cache-Control": "no-cache, no-store, must-revalidate", + }, + }); +} diff --git a/src/components/Componentes/button.tsx b/src/components/Componentes/button.tsx index 1f43d0b..0c9008c 100644 --- a/src/components/Componentes/button.tsx +++ b/src/components/Componentes/button.tsx @@ -142,7 +142,9 @@ export function Button({ aria-hidden="true" className={[ "size-5", - direction === "left" ? "rotate-180" : "", + direction === "left" + ? "rotate-180 rtl:rotate-0" + : "rotate-0 rtl:rotate-180", isOutlined ? "text-[#8B8B8B]" : "text-white", ] .filter(Boolean) diff --git a/src/components/Componentes/call-result-sheet.tsx b/src/components/Componentes/call-result-sheet.tsx index feacb1c..e675ac0 100644 --- a/src/components/Componentes/call-result-sheet.tsx +++ b/src/components/Componentes/call-result-sheet.tsx @@ -1,7 +1,7 @@ "use client"; import type { HTMLAttributes } from "react"; -import { useEffect, useId, useState } from "react"; +import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import { useI18n } from "@/translations/provider"; const EXIT_ANIMATION_MS = 220; @@ -25,39 +25,45 @@ export function CallResultSheet({ ...props }: CallResultSheetProps) { const { dictionary: t } = useI18n(); - const callResultOptions = [ - t["Not a good personal fit"], - t["No mutual interest"], - t["Different expectations"], - t["No connection felt"], - t["Location not suitable"], - t["Other reasons"], - ]; + const callResultOptions = useMemo( + () => [ + t["Not a good personal fit"], + t["No mutual interest"], + t["Different expectations"], + t["No connection felt"], + t["Location not suitable"], + t["Other reasons"], + ], + [t], + ); const groupId = useId(); const [isVisible, setIsVisible] = useState(true); - const [isEntering, setIsEntering] = useState(true); const [isClosing, setIsClosing] = useState(false); const [selectedReason, setSelectedReason] = useState( callResultOptions[0], ); + const isMountedRef = useRef(true); - const closeSheet = () => { + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + + const closeSheet = useCallback(() => { if (isClosing) { return; } setIsClosing(true); - }; - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - setIsEntering(false); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, []); + window.setTimeout(() => { + if (isMountedRef.current) { + setIsVisible(false); + } + onClose?.(); + }, EXIT_ANIMATION_MS); + }, [isClosing, onClose]); useEffect(() => { if (!isVisible) { @@ -76,21 +82,6 @@ export function CallResultSheet({ }; }, [isVisible]); - useEffect(() => { - if (!isClosing) { - return; - } - - const timeoutId = window.setTimeout(() => { - setIsVisible(false); - onClose?.(); - }, EXIT_ANIMATION_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [isClosing, onClose]); - if (!isVisible) { return null; } @@ -98,8 +89,8 @@ export function CallResultSheet({ return (
[ + t["Not a good personal fit"], + t["No mutual interest"], + t["Different expectations"], + t["No connection felt"], + t["Location not suitable"], + t["Other reasons"], + ], + [t], + ); const groupId = useId(); const [isVisible, setIsVisible] = useState(true); - const [isEntering, setIsEntering] = useState(true); const [isClosing, setIsClosing] = useState(false); const [selectedReasons, setSelectedReasons] = useState([]); const [reasonText, setReasonText] = useState(""); const textareaRef = useRef(null); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); useEffect(() => { const otherReason = options[options.length - 1]; @@ -53,23 +63,19 @@ export function DismissReasonSheet({ } }, [selectedReasons, options]); - const closeSheet = () => { + const closeSheet = useCallback(() => { if (isClosing) { return; } setIsClosing(true); - }; - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - setIsEntering(false); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, []); + window.setTimeout(() => { + if (isMountedRef.current) { + setIsVisible(false); + } + onClose?.(); + }, EXIT_ANIMATION_MS); + }, [isClosing, onClose]); useEffect(() => { if (!isVisible) { @@ -88,21 +94,6 @@ export function DismissReasonSheet({ }; }, [isVisible]); - useEffect(() => { - if (!isClosing) { - return; - } - - const timeoutId = window.setTimeout(() => { - setIsVisible(false); - onClose?.(); - }, EXIT_ANIMATION_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [isClosing, onClose]); - if (!isVisible) { return null; } @@ -110,8 +101,8 @@ export function DismissReasonSheet({ return (
{ - if (isClosing) { - return; - } + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const closeSheet = useCallback(() => { + if (isClosing) return; setIsClosing(true); - }; + window.setTimeout(() => { + if (isMountedRef.current) { + setIsVisible(false); + } + onClose?.(); + }, EXIT_ANIMATION_MS); + }, [isClosing, onClose]); const controls = { close: closeSheet }; const resolvedButtons = typeof buttons === "function" ? buttons(controls) : buttons; - useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - setIsEntering(false); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, []); - useEffect(() => { if (!isVisible) { return; @@ -71,21 +71,6 @@ export function FemaleConsentSheet({ }; }, [isVisible]); - useEffect(() => { - if (!isClosing) { - return; - } - - const timeoutId = window.setTimeout(() => { - setIsVisible(false); - onClose?.(); - }, EXIT_ANIMATION_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [isClosing, onClose]); - if (!isVisible) { return null; } @@ -93,8 +78,8 @@ export function FemaleConsentSheet({ return (
{ + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); // Outcome selection state: "ongoing" (Option A) or "canceled" (Option B) const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing"); @@ -56,22 +63,18 @@ export function FemaleOutcomeSheet({ } }, [selectedReasons, reasons]); - const closeSheet = () => { + const closeSheet = useCallback(() => { if (isClosing) { return; } setIsClosing(true); - }; - - useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - setIsEntering(false); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, []); + window.setTimeout(() => { + if (isMountedRef.current) { + setIsVisible(false); + } + onClose?.(); + }, EXIT_ANIMATION_MS); + }, [isClosing, onClose]); useEffect(() => { if (!isVisible) { @@ -90,21 +93,6 @@ export function FemaleOutcomeSheet({ }; }, [isVisible]); - useEffect(() => { - if (!isClosing) { - return; - } - - const timeoutId = window.setTimeout(() => { - setIsVisible(false); - onClose?.(); - }, EXIT_ANIMATION_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [isClosing, onClose]); - if (!isVisible) { return null; } @@ -117,8 +105,8 @@ export function FemaleOutcomeSheet({ return (
{ + isMountedRef.current = true; setMounted(true); + return () => { + isMountedRef.current = false; + }; }, []); + // When isOpen changes from outside, reset isClosing + useEffect(() => { + if (isOpen) { + setIsClosing(false); + } + }, [isOpen]); + const resolvedTitle = "Tips"; const resolvedDescription = description ?? @@ -45,29 +55,17 @@ export function HelpModal({ const closeSheet = useCallback(() => { if (isClosing) return; setIsClosing(true); - }, [isClosing]); - - // Sync with isOpen prop - useEffect(() => { - if (isOpen) { - setIsVisible(true); - setIsClosing(false); - setIsEntering(true); - } - }, [isOpen]); - - // Entry animation - useEffect(() => { - if (!isVisible) return; - const frameId = window.requestAnimationFrame(() => { - setIsEntering(false); - }); - return () => window.cancelAnimationFrame(frameId); - }, [isVisible]); + window.setTimeout(() => { + if (isMountedRef.current) { + setIsClosing(false); + } + onClose(); + }, EXIT_ANIMATION_MS); + }, [isClosing, onClose]); // Lock body scroll useEffect(() => { - if (!isVisible) return; + if (!isOpen || !mounted) return; const previousBodyOverflow = document.body.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow; document.body.style.overflow = "hidden"; @@ -76,25 +74,15 @@ export function HelpModal({ document.body.style.overflow = previousBodyOverflow; document.documentElement.style.overflow = previousHtmlOverflow; }; - }, [isVisible]); - - // Exit animation - useEffect(() => { - if (!isClosing) return; - const timeoutId = window.setTimeout(() => { - setIsVisible(false); - onClose(); - }, EXIT_ANIMATION_MS); - return () => window.clearTimeout(timeoutId); - }, [isClosing, onClose]); + }, [isOpen, mounted]); - if (!isVisible || !mounted) return null; + if (!isOpen || !mounted) return null; return createPortal(
diff --git a/src/components/Componentes/information-sheet.tsx b/src/components/Componentes/information-sheet.tsx index 89f4fb2..7f9ba91 100644 --- a/src/components/Componentes/information-sheet.tsx +++ b/src/components/Componentes/information-sheet.tsx @@ -2,7 +2,7 @@ import Image, { type StaticImageData } from "next/image"; import type { HTMLAttributes, ReactNode } from "react"; -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import Button from "./button"; import { LoadingSkeleton } from "./loading-skeleton"; import { useI18n } from "@/translations/provider"; @@ -145,16 +145,31 @@ export function InformationSheet({ locale === "he" || locale === "ks"; const [isVisible, setIsVisible] = useState(true); - const [isEntering, setIsEntering] = useState(true); const [isClosing, setIsClosing] = useState(false); + const isMountedRef = useRef(true); + + useEffect(() => { + isMountedRef.current = true; + return () => { + isMountedRef.current = false; + }; + }, []); + const resolvedIcon = resolveIcon(icon); - const closeSheet = () => { + const closeSheet = useCallback(() => { if (isClosing) { return; } setIsClosing(true); - }; + window.setTimeout(() => { + if (isMountedRef.current) { + setIsVisible(false); + } + onClose?.(); + }, EXIT_ANIMATION_MS); + }, [isClosing, onClose]); + const controls = { close: closeSheet }; const resolvedTitle = typeof title === "function" ? title(controls) : title; const resolvedButtons = @@ -166,16 +181,6 @@ export function InformationSheet({ buttons ); - useEffect(() => { - const frameId = window.requestAnimationFrame(() => { - setIsEntering(false); - }); - - return () => { - window.cancelAnimationFrame(frameId); - }; - }, []); - useEffect(() => { if (!isVisible) { return; @@ -193,21 +198,6 @@ export function InformationSheet({ }; }, [isVisible]); - useEffect(() => { - if (!isClosing) { - return; - } - - const timeoutId = window.setTimeout(() => { - setIsVisible(false); - onClose?.(); - }, EXIT_ANIMATION_MS); - - return () => { - window.clearTimeout(timeoutId); - }; - }, [isClosing, onClose]); - if (!isVisible) { return null; } @@ -215,8 +205,8 @@ export function InformationSheet({ return (