Files
meezi/web/dashboard/src/components/layout/trial-countdown-banner.tsx
T
soroush.asadi 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
feat: username/password authentication for admin and merchant panels
- 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>
2026-05-31 19:58:54 +03:30

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>
);
}