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:
keyhan
2026-06-11 12:28:59 +03:30
parent e0964e6528
commit 2bc86c03b8
5 changed files with 181 additions and 73 deletions
+50 -39
View File
@@ -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>