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 node_modules
.next .next
dist
out
# Version control & git metadata
.git .git
.gitignore .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 RUN apk add --no-cache libc6-compat
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ 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 WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY . . 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_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_SECURITY_KEY=$NEXT_PUBLIC_SECURITY_KEY ENV NEXT_PUBLIC_SECURITY_KEY=$NEXT_PUBLIC_SECURITY_KEY
ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production
RUN npm run build RUN npm run build
FROM base AS runner
# ──────────────────────────────────────────────────────────────────────────
# مرحلهٔ ۳ — runner: سرور مینیمال و امن standalone در محیط پروداکشن
# ──────────────────────────────────────────────────────────────────────────
FROM node:22-alpine AS runner
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
# ایجاد کاربر غیر روت برای امنیت کانتینر
RUN addgroup --system --gid 1001 nodejs && \ RUN addgroup --system --gid 1001 nodejs && \
adduser --system --uid 1001 nextjs adduser --system --uid 1001 nextjs
# کپی دارایی‌های استاتیک و خروجی standalone
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs USER nextjs
EXPOSE 3000 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"] CMD ["node", "server.js"]

21
docker-compose.yml

@ -1,3 +1,6 @@
# docker-compose برای وب‌اپ ازدواج (Marriage WebApp)
# منبع واحد متغیرها در پروداکشن: فایل .env.production
services: services:
web: web:
build: build:
@ -5,19 +8,29 @@ services:
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
NEXT_PUBLIC_API_BASE_URL: https://habibapp.com 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: ports:
- "5561:3000" - "5561:3000"
environment: environment:
- NODE_ENV=production - NODE_ENV=production
- NEXT_PUBLIC_API_BASE_URL=https://habibapp.com - NEXT_PUBLIC_API_BASE_URL=https://habibapp.com
- NEXT_PUBLIC_SECURITY_KEY=t5yugymks5458fd4ghfg6h6fg - NEXT_PUBLIC_SECURITY_KEY=t5yugymks5458fd4ghfg6h6fg
restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:3000"]
test: ["CMD", "wget", "-qO-", "http://localhost:3000/healthz"]
interval: 30s interval: 30s
timeout: 10s
timeout: 5s
retries: 3 retries: 3
start_period: 40s
start_period: 15s
networks: networks:
- najm_najm - najm_najm

5
next.config.ts

@ -3,6 +3,8 @@ import path from "path";
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: "standalone", output: "standalone",
poweredByHeader: false,
reactStrictMode: true,
allowedDevOrigins: ["192.168.1.64"], allowedDevOrigins: ["192.168.1.64"],
turbopack: {}, turbopack: {},
devIndicators: false, devIndicators: false,
@ -10,9 +12,10 @@ const nextConfig: NextConfig = {
// Compression // Compression
compress: true, compress: true,
// Image optimization
// Image optimization for ultra-fast WebView media loading
images: { images: {
formats: ["image/avif", "image/webp"], formats: ["image/avif", "image/webp"],
minimumCacheTTL: 31536000,
remotePatterns: [ remotePatterns: [
{ {
protocol: "https", 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" aria-hidden="true"
className={[ className={[
"size-5", "size-5",
direction === "left" ? "rotate-180" : "",
direction === "left"
? "rotate-180 rtl:rotate-0"
: "rotate-0 rtl:rotate-180",
isOutlined ? "text-[#8B8B8B]" : "text-white", isOutlined ? "text-[#8B8B8B]" : "text-white",
] ]
.filter(Boolean) .filter(Boolean)

61
src/components/Componentes/call-result-sheet.tsx

@ -1,7 +1,7 @@
"use client"; "use client";
import type { HTMLAttributes } from "react"; 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"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -25,39 +25,45 @@ export function CallResultSheet({
...props ...props
}: CallResultSheetProps) { }: CallResultSheetProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const callResultOptions = [
const callResultOptions = useMemo(
() => [
t["Not a good personal fit"], t["Not a good personal fit"],
t["No mutual interest"], t["No mutual interest"],
t["Different expectations"], t["Different expectations"],
t["No connection felt"], t["No connection felt"],
t["Location not suitable"], t["Location not suitable"],
t["Other reasons"], t["Other reasons"],
];
],
[t],
);
const groupId = useId(); const groupId = useId();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [selectedReason, setSelectedReason] = useState<string>( const [selectedReason, setSelectedReason] = useState<string>(
callResultOptions[0], callResultOptions[0],
); );
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const closeSheet = () => {
const closeSheet = useCallback(() => {
if (isClosing) { if (isClosing) {
return; return;
} }
setIsClosing(true); 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(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
@ -76,21 +82,6 @@ export function CallResultSheet({
}; };
}, [isVisible]); }, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
@ -98,8 +89,8 @@ export function CallResultSheet({
return ( return (
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -128,8 +119,8 @@ export function CallResultSheet({
<section <section
{...props} {...props}
className={[ 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, className,
] ]
.filter(Boolean) .filter(Boolean)

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

@ -1,7 +1,7 @@
"use client"; "use client";
import type { HTMLAttributes } from "react"; 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 { useI18n } from "@/translations/provider";
import Button from "./button"; import Button from "./button";
@ -24,21 +24,31 @@ export function DismissReasonSheet({
...props ...props
}: DismissReasonSheetProps) { }: DismissReasonSheetProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const options = [
const options = useMemo(
() => [
t["Not a good personal fit"], t["Not a good personal fit"],
t["No mutual interest"], t["No mutual interest"],
t["Different expectations"], t["Different expectations"],
t["No connection felt"], t["No connection felt"],
t["Location not suitable"], t["Location not suitable"],
t["Other reasons"], t["Other reasons"],
];
],
[t],
);
const groupId = useId(); const groupId = useId();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [selectedReasons, setSelectedReasons] = useState<string[]>([]); const [selectedReasons, setSelectedReasons] = useState<string[]>([]);
const [reasonText, setReasonText] = useState(""); const [reasonText, setReasonText] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null); const textareaRef = useRef<HTMLTextAreaElement>(null);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
useEffect(() => { useEffect(() => {
const otherReason = options[options.length - 1]; const otherReason = options[options.length - 1];
@ -53,23 +63,19 @@ export function DismissReasonSheet({
} }
}, [selectedReasons, options]); }, [selectedReasons, options]);
const closeSheet = () => {
const closeSheet = useCallback(() => {
if (isClosing) { if (isClosing) {
return; return;
} }
setIsClosing(true); 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(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
@ -88,21 +94,6 @@ export function DismissReasonSheet({
}; };
}, [isVisible]); }, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
@ -110,8 +101,8 @@ export function DismissReasonSheet({
return ( return (
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -140,8 +131,8 @@ export function DismissReasonSheet({
<section <section
{...props} {...props}
className={[ 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, className,
] ]
.filter(Boolean) .filter(Boolean)

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

@ -2,7 +2,7 @@
import Image from "next/image"; import Image from "next/image";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -29,31 +29,31 @@ export function FemaleConsentSheet({
}: FemaleConsentSheetProps) { }: FemaleConsentSheetProps) {
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false); 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); setIsClosing(true);
};
window.setTimeout(() => {
if (isMountedRef.current) {
setIsVisible(false);
}
onClose?.();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
const controls = { close: closeSheet }; const controls = { close: closeSheet };
const resolvedButtons = const resolvedButtons =
typeof buttons === "function" ? buttons(controls) : buttons; typeof buttons === "function" ? buttons(controls) : buttons;
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, []);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;
@ -71,21 +71,6 @@ export function FemaleConsentSheet({
}; };
}, [isVisible]); }, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
@ -93,8 +78,8 @@ export function FemaleConsentSheet({
return ( return (
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -123,8 +108,8 @@ export function FemaleConsentSheet({
<section <section
{...props} {...props}
className={[ 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, className,
] ]
.filter(Boolean) .filter(Boolean)

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

@ -1,6 +1,6 @@
"use client"; "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 { useI18n } from "@/translations/provider";
import SwipeButton from "./swipe-button"; import SwipeButton from "./swipe-button";
@ -21,8 +21,15 @@ export function FemaleOutcomeSheet({
const groupId = useId(); const groupId = useId();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false); 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) // Outcome selection state: "ongoing" (Option A) or "canceled" (Option B)
const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing"); const [outcome, setOutcome] = useState<"ongoing" | "canceled">("ongoing");
@ -56,22 +63,18 @@ export function FemaleOutcomeSheet({
} }
}, [selectedReasons, reasons]); }, [selectedReasons, reasons]);
const closeSheet = () => {
const closeSheet = useCallback(() => {
if (isClosing) { if (isClosing) {
return; return;
} }
setIsClosing(true); 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(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
@ -90,21 +93,6 @@ export function FemaleOutcomeSheet({
}; };
}, [isVisible]); }, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
@ -117,8 +105,8 @@ export function FemaleOutcomeSheet({
return ( return (
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -146,8 +134,8 @@ export function FemaleOutcomeSheet({
> >
<section <section
className={[ 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) .filter(Boolean)
.join(" ")} .join(" ")}

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

@ -1,7 +1,7 @@
"use client"; "use client";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import Button from "./button"; import Button from "./button";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -24,15 +24,25 @@ export function HelpModal({
buttonText, buttonText,
}: HelpModalProps) { }: HelpModalProps) {
const { dictionary: t } = useI18n(); 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 [mounted, setMounted] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
setMounted(true); setMounted(true);
return () => {
isMountedRef.current = false;
};
}, []); }, []);
// When isOpen changes from outside, reset isClosing
useEffect(() => {
if (isOpen) {
setIsClosing(false);
}
}, [isOpen]);
const resolvedTitle = "Tips"; const resolvedTitle = "Tips";
const resolvedDescription = const resolvedDescription =
description ?? description ??
@ -45,29 +55,17 @@ export function HelpModal({
const closeSheet = useCallback(() => { const closeSheet = useCallback(() => {
if (isClosing) return; if (isClosing) return;
setIsClosing(true); setIsClosing(true);
}, [isClosing]);
// Sync with isOpen prop
useEffect(() => {
if (isOpen) {
setIsVisible(true);
window.setTimeout(() => {
if (isMountedRef.current) {
setIsClosing(false); 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 // Lock body scroll
useEffect(() => { useEffect(() => {
if (!isVisible) return;
if (!isOpen || !mounted) return;
const previousBodyOverflow = document.body.style.overflow; const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@ -76,25 +74,15 @@ export function HelpModal({
document.body.style.overflow = previousBodyOverflow; document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow; 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( return createPortal(
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
].join(" ")} ].join(" ")}
@ -117,8 +105,8 @@ export function HelpModal({
> >
<section <section
className={[ 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(" ")} ].join(" ")}
> >
<div className="mx-auto flex flex-col items-center"> <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 Image, { type StaticImageData } from "next/image";
import type { HTMLAttributes, ReactNode } from "react"; import type { HTMLAttributes, ReactNode } from "react";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import Button from "./button"; import Button from "./button";
import { LoadingSkeleton } from "./loading-skeleton"; import { LoadingSkeleton } from "./loading-skeleton";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -145,16 +145,31 @@ export function InformationSheet({
locale === "he" || locale === "he" ||
locale === "ks"; locale === "ks";
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => {
isMountedRef.current = true;
return () => {
isMountedRef.current = false;
};
}, []);
const resolvedIcon = resolveIcon(icon); const resolvedIcon = resolveIcon(icon);
const closeSheet = () => {
const closeSheet = useCallback(() => {
if (isClosing) { if (isClosing) {
return; return;
} }
setIsClosing(true); setIsClosing(true);
};
window.setTimeout(() => {
if (isMountedRef.current) {
setIsVisible(false);
}
onClose?.();
}, EXIT_ANIMATION_MS);
}, [isClosing, onClose]);
const controls = { close: closeSheet }; const controls = { close: closeSheet };
const resolvedTitle = typeof title === "function" ? title(controls) : title; const resolvedTitle = typeof title === "function" ? title(controls) : title;
const resolvedButtons = const resolvedButtons =
@ -166,16 +181,6 @@ export function InformationSheet({
buttons buttons
); );
useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
});
return () => {
window.cancelAnimationFrame(frameId);
};
}, []);
useEffect(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;
@ -193,21 +198,6 @@ export function InformationSheet({
}; };
}, [isVisible]); }, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
@ -215,8 +205,8 @@ export function InformationSheet({
return ( return (
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -246,8 +236,8 @@ export function InformationSheet({
<section <section
{...props} {...props}
className={[ 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, className,
] ]
.filter(Boolean) .filter(Boolean)

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

@ -232,7 +232,7 @@ export function NavigationButton({
return ( return (
<GoArrowLeft <GoArrowLeft
aria-hidden="true" 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": case "support":

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

@ -1,7 +1,7 @@
"use client"; "use client";
import type { HTMLAttributes } from "react"; 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"; import { useI18n } from "@/translations/provider";
const EXIT_ANIMATION_MS = 220; const EXIT_ANIMATION_MS = 220;
@ -25,29 +25,32 @@ export function OutcomeSelectionSheet({
const { dictionary: t } = useI18n(); const { dictionary: t } = useI18n();
const groupId = useId(); const groupId = useId();
const [isVisible, setIsVisible] = useState(true); const [isVisible, setIsVisible] = useState(true);
const [isEntering, setIsEntering] = useState(true);
const [isClosing, setIsClosing] = useState(false); const [isClosing, setIsClosing] = useState(false);
const [selectedStatus, setSelectedStatus] = useState<"success" | "failure">( const [selectedStatus, setSelectedStatus] = useState<"success" | "failure">(
"success", "success",
); );
const closeSheet = () => {
if (isClosing) {
return;
}
setIsClosing(true);
};
const isMountedRef = useRef(true);
useEffect(() => { useEffect(() => {
const frameId = window.requestAnimationFrame(() => {
setIsEntering(false);
});
isMountedRef.current = true;
return () => { 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(() => { useEffect(() => {
if (!isVisible) { if (!isVisible) {
return; return;
@ -65,21 +68,6 @@ export function OutcomeSelectionSheet({
}; };
}, [isVisible]); }, [isVisible]);
useEffect(() => {
if (!isClosing) {
return;
}
const timeoutId = window.setTimeout(() => {
setIsVisible(false);
onClose?.();
}, EXIT_ANIMATION_MS);
return () => {
window.clearTimeout(timeoutId);
};
}, [isClosing, onClose]);
if (!isVisible) { if (!isVisible) {
return null; return null;
} }
@ -92,8 +80,8 @@ export function OutcomeSelectionSheet({
return ( return (
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -122,8 +110,8 @@ export function OutcomeSelectionSheet({
<section <section
{...props} {...props}
className={[ 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, className,
] ]
.filter(Boolean) .filter(Boolean)

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

@ -1,7 +1,7 @@
"use client"; "use client";
import Image from "next/image"; import Image from "next/image";
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { FiHeadphones } from "react-icons/fi"; import { FiHeadphones } from "react-icons/fi";
import { useI18n } from "@/translations/provider"; import { useI18n } from "@/translations/provider";
@ -15,41 +15,39 @@ export type SupportSheetProps = {
export function SupportSheet({ isOpen, onClose }: SupportSheetProps) { export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
const { dictionary: t } = useI18n(); 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 [mounted, setMounted] = useState(false);
const [isClosing, setIsClosing] = useState(false);
const isMountedRef = useRef(true);
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
setMounted(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(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
setIsVisible(true);
setIsClosing(false); setIsClosing(false);
setIsEntering(true);
} }
}, [isOpen]); }, [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 // Lock body scroll
useEffect(() => { useEffect(() => {
if (!isVisible) return;
if (!isOpen || !mounted) return;
const previousBodyOverflow = document.body.style.overflow; const previousBodyOverflow = document.body.style.overflow;
const previousHtmlOverflow = document.documentElement.style.overflow; const previousHtmlOverflow = document.documentElement.style.overflow;
document.body.style.overflow = "hidden"; document.body.style.overflow = "hidden";
@ -58,17 +56,7 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
document.body.style.overflow = previousBodyOverflow; document.body.style.overflow = previousBodyOverflow;
document.documentElement.style.overflow = previousHtmlOverflow; 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 = () => { const handleSupportContact = () => {
if (typeof window !== "undefined" && "HabibApp" in window) { if (typeof window !== "undefined" && "HabibApp" in window) {
@ -93,13 +81,13 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
closeSheet(); closeSheet();
}; };
if (!isVisible || !mounted) return null;
if (!isOpen || !mounted) return null;
return createPortal( return createPortal(
<div <div
className={[ 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]/0 opacity-0"
: "bg-[#171717]/55 opacity-100", : "bg-[#171717]/55 opacity-100",
] ]
@ -115,8 +103,8 @@ export function SupportSheet({ isOpen, onClose }: SupportSheetProps) {
> >
<section <section
className={[ 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) .filter(Boolean)
.join(" ")} .join(" ")}

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

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

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

@ -25,6 +25,8 @@ export function TokenSwitcher({
}: TokenSwitcherProps) { }: TokenSwitcherProps) {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [currentToken, setCurrentToken] = useState<string>(""); const [currentToken, setCurrentToken] = useState<string>("");
const [customTokenInput, setCustomTokenInput] = useState<string>("");
const [isCustomInputOpen, setIsCustomInputOpen] = useState(false);
useEffect(() => { useEffect(() => {
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
@ -43,8 +45,12 @@ export function TokenSwitcher({
const isMale2 = currentToken === MALE_2_TOKEN; const isMale2 = currentToken === MALE_2_TOKEN;
const isFemale = currentToken === FEMALE_TOKEN; const isFemale = currentToken === FEMALE_TOKEN;
const isNoToken = currentToken === "NO_TOKEN" || !currentToken; const isNoToken = currentToken === "NO_TOKEN" || !currentToken;
const isCustomToken = !isMale && !isMale2 && !isFemale && !isNoToken;
const handleSelectToken = (newToken: string) => { const handleSelectToken = (newToken: string) => {
const trimmed = newToken.trim();
if (!trimmed) return;
if (typeof window !== "undefined") { if (typeof window !== "undefined") {
// Clear local storage and session storage so answers and cached state from previous user are removed // Clear local storage and session storage so answers and cached state from previous user are removed
try { try {
@ -54,15 +60,15 @@ export function TokenSwitcher({
console.error("Failed to clear storage", e); 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 { try {
sessionStorage.setItem(TOKEN_COOKIE_NAME, newToken);
sessionStorage.setItem(TOKEN_COOKIE_NAME, trimmed);
} catch (e) { } catch (e) {
console.warn("sessionStorage is not accessible:", 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); setIsOpen(false);
// Navigate to the beginning of the flow (intro page) // Navigate to the beginning of the flow (intro page)
@ -165,17 +171,17 @@ export function TokenSwitcher({
? "خانم 👩" ? "خانم 👩"
: isNoToken : isNoToken
? "بدون توکن 👤" ? "بدون توکن 👤"
: "توکن 🔑"}
: "سفارشی 🔑"}
</span> </span>
</button> </button>
{isOpen && ( {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="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 <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()} 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"> <h3 className="font-bold text-slate-900 dark:text-white text-base flex items-center gap-2">
<MdOutlineSwitchAccount className="text-rose-500 text-xl" /> <MdOutlineSwitchAccount className="text-rose-500 text-xl" />
انتخاب حساب کاربری (تست) انتخاب حساب کاربری (تست)
@ -189,7 +195,7 @@ export function TokenSwitcher({
</button> </button>
</div> </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 */} {/* Male option card */}
<div <div
className={[ className={[
@ -316,6 +322,34 @@ export function TokenSwitcher({
</div> </div>
</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 */} {/* No token option card */}
<div <div
className={[ className={[
@ -328,9 +362,9 @@ export function TokenSwitcher({
<button <button
type="button" type="button"
onClick={() => handleSelectToken("NO_TOKEN")} 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>
<div className="text-sm font-bold">کاربر بدون توکن</div> <div className="text-sm font-bold">کاربر بدون توکن</div>
<div className="text-xs text-slate-500 dark:text-slate-400 font-medium"> <div className="text-xs text-slate-500 dark:text-slate-400 font-medium">
@ -354,15 +388,46 @@ export function TokenSwitcher({
</button> </button>
</div> </div>
</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>
<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 <button
type="button" type="button"
onClick={() => setIsOpen(false)} 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" 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> </button>
</div> </div>
</div> </div>

26
src/lib/auth-bridge.ts

@ -49,7 +49,20 @@ class AuthBridge {
const coinsCookie = const coinsCookie =
getClientCookie(COINS_COOKIE_NAME) ?? getClientCookie("habib_coins"); 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; this.token = null;
return false; return false;
} }
@ -247,12 +260,11 @@ class AuthBridge {
} }
const isDevelopment = process.env.NODE_ENV !== "production"; 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; return null;

Loading…
Cancel
Save