mortezaei 1 week ago
parent
commit
a7a79c63cf
  1. 44
      .dockerignore
  2. 46
      Dockerfile
  3. 21
      docker-compose.yml
  4. 5
      next.config.ts
  5. 11
      src/app/api/healthz/route.ts
  6. 11
      src/app/healthz/route.ts
  7. 4
      src/components/Componentes/button.tsx
  8. 61
      src/components/Componentes/call-result-sheet.tsx
  9. 61
      src/components/Componentes/dismiss-reason-sheet.tsx
  10. 57
      src/components/Componentes/female-consent-sheet.tsx
  11. 54
      src/components/Componentes/female-outcome-sheet.tsx
  12. 64
      src/components/Componentes/help-modal.tsx
  13. 56
      src/components/Componentes/information-sheet.tsx
  14. 2
      src/components/Componentes/navigation-button.tsx
  15. 54
      src/components/Componentes/outcome-selection-sheet.tsx
  16. 62
      src/components/Componentes/support-sheet.tsx
  17. 4
      src/components/Componentes/test-questions-flow.tsx
  18. 91
      src/components/Componentes/token-switcher.tsx
  19. 26
      src/lib/auth-bridge.ts

44
.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

46
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"]

21
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

5
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",

11
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",
},
});
}

11
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",
},
});
}

4
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)

61
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 = [
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<string>(
callResultOptions[0],
);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
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) {
@ -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 (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -128,8 +119,8 @@ export function CallResultSheet({
<section
{...props}
className={[
"w-full sm:max-w-[375px] rounded-t-[34px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"w-full sm:max-w-[375px] rounded-t-[34px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)

61
src/components/Componentes/dismiss-reason-sheet.tsx

@ -1,7 +1,7 @@
"use client";
import type { HTMLAttributes } from "react";
import { useEffect, useId, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useI18n } from "@/translations/provider";
import Button from "./button";
@ -24,21 +24,31 @@ export function DismissReasonSheet({
...props
}: DismissReasonSheetProps) {
const { dictionary: t } = useI18n();
const options = [
const options = 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 [selectedReasons, setSelectedReasons] = useState<string[]>([]);
const [reasonText, setReasonText] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(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 (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -140,8 +131,8 @@ export function DismissReasonSheet({
<section
{...props}
className={[
"flex h-[80vh] w-full flex-col overflow-hidden rounded-t-[15px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px]",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"flex h-[80vh] w-full flex-col overflow-hidden rounded-t-[15px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)

57
src/components/Componentes/female-consent-sheet.tsx

@ -2,7 +2,7 @@
import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220;
@ -29,31 +29,31 @@ export function FemaleConsentSheet({
}: FemaleConsentSheetProps) {
const { dictionary: t } = useI18n();
const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
const closeSheet = () => {
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 (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -123,8 +108,8 @@ export function FemaleConsentSheet({
<section
{...props}
className={[
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] py-5 px-4 text-left shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] py-5 px-4 text-left shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)

54
src/components/Componentes/female-outcome-sheet.tsx

@ -1,6 +1,6 @@
"use client";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react";
import { useI18n } from "@/translations/provider";
import SwipeButton from "./swipe-button";
@ -21,8 +21,15 @@ export function FemaleOutcomeSheet({
const groupId = useId();
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;
};
}, []);
// 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 (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -146,8 +134,8 @@ export function FemaleOutcomeSheet({
>
<section
className={[
"flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-[34px] bg-[#F9F8F8] p-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px]",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"flex max-h-[85vh] w-full flex-col overflow-hidden rounded-t-[34px] bg-[#F9F8F8] p-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform sm:max-w-[375px] animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
]
.filter(Boolean)
.join(" ")}

64
src/components/Componentes/help-modal.tsx

@ -1,7 +1,7 @@
"use client";
import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import Button from "./button";
import { useI18n } from "@/translations/provider";
@ -24,15 +24,25 @@ export function HelpModal({
buttonText,
}: HelpModalProps) {
const { dictionary: t } = useI18n();
const [isVisible, setIsVisible] = useState(isOpen);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
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);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsClosing(false);
setIsEntering(true);
}
}, [isOpen]);
// Entry animation
useEffect(() => {
if (!isVisible) return;
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
});
return () => window.cancelAnimationFrame(frameId);
}, [isVisible]);
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(
<div
className={[
"fixed inset-0 z-[100] flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-[100] flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
].join(" ")}
@ -117,8 +105,8 @@ export function HelpModal({
>
<section
className={[
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] px-4 pb-5 pt-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] px-4 pb-5 pt-4 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
].join(" ")}
>
<div className="mx-auto flex flex-col items-center">

56
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 (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -246,8 +236,8 @@ export function InformationSheet({
<section
{...props}
className={[
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)

2
src/components/Componentes/navigation-button.tsx

@ -232,7 +232,7 @@ export function NavigationButton({
return (
<GoArrowLeft
aria-hidden="true"
className={`size-6 ${variant === "transparent" ? "text-white" : "text-[#111111]"}`}
className={`size-6 rtl:rotate-180 ${variant === "transparent" ? "text-white" : "text-[#111111]"}`}
/>
);
case "support":

54
src/components/Componentes/outcome-selection-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,29 +25,32 @@ export function OutcomeSelectionSheet({
const { dictionary: t } = useI18n();
const groupId = useId();
const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<"success" | "failure">(
"success",
);
const closeSheet = () => {
if (isClosing) {
return;
}
setIsClosing(true);
};
const isMountedRef = useRef(true);
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
});
isMountedRef.current = true;
return () => {
window.cancelAnimationFrame(frameId);
isMountedRef.current = false;
};
}, []);
const closeSheet = useCallback(() => {
if (isClosing) {
return;
}
setIsClosing(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsVisible(false);
}
onClose?.();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
useEffect(() => {
if (!isVisible) {
return;
@ -65,21 +68,6 @@ export function OutcomeSelectionSheet({
};
}, [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;
}
@ -92,8 +80,8 @@ export function OutcomeSelectionSheet({
return (
<div
className={[
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms]",
isClosing || isEntering
"fixed inset-0 z-50 flex items-end justify-center transition-all duration-[220ms] animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -122,8 +110,8 @@ export function OutcomeSelectionSheet({
<section
{...props}
className={[
"w-full sm:max-w-[375px] rounded-t-[34px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"w-full sm:max-w-[375px] rounded-t-[34px] bg-[#F9F8F8] p-3.5 text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
className,
]
.filter(Boolean)

62
src/components/Componentes/support-sheet.tsx

@ -1,7 +1,7 @@
"use client";
import Image from "next/image";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { FiHeadphones } from "react-icons/fi";
import { useI18n } from "@/translations/provider";
@ -15,41 +15,39 @@ export type SupportSheetProps = {
export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
const { dictionary: t } = useI18n();
const [isVisible, setIsVisible] = useState(isOpen);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false);
const [mounted, setMounted] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
setMounted(true);
}, []);
const closeSheet = () => {
if (isClosing) return;
setIsClosing(true);
return () => {
isMountedRef.current = false;
};
}, []);
// Sync with isOpen prop
// When isOpen changes from outside, reset isClosing
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]);
const closeSheet = useCallback(() => {
if (isClosing) return;
setIsClosing(true);
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";
@ -58,17 +56,7 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
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]);
const handleSupportContact = () => {
if (typeof window !== "undefined" && "HabibApp" in window) {
@ -93,13 +81,13 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
closeSheet();
};
if (!isVisible || !mounted) return null;
if (!isOpen || !mounted) return null;
return createPortal(
<div
className={[
"fixed inset-0 z-[100] flex items-end justify-center transition-all duration-[220ms] select-none",
isClosing || isEntering
"fixed inset-0 z-[100] flex items-end justify-center transition-all duration-[220ms] select-none animate-in fade-in",
isClosing
? "bg-[#171717]/0 opacity-0"
: "bg-[#171717]/55 opacity-100",
]
@ -115,8 +103,8 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
>
<section
className={[
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#EBEBEB] px-[14px] py-[14px] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform flex flex-col items-center",
isClosing || isEntering ? "translate-y-full" : "translate-y-0",
"w-full sm:max-w-[375px] rounded-t-[15px] bg-[#EBEBEB] px-[14px] py-[14px] text-center shadow-[0_20px_60px_rgba(15,23,42,0.08)] transition-transform duration-[220ms] ease-out will-change-transform flex flex-col items-center animate-in slide-in-from-bottom",
isClosing ? "translate-y-full" : "translate-y-0",
]
.filter(Boolean)
.join(" ")}

4
src/components/Componentes/test-questions-flow.tsx

@ -370,7 +370,7 @@ export default function TestQuestionsFlow({
disabled={currentIndex === 0}
className="flex items-center gap-1.5 group-14 font-semibold text-[#4A4A4A] disabled:opacity-30 disabled:pointer-events-none transition-opacity py-2 px-1"
>
<GoArrowLeft className="size-5" />
<GoArrowLeft className="size-5 rtl:rotate-180" />
<span>{locale === "fa" ? "قبلی" : previousLabel}</span>
</button>
@ -385,7 +385,7 @@ export default function TestQuestionsFlow({
className="flex items-center gap-1.5 group-14 font-semibold text-[#4A4A4A] transition-opacity py-2 px-1"
>
<span>{locale === "fa" ? "بعدی" : "Next"}</span>
<GoArrowRight className="size-5" />
<GoArrowRight className="size-5 rtl:rotate-180" />
</button>
)}

91
src/components/Componentes/token-switcher.tsx

@ -25,6 +25,8 @@ export function TokenSwitcher({
}: TokenSwitcherProps) {
const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>("");
const [customTokenInput, setCustomTokenInput] = useState<string>("");
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false);
useEffect(() => {
if (typeof window !== "undefined") {
@ -43,8 +45,12 @@ export function TokenSwitcher({
const isMale2 = currentToken === MALE_2_TOKEN;
const isFemale = currentToken === FEMALE_TOKEN;
const isNoToken = currentToken === "NO_TOKEN" || !currentToken;
const isCustomToken = !isMale && !isMale2 && !isFemale && !isNoToken;
const handleSelectToken = (newToken: string) => {
const trimmed = newToken.trim();
if (!trimmed) return;
if (typeof window !== "undefined") {
// Clear local storage and session storage so answers and cached state from previous user are removed
try {
@ -54,15 +60,15 @@ export function TokenSwitcher({
console.error("Failed to clear storage", e);
}
setClientCookie(TOKEN_COOKIE_NAME, newToken);
setClientCookie("habib_token", newToken);
setClientCookie(TOKEN_COOKIE_NAME, trimmed);
setClientCookie("habib_token", trimmed);
try {
sessionStorage.setItem(TOKEN_COOKIE_NAME, newToken);
sessionStorage.setItem(TOKEN_COOKIE_NAME, trimmed);
} catch (e) {
console.warn("sessionStorage is not accessible:", e);
}
(window as any).HABIB_TOKEN = newToken;
setCurrentToken(newToken);
(window as any).HABIB_TOKEN = trimmed;
setCurrentToken(trimmed);
setIsOpen(false);
// Navigate to the beginning of the flow (intro page)
@ -165,17 +171,17 @@ export function TokenSwitcher({
? "خانم 👩"
: isNoToken
? "بدون توکن 👤"
: "توکن 🔑"}
: "سفارشی 🔑"}
</span>
</button>
{isOpen && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/50 p-4 backdrop-blur-xs animate-in fade-in duration-200">
<div
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800"
className="w-full max-w-sm rounded-2xl bg-white p-5 shadow-xl dark:bg-slate-900 border border-slate-100 dark:border-slate-800 max-h-[90vh] flex flex-col"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800">
<div className="flex items-center justify-between pb-3 border-b border-slate-100 dark:border-slate-800 shrink-0">
<h3 className="font-bold text-slate-900 dark:text-white text-base flex items-center gap-2">
<MdOutlineSwitchAccount className="text-rose-500 text-xl" />
انتخاب حساب کاربری (تست)
@ -189,7 +195,7 @@ export function TokenSwitcher({
</button>
</div>
<div className="mt-4 flex flex-col gap-3">
<div className="mt-4 flex flex-col gap-2.5 overflow-y-auto pr-0.5 flex-1">
{/* Male option card */}
<div
className={[
@ -316,6 +322,34 @@ export function TokenSwitcher({
</div>
</div>
{/* Custom Token Card (if active) */}
{isCustomToken && (
<div className="flex items-center justify-between rounded-xl border border-rose-500 bg-rose-50/60 dark:bg-rose-950/30 text-rose-900 dark:text-rose-100 font-semibold shadow-xs dir-rtl overflow-hidden">
<div className="flex-1 flex items-center gap-3 p-3.5 text-right min-w-0">
<span className="text-2xl shrink-0">🔑</span>
<div className="min-w-0 flex-1">
<div className="text-sm font-bold">توکن سفارشی</div>
<div className="text-[11px] font-mono text-slate-500 dark:text-slate-400 dir-ltr text-left mt-0.5 select-all truncate">
{currentToken}
</div>
</div>
</div>
<div className="flex items-center gap-1.5 pl-3">
<span className="text-[10px] px-2 py-0.5 rounded-full bg-rose-500 text-white font-medium">
فعال
</span>
<button
type="button"
onClick={() => handleResetUser(null, "توکن سفارشی")}
title="پاک کردن اطلاعات و شروع مجدد"
className="p-2 text-slate-400 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-950/20 rounded-lg transition-colors"
>
🗑
</button>
</div>
</div>
)}
{/* No token option card */}
<div
className={[
@ -328,9 +362,9 @@ export function TokenSwitcher({
<button
type="button"
onClick={() => handleSelectToken("NO_TOKEN")}
className="flex-1 flex items-center gap-3 p-3.5 text-right"
className="flex-1 flex items-center gap-3 p-3.5 text-right min-w-0"
>
<span className="text-2xl">👤</span>
<span className="text-2xl shrink-0">👤</span>
<div>
<div className="text-sm font-bold">کاربر بدون توکن</div>
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium">
@ -354,15 +388,46 @@ export function TokenSwitcher({
</button>
</div>
</div>
{/* Inline Custom Token Input Box */}
<div className="rounded-xl border border-dashed border-slate-300 dark:border-slate-700 p-3 bg-slate-50/50 dark:bg-slate-800/50 dir-rtl">
<div className="text-xs font-bold text-slate-700 dark:text-slate-300 mb-2 flex items-center gap-1.5">
<span>🔑</span>
<span>افزودن / تنظیم توکن دلخواه:</span>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={customTokenInput}
onChange={(e) => setCustomTokenInput(e.target.value)}
placeholder="توکن ۳۲/۴۰ کاراکتری..."
dir="ltr"
className="flex-1 rounded-lg border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2.5 py-1.5 text-xs font-mono text-slate-800 dark:text-slate-200 outline-none focus:border-rose-500 transition-colors"
onKeyDown={(e) => {
if (e.key === "Enter" && customTokenInput.trim()) {
handleSelectToken(customTokenInput);
}
}}
/>
<button
type="button"
disabled={!customTokenInput.trim()}
onClick={() => handleSelectToken(customTokenInput)}
className="rounded-lg bg-rose-500 px-3 py-1.5 text-xs font-semibold text-white shadow-xs hover:bg-rose-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shrink-0"
>
اعمال
</button>
</div>
</div>
</div>
<div className="mt-5 flex flex-col gap-2">
<div className="mt-4 pt-3 border-t border-slate-100 dark:border-slate-800 flex flex-col gap-2 shrink-0">
<button
type="button"
onClick={() => setIsOpen(false)}
className="w-full py-2.5 rounded-xl bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-300 font-medium text-sm hover:bg-slate-200 dark:hover:bg-slate-700 transition-colors"
>
انصراف
بستن
</button>
</div>
</div>

26
src/lib/auth-bridge.ts

@ -49,7 +49,20 @@ class AuthBridge {
const coinsCookie =
getClientCookie(COINS_COOKIE_NAME) ?? getClientCookie("habib_coins");
if (!token || token === "NO_TOKEN" || token.trim() === "") {
if (token === "NO_TOKEN") {
this.token = "NO_TOKEN";
return false;
}
if (!token || token.trim() === "") {
const isDevelopment = process.env.NODE_ENV !== "production";
if (isDevelopment) {
this.token =
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308";
this.coins = coinsValue ?? Number(coinsCookie ?? 0);
return true;
}
this.token = null;
return false;
}
@ -247,12 +260,11 @@ class AuthBridge {
}
const isDevelopment = process.env.NODE_ENV !== "production";
if (
isDevelopment &&
process.env.NEXT_PUBLIC_DEFAULT_TOKEN &&
process.env.NEXT_PUBLIC_DEFAULT_TOKEN !== "NO_TOKEN"
) {
return process.env.NEXT_PUBLIC_DEFAULT_TOKEN;
if (isDevelopment) {
return (
process.env.NEXT_PUBLIC_DEFAULT_TOKEN ??
"f3a7543b44ef0a713d1ee0d4f7866b3825cf1308"
);
}
return null;

Loading…
Cancel
Save