639d5c305e
CI/CD / CI · API (dotnet build + test) (push) Successful in 49s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 42s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m8s
CI/CD / CI · Admin Web (tsc) (push) Successful in 37s
CI/CD / CI · Website (tsc) (push) Successful in 46s
CI/CD / CI · Koja (tsc) (push) Successful in 49s
CI/CD / Deploy · all services (push) Has been cancelled
- Add PasswordHasher utility (PBKDF2/SHA-256, 100k iterations)
- Add Username + PasswordHash fields to Employee and SystemAdmin entities
- EF migration: AddPasswordLogin (nullable columns on both tables)
- Meezi.API: POST /api/auth/login (employee password login, CHOOSE_CAFE support)
- Meezi.API: PUT/DELETE /api/cafes/{id}/employees/{id}/credentials (Owner/Manager only)
- Meezi.Admin.API: POST /api/admin/auth/login + PUT /api/admin/auth/password
- Dashboard login page: OTP / Password tabs
- Admin login page: OTP / Password tabs
- HR screen: new Credentials tab for setting employee username/password
- PlatformDataSeeder: ensure system admin + integration settings in production
- Trial countdown banner: updated deadline to 1 Tir 1405 (Jun 22)
- i18n: fa/en/ar updated for all new UI strings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
147 lines
4.7 KiB
TypeScript
147 lines
4.7 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { useLocale } from "next-intl";
|
|
import { useRouter } from "@/i18n/routing";
|
|
import { Clock, X, Zap } from "lucide-react";
|
|
|
|
// 1 Tir 1405 = June 22, 2026 (Tehran IRDT UTC+4:30)
|
|
const DEADLINE = new Date("2026-06-22T00:00:00+04:30");
|
|
const STORAGE_KEY = "meezi_trial_banner_v1";
|
|
|
|
interface TimeLeft {
|
|
days: number;
|
|
hours: number;
|
|
minutes: number;
|
|
seconds: number;
|
|
}
|
|
|
|
function calcTimeLeft(): TimeLeft {
|
|
const diff = Math.max(0, DEADLINE.getTime() - Date.now());
|
|
return {
|
|
days: Math.floor(diff / 86_400_000),
|
|
hours: Math.floor((diff % 86_400_000) / 3_600_000),
|
|
minutes: Math.floor((diff % 3_600_000) / 60_000),
|
|
seconds: Math.floor((diff % 60_000) / 1_000),
|
|
};
|
|
}
|
|
|
|
function pad(n: number) {
|
|
return n.toString().padStart(2, "0");
|
|
}
|
|
|
|
export function TrialCountdownBanner() {
|
|
const locale = useLocale();
|
|
const router = useRouter();
|
|
const isRtl = locale !== "en";
|
|
|
|
// Start hidden — reveal after mount so we can read localStorage without SSR mismatch
|
|
const [visible, setVisible] = useState(false);
|
|
const [timeLeft, setTimeLeft] = useState<TimeLeft>(calcTimeLeft);
|
|
const [expired, setExpired] = useState(false);
|
|
|
|
// Hydrate visibility from localStorage
|
|
useEffect(() => {
|
|
if (localStorage.getItem(STORAGE_KEY) !== "1") {
|
|
setVisible(true);
|
|
}
|
|
}, []);
|
|
|
|
// Tick every second
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const id = setInterval(() => {
|
|
const tl = calcTimeLeft();
|
|
setTimeLeft(tl);
|
|
if (tl.days === 0 && tl.hours === 0 && tl.minutes === 0 && tl.seconds === 0) {
|
|
setExpired(true);
|
|
}
|
|
}, 1_000);
|
|
return () => clearInterval(id);
|
|
}, [visible]);
|
|
|
|
if (!visible) return null;
|
|
|
|
const dismiss = () => {
|
|
setVisible(false);
|
|
localStorage.setItem(STORAGE_KEY, "1");
|
|
};
|
|
|
|
const urgency = timeLeft.days <= 3; // red when ≤ 3 days left
|
|
const soon = timeLeft.days <= 7; // amber when ≤ 7 days left
|
|
|
|
const bgClass = urgency
|
|
? "bg-red-600"
|
|
: soon
|
|
? "bg-amber-500"
|
|
: "bg-[#0F6E56]";
|
|
|
|
const textFa = expired
|
|
? "دوره آزمایشی میزی به پایان رسید. برای ادامه پلن انتخاب کنید."
|
|
: "دوره آزمایشی رایگان تا ۱ تیر ۱۴۰۵";
|
|
|
|
const textEn = expired
|
|
? "Your Meezi trial has ended. Choose a plan to continue."
|
|
: "Free trial ends 1 Tir 1405 (Jun 22)";
|
|
|
|
const Digit = ({ value, label }: { value: number; label: string }) => (
|
|
<div className="flex flex-col items-center">
|
|
<span className="min-w-[2.25rem] rounded-md bg-white/20 px-2 py-0.5 text-center text-base font-extrabold tabular-nums leading-tight text-white sm:text-lg">
|
|
{pad(value)}
|
|
</span>
|
|
<span className="mt-0.5 text-[10px] font-medium text-white/70">{label}</span>
|
|
</div>
|
|
);
|
|
|
|
const labelsFa = ["روز", "ساعت", "دقیقه", "ثانیه"];
|
|
const labelsEn = ["d", "h", "m", "s"];
|
|
const labels = isRtl ? labelsFa : labelsEn;
|
|
|
|
return (
|
|
<div
|
|
className={`relative flex flex-wrap items-center gap-x-4 gap-y-2 px-4 py-2 sm:px-6 ${bgClass} transition-colors duration-700`}
|
|
role="banner"
|
|
aria-live="polite"
|
|
>
|
|
{/* Icon + message */}
|
|
<div className="flex items-center gap-2 text-white">
|
|
<Clock className="h-4 w-4 shrink-0 opacity-80" />
|
|
<span className="text-xs font-semibold sm:text-sm">
|
|
{isRtl ? textFa : textEn}
|
|
</span>
|
|
</div>
|
|
|
|
{/* Countdown digits */}
|
|
{!expired && (
|
|
<div className="flex items-end gap-2">
|
|
<Digit value={timeLeft.days} label={labels[0]} />
|
|
<span className="mb-3 text-white/60 font-bold">:</span>
|
|
<Digit value={timeLeft.hours} label={labels[1]} />
|
|
<span className="mb-3 text-white/60 font-bold">:</span>
|
|
<Digit value={timeLeft.minutes} label={labels[2]} />
|
|
<span className="mb-3 text-white/60 font-bold">:</span>
|
|
<Digit value={timeLeft.seconds} label={labels[3]} />
|
|
</div>
|
|
)}
|
|
|
|
{/* CTA */}
|
|
<button
|
|
onClick={() => router.push("/subscription")}
|
|
className="ms-auto flex items-center gap-1.5 rounded-lg bg-white px-3 py-1.5 text-xs font-bold text-gray-900 shadow-sm transition hover:bg-gray-100 active:scale-95"
|
|
>
|
|
<Zap className="h-3.5 w-3.5 text-amber-500" />
|
|
{isRtl ? "ارتقا به پرو" : "Upgrade to Pro"}
|
|
</button>
|
|
|
|
{/* Dismiss */}
|
|
<button
|
|
onClick={dismiss}
|
|
className="shrink-0 rounded p-0.5 text-white/70 transition hover:text-white"
|
|
aria-label={isRtl ? "بستن" : "Dismiss"}
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|