Localize applications and services list pages.
Move the apps and managed-services list pages onto the i18n dictionaries (titles, table headers, lifecycle/expiry labels, delete confirmations, toast messages) and the locale-aware Link, reusing the shared status and lifecycle/expiry copy. Add RTL-aware table alignment and icon spacing. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import Link from 'next/link';
|
||||
import { Link } from '@/i18n/Link';
|
||||
import { useT } from '@/i18n/I18nProvider';
|
||||
import type { Dictionary } from '@/i18n/dictionaries/fa';
|
||||
import api from '@/lib/api';
|
||||
import type { Application } from '@/types';
|
||||
import { Rocket, Package, Hexagon, Database, Box, AlertTriangle, Clock } from 'lucide-react';
|
||||
@@ -33,28 +35,37 @@ const lifecycleColors: Record<string, string> = {
|
||||
deleted: 'text-gray-500 bg-gray-100',
|
||||
};
|
||||
|
||||
const lifecycleLabels: Record<string, string> = {
|
||||
active: 'Active',
|
||||
suspended: 'Suspended — Unpaid',
|
||||
pending_deletion: 'Pending Deletion',
|
||||
deleted: 'Deleted',
|
||||
};
|
||||
type AppsDict = Dictionary['dashboard']['apps'];
|
||||
|
||||
function formatExpiry(expiresAt?: string): { text: string; urgent: boolean } {
|
||||
function statusLabel(status: string, t: Dictionary): string {
|
||||
return (t.dashboard.status as Record<string, string>)[status] ?? status;
|
||||
}
|
||||
|
||||
function lifecycleLabel(lifecycle: string, a: AppsDict): string {
|
||||
return (a.lifecycle as Record<string, string>)[lifecycle] ?? lifecycle;
|
||||
}
|
||||
|
||||
function formatExpiry(expiresAt: string | undefined, a: AppsDict): { text: string; urgent: boolean } {
|
||||
if (!expiresAt) return { text: '—', urgent: false };
|
||||
const now = new Date();
|
||||
const exp = new Date(expiresAt);
|
||||
const diff = exp.getTime() - now.getTime();
|
||||
if (diff <= 0) return { text: 'Expired', urgent: true };
|
||||
if (diff <= 0) return { text: a.expired, urgent: true };
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days > 0) return { text: `${days}d ${hours % 24}h remaining`, urgent: days < 3 };
|
||||
if (hours > 0) return { text: `${hours}h remaining`, urgent: hours < 6 };
|
||||
if (days > 0)
|
||||
return {
|
||||
text: a.daysHours.replace('{d}', String(days)).replace('{h}', String(hours % 24)),
|
||||
urgent: days < 3,
|
||||
};
|
||||
if (hours > 0) return { text: a.hours.replace('{h}', String(hours)), urgent: hours < 6 };
|
||||
const mins = Math.floor(diff / 60000);
|
||||
return { text: `${mins}m remaining`, urgent: true };
|
||||
return { text: a.mins.replace('{m}', String(mins)), urgent: true };
|
||||
}
|
||||
|
||||
export default function AppsPage() {
|
||||
const t = useT();
|
||||
const a = t.dashboard.apps;
|
||||
const confirm = useConfirm();
|
||||
|
||||
const { data: appsRaw = [], isLoading } = useQuery<Application[]>({
|
||||
@@ -65,8 +76,8 @@ export default function AppsPage() {
|
||||
|
||||
const { deleteApplication, isDeleting, isAnyDeleting } = useApplicationDelete({
|
||||
invalidateKeys: [['applications', 'application'], ['applications']],
|
||||
successMessage: 'Application deleted',
|
||||
successWithCreditMessage: 'Application deleted. Your prepaid resources are shown on the dashboard.',
|
||||
successMessage: a.deleted,
|
||||
successWithCreditMessage: a.deletedWithCredit,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
@@ -96,21 +107,21 @@ export default function AppsPage() {
|
||||
<div className="space-y-6">
|
||||
<div className="page-header">
|
||||
<div>
|
||||
<h1 className="page-title">My Applications</h1>
|
||||
<p className="page-subtitle">{apps.length} application{apps.length !== 1 ? 's' : ''}</p>
|
||||
<h1 className="page-title">{a.title}</h1>
|
||||
<p className="page-subtitle">{a.count.replace('{n}', String(apps.length))}</p>
|
||||
</div>
|
||||
<Link href="/dashboard/deploy" className="btn-primary">
|
||||
<Rocket className="w-4 h-4 mr-1 inline" /> New Application
|
||||
<Rocket className="w-4 h-4 mr-1 rtl:mr-0 rtl:ml-1 inline" /> {a.newApplication}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{apps.length === 0 ? (
|
||||
<div className="card text-center py-16">
|
||||
<Package className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p className="text-gray-600 text-lg font-medium">No applications yet</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">Deploy your first application to get started.</p>
|
||||
<p className="text-gray-600 text-lg font-medium">{a.noApplications}</p>
|
||||
<p className="text-gray-400 mt-1 text-sm">{a.emptyDesc}</p>
|
||||
<Link href="/dashboard/deploy" className="btn-primary mt-6 inline-flex items-center gap-1">
|
||||
<Rocket className="w-4 h-4" /> Deploy your first app
|
||||
<Rocket className="w-4 h-4" /> {a.deployFirst}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
@@ -119,19 +130,19 @@ export default function AppsPage() {
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
<thead className="bg-gray-50/80">
|
||||
<tr>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider min-w-[300px]">Application</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Runtime</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Service Status</th>
|
||||
<th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">Expiry</th>
|
||||
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">Actions</th>
|
||||
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider min-w-[300px]">{a.colApplication}</th>
|
||||
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colRuntime}</th>
|
||||
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colStatus}</th>
|
||||
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colServiceStatus}</th>
|
||||
<th className="px-6 py-3.5 text-left rtl:text-right text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colExpiry}</th>
|
||||
<th className="px-6 py-3.5 text-right rtl:text-left text-xs font-semibold text-gray-500 uppercase tracking-wider">{a.colActions}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100">
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
const expiry = formatExpiry(app.planExpiresAt, a);
|
||||
const rowDeleting = isDeleting(app.id);
|
||||
return (
|
||||
<tr
|
||||
@@ -148,12 +159,12 @@ export default function AppsPage() {
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-sm text-gray-600 capitalize ${rowDeleting ? deletingRowContentClass : ''}`}>{app.runtime}</td>
|
||||
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{statusLabel(latestStatus, t)}</span>
|
||||
</td>
|
||||
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
{lifecycleLabel(lifecycle, a)}
|
||||
</span>
|
||||
</td>
|
||||
<td className={`px-6 py-4 ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
@@ -163,22 +174,22 @@ export default function AppsPage() {
|
||||
{expiry.text}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400">No plan</span>
|
||||
<span className="text-xs text-gray-400">{a.noPlan}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className={`px-6 py-4 text-right ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<td className={`px-6 py-4 text-right rtl:text-left ${rowDeleting ? deletingRowContentClass : ''}`}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link href={`/dashboard/apps/${app.id}`} className="btn-ghost text-xs px-3 py-1.5">
|
||||
View
|
||||
{a.view}
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isAnyDeleting}
|
||||
onClick={async () => {
|
||||
const ok = await confirm({
|
||||
title: 'Delete Application',
|
||||
message: `Permanently delete "${app.name}" and all its data?\n\nIf your plan still has time left, the prepaid resources will appear on your dashboard for use on a new app at no extra charge.`,
|
||||
confirmText: 'Delete',
|
||||
title: a.deleteTitle,
|
||||
message: a.deleteMessage.replace('{name}', app.name),
|
||||
confirmText: t.common.delete,
|
||||
variant: 'danger',
|
||||
});
|
||||
if (ok) deleteApplication(app.id);
|
||||
@@ -206,7 +217,7 @@ export default function AppsPage() {
|
||||
{apps.map((app) => {
|
||||
const latestStatus = app.deployments?.[0]?.status || 'pending';
|
||||
const lifecycle = app.lifecycleStatus || 'active';
|
||||
const expiry = formatExpiry(app.planExpiresAt);
|
||||
const expiry = formatExpiry(app.planExpiresAt, a);
|
||||
return (
|
||||
<Link
|
||||
key={app.id}
|
||||
@@ -223,12 +234,12 @@ export default function AppsPage() {
|
||||
<p className="text-xs text-gray-500 capitalize">{app.runtime}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{latestStatus}</span>
|
||||
<span className={`badge ${statusColors[latestStatus] || 'badge-gray'}`}>{statusLabel(latestStatus, t)}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-semibold ${lifecycleColors[lifecycle] || 'text-gray-500 bg-gray-100'}`}>
|
||||
{lifecycle === 'suspended' && <AlertTriangle className="w-3 h-3" />}
|
||||
{lifecycleLabels[lifecycle] || lifecycle}
|
||||
{lifecycleLabel(lifecycle, a)}
|
||||
</span>
|
||||
{app.planExpiresAt && (
|
||||
<span className={`inline-flex items-center gap-1 text-xs ${expiry.urgent ? 'text-red-600 font-semibold' : 'text-gray-500'}`}>
|
||||
@@ -242,7 +253,7 @@ export default function AppsPage() {
|
||||
<Database className="w-3 h-3" /> {app.databaseType}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Box className="w-3 h-3" /> {app.replicas} replica{app.replicas > 1 ? 's' : ''}
|
||||
<Box className="w-3 h-3" /> {app.replicas} {app.replicas > 1 ? t.dashboard.home.replicaPlural : t.dashboard.home.replica}
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
Reference in New Issue
Block a user