TorbatYar/frontend/components/admin/controls.tsx
Mortezakoohjani 800b0ba2c5 Deploy TorbatYar for torbatyar.ir with nginx multi-tenant routing.
Wire production domain, CORS for tenant subdomains, celery volume mounts, and nginx reverse proxy configs for apex, API, identity, auth, and wildcard tenants.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-21 21:43:33 +03:30

239 lines
6.3 KiB
TypeScript

"use client";
import {
ChangeEvent,
ReactNode,
SelectHTMLAttributes,
TextareaHTMLAttributes,
InputHTMLAttributes,
} from "react";
import { Button } from "@/components/ui";
export const inputClass =
"mt-1 w-full rounded-xl border border-gray-200 px-4 py-2.5 text-sm focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary disabled:bg-gray-50";
export const labelClass = "block text-sm font-medium text-secondary";
export const cardClass =
"rounded-2xl border border-gray-100 bg-white p-6 shadow-sm";
export function Field({
label,
hint,
children,
required,
}: {
label: string;
hint?: string;
required?: boolean;
children: ReactNode;
}) {
return (
<label className="block">
<span className={labelClass}>
{label}
{required && <span className="text-red-500"> *</span>}
</span>
{children}
{hint && <span className="mt-1 block text-xs text-gray-400">{hint}</span>}
</label>
);
}
interface TextFieldProps extends InputHTMLAttributes<HTMLInputElement> {
label: string;
hint?: string;
onValue?: (v: string) => void;
}
export function TextField({ label, hint, required, onValue, onChange, className, ...props }: TextFieldProps) {
return (
<Field label={label} hint={hint} required={required}>
<input
className={`${inputClass} ${className ?? ""}`}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
onValue?.(e.target.value);
onChange?.(e);
}}
{...props}
/>
</Field>
);
}
interface TextareaFieldProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label: string;
hint?: string;
onValue?: (v: string) => void;
}
export function TextareaField({ label, hint, required, onValue, onChange, ...props }: TextareaFieldProps) {
return (
<Field label={label} hint={hint} required={required}>
<textarea
className={inputClass}
rows={3}
onChange={(e) => {
onValue?.(e.target.value);
onChange?.(e);
}}
{...props}
/>
</Field>
);
}
interface SelectFieldProps extends SelectHTMLAttributes<HTMLSelectElement> {
label: string;
hint?: string;
options: { value: string; label: string }[];
onValue?: (v: string) => void;
}
export function SelectField({ label, hint, required, options, onValue, onChange, ...props }: SelectFieldProps) {
return (
<Field label={label} hint={hint} required={required}>
<select
className={inputClass}
onChange={(e) => {
onValue?.(e.target.value);
onChange?.(e);
}}
{...props}
>
{options.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</Field>
);
}
export function Banner({
variant,
children,
}: {
variant: "error" | "success" | "info";
children: ReactNode;
}) {
if (!children) return null;
const styles = {
error: "bg-red-50 text-red-600",
success: "bg-green-50 text-green-700",
info: "bg-blue-50 text-blue-700",
}[variant];
return (
<p className={`rounded-lg px-4 py-3 text-sm ${styles}`}>{children}</p>
);
}
export function Modal({
open,
title,
onClose,
children,
footer,
}: {
open: boolean;
title: string;
onClose: () => void;
children: ReactNode;
footer?: ReactNode;
}) {
if (!open) return null;
return (
<div
className="fixed inset-0 z-[100] flex items-center justify-center bg-black/40 p-4"
onClick={onClose}
>
<div
className="max-h-[90vh] w-full max-w-lg overflow-y-auto rounded-2xl bg-white p-6 shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold text-secondary">{title}</h3>
<button
type="button"
onClick={onClose}
className="rounded-lg p-1 text-gray-400 hover:bg-gray-100 hover:text-gray-600"
aria-label="بستن"
>
</button>
</div>
{children}
{footer && <div className="mt-6 flex justify-end gap-3">{footer}</div>}
</div>
</div>
);
}
const STATUS_STYLES: Record<string, string> = {
active: "bg-green-100 text-green-700",
trialing: "bg-blue-100 text-blue-700",
pending_activation: "bg-amber-100 text-amber-700",
draft: "bg-gray-100 text-gray-600",
suspended: "bg-red-100 text-red-700",
inactive: "bg-gray-100 text-gray-600",
maintenance: "bg-amber-100 text-amber-700",
archived: "bg-gray-100 text-gray-500",
deleted: "bg-red-100 text-red-700",
canceled: "bg-red-100 text-red-700",
expired: "bg-gray-100 text-gray-500",
past_due: "bg-amber-100 text-amber-700",
};
const STATUS_LABELS: Record<string, string> = {
active: "فعال",
trialing: "آزمایشی",
pending_activation: "در انتظار فعال‌سازی",
draft: "پیش‌نویس",
suspended: "معلق",
inactive: "غیرفعال",
maintenance: "در حال تعمیر",
archived: "بایگانی‌شده",
deleted: "حذف‌شده",
canceled: "لغوشده",
expired: "منقضی",
past_due: "معوق",
};
export function StatusBadge({ status }: { status: string }) {
const style = STATUS_STYLES[status] || "bg-gray-100 text-gray-600";
return (
<span
className={`inline-block rounded-full px-2.5 py-0.5 text-xs font-medium ${style}`}
>
{STATUS_LABELS[status] || status}
</span>
);
}
export function EmptyState({ message }: { message: string }) {
return <p className="p-6 text-sm text-gray-500">{message}</p>;
}
export function AdminPageHeader({
title,
subtitle,
action,
}: {
title: string;
subtitle?: string;
action?: ReactNode;
}) {
return (
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-secondary">{title}</h1>
{subtitle && <p className="mt-1 text-sm text-gray-600">{subtitle}</p>}
</div>
{action && <div className="flex flex-wrap gap-3">{action}</div>}
</div>
);
}
export { Button };