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>
239 lines
6.3 KiB
TypeScript
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 };
|