Localize database-config, snapshots panel and new-service wizard.

Add components.dbConfig / components.snapshots and dashboard.servicesNew
dictionaries; move the managed-database config (engine, credentials, dump
upload, storage), the snapshots panel and the three-step new-service
wizard onto them. validateDbDumpStorage now takes a localized template.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
keyhan
2026-06-11 19:22:37 +03:30
parent 63e368f464
commit 96d24b50a1
6 changed files with 334 additions and 114 deletions
@@ -1,11 +1,12 @@
'use client';
import { useRef, useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import { useState, useMemo } from 'react';
import { useMutation, useQuery } from '@tanstack/react-query';
import api from '@/lib/api';
import { toast } from 'react-toastify';
import { Link } from '@/i18n/Link';
import { useT } from '@/i18n/I18nProvider';
import { useLocalizedRouter } from '@/i18n/navigation';
import type {
Application,
CreateApplicationDto,
@@ -37,11 +38,12 @@ import {
type ServiceKind = 'managed_database' | 'managed_redis' | 'managed_rabbitmq';
const steps = ['Service type', 'Configuration', 'Review & pay'];
export default function NewManagedServicePage() {
const t = useT();
const s = t.dashboard.servicesNew;
const steps = s.steps;
const { notifyDeployStarted } = useDeployProgressActions();
const router = useRouter();
const router = useLocalizedRouter();
const [step, setStep] = useState(0);
const [kind, setKind] = useState<ServiceKind | null>(null);
const [paymentMethod, setPaymentMethod] = useState<'wallet' | 'gateway'>('wallet');
@@ -181,10 +183,10 @@ export default function NewManagedServicePage() {
await api.post(`/deployments/applications/${appId}/deploy`);
} catch {
useDeployProgressStore.getState().stopTracking(appId);
throw new Error('Deploy failed');
throw new Error(s.deployFailed);
}
setDeployStage('done');
toast.success('Service provisioned successfully');
toast.success(s.provisionedSuccess);
router.push(`/dashboard/services/${appId}`);
};
@@ -217,12 +219,12 @@ export default function NewManagedServicePage() {
onSuccess: (appId) =>
finishDeploy(appId, form.name).catch(() => {
setDeployStage('error');
toast.error('Payment succeeded but deployment failed');
toast.error(s.paySucceededDeployFailed);
}),
onError: (err: unknown) => {
setDeployStage('error');
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Payment or provisioning failed');
toast.error(msg || s.payProvisionFailed);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
@@ -233,7 +235,7 @@ export default function NewManagedServicePage() {
if (payAmount > 0) {
const { data: gw } = await api.post('/billing/gateway/initiate', {
amount: payAmount,
description: `Service: ${form.name} (${selectedCycle})`,
description: s.serviceDesc.replace('{name}', form.name).replace('{cycle}', selectedCycle),
callbackUrl: `${window.location.origin}/dashboard/services/new`,
});
await api.post('/billing/gateway/verify', {
@@ -253,19 +255,19 @@ export default function NewManagedServicePage() {
onSuccess: (appId) =>
finishDeploy(appId, form.name).catch(() => {
setDeployStage('error');
toast.error('Payment succeeded but deployment failed');
toast.error(s.paySucceededDeployFailed);
}),
onError: (err: unknown) => {
setDeployStage('error');
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'Payment failed');
toast.error(msg || s.payFailed);
setTimeout(() => setDeployStage('idle'), 2000);
},
});
const validateBeforePay = () => {
if (kind === 'managed_database' && dbDumpFile) {
const err = validateDbDumpStorage(dbDumpFile, parseInt(form.dbStorageSize, 10) || 1);
const err = validateDbDumpStorage(dbDumpFile, parseInt(form.dbStorageSize, 10) || 1, t.components.dbConfig.storageTooSmallDetail);
if (err) {
setRestoreStorageErrorMessage(err);
setShowRestoreStorageErrorModal(true);
@@ -280,7 +282,7 @@ export default function NewManagedServicePage() {
if (payAmount === 0) walletPayMutation.mutate();
else if (paymentMethod === 'wallet') {
if (!hasEnoughBalance) {
toast.error('Insufficient wallet balance');
toast.error(s.insufficientBalance);
return;
}
walletPayMutation.mutate();
@@ -299,11 +301,11 @@ export default function NewManagedServicePage() {
<div className="max-w-2xl mx-auto space-y-8 animate-fade-in">
<div className="flex items-center gap-3">
<Link href="/dashboard/services" className="btn-ghost">
<ArrowLeft className="w-4 h-4" />
<ArrowLeft className="w-4 h-4 rtl:rotate-180" />
</Link>
<div>
<h1 className="page-title">New managed service</h1>
<p className="page-subtitle">Database, Redis, or RabbitMQ billed like applications</p>
<h1 className="page-title">{s.title}</h1>
<p className="page-subtitle">{s.subtitle}</p>
</div>
</div>
@@ -338,9 +340,9 @@ export default function NewManagedServicePage() {
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{(
[
{ id: 'managed_database' as const, title: 'Database', desc: 'PostgreSQL, MySQL, MariaDB, MongoDB' },
{ id: 'managed_redis' as const, title: 'Redis', desc: 'In-memory cache & store' },
{ id: 'managed_rabbitmq' as const, title: 'RabbitMQ', desc: 'Message broker' },
{ id: 'managed_database' as const, title: s.typeDatabase, desc: s.typeDatabaseDesc },
{ id: 'managed_redis' as const, title: s.typeRedis, desc: s.typeRedisDesc },
{ id: 'managed_rabbitmq' as const, title: s.typeRabbitmq, desc: s.typeRabbitmqDesc },
] as const
).map((opt) => (
<button
@@ -380,14 +382,14 @@ export default function NewManagedServicePage() {
{step === 1 && kind && (
<div className="space-y-6">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Service name</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{s.serviceName}</label>
<input
className="input-field"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value.toLowerCase() })}
placeholder="my-database"
placeholder={s.serviceNamePlaceholder}
/>
<p className="text-xs text-gray-400 mt-1">Lowercase letters, numbers, and hyphens only</p>
<p className="text-xs text-gray-400 mt-1">{s.nameHint}</p>
</div>
{kind === 'managed_database' && (
@@ -413,7 +415,7 @@ export default function NewManagedServicePage() {
{kind === 'managed_redis' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Redis version</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{s.redisVersion}</label>
<select
className="input-field max-w-xs"
value={form.redisVersion}
@@ -428,7 +430,7 @@ export default function NewManagedServicePage() {
</div>
{form.optionalServiceResources?.redis && (
<OptionalServiceResourceFields
title="Redis resources"
title={s.redisResources}
accentClass="text-red-500"
borderClass="border-red-400"
bgClass="bg-red-50"
@@ -450,7 +452,7 @@ export default function NewManagedServicePage() {
{kind === 'managed_rabbitmq' && (
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">RabbitMQ version</label>
<label className="block text-sm font-medium text-gray-700 mb-1">{s.rabbitmqVersion}</label>
<select
className="input-field max-w-xs"
value={form.rabbitmqVersion}
@@ -465,7 +467,7 @@ export default function NewManagedServicePage() {
</div>
{form.optionalServiceResources?.rabbitmq && (
<OptionalServiceResourceFields
title="RabbitMQ resources"
title={s.rabbitmqResources}
accentClass="text-orange-500"
borderClass="border-orange-400"
bgClass="bg-orange-50"
@@ -495,20 +497,20 @@ export default function NewManagedServicePage() {
) : costData ? (
<>
<div className="bg-emerald-50 rounded-xl p-4 border border-emerald-200">
<p className="text-sm font-medium text-gray-700 mb-3">Billing cycle</p>
<p className="text-sm font-medium text-gray-700 mb-3">{s.billingCycle}</p>
<div className="grid grid-cols-3 gap-2">
{(['hourly', 'monthly', 'yearly'] as const).map((cycle) => (
<button
key={cycle}
type="button"
onClick={() => setSelectedCycle(cycle)}
className={`py-3 rounded-lg border-2 text-sm font-medium capitalize ${
className={`py-3 rounded-lg border-2 text-sm font-medium ${
selectedCycle === cycle ? 'border-emerald-500 bg-white' : 'border-transparent bg-white/50'
}`}
>
{cycle}
{s.cycles[cycle]}
<span className="block text-lg font-bold text-emerald-700 mt-1">
{Number(costData[cycle]).toLocaleString('en-US')} T
{Number(costData[cycle]).toLocaleString('en-US')} {t.common.currencyShort}
</span>
</button>
))}
@@ -516,7 +518,7 @@ export default function NewManagedServicePage() {
</div>
{requiresPayment && (
<div>
<p className="text-sm font-medium text-gray-700 mb-2">Payment method</p>
<p className="text-sm font-medium text-gray-700 mb-2">{s.paymentMethod}</p>
<div className="grid grid-cols-2 gap-3">
<button
type="button"
@@ -526,39 +528,39 @@ export default function NewManagedServicePage() {
}`}
>
<Wallet className="w-5 h-5 text-primary-600" />
<p className="font-semibold text-sm mt-2">Wallet</p>
<p className="font-semibold text-sm mt-2">{s.wallet}</p>
<p className="text-xs text-gray-500">
{Number(walletBalance).toLocaleString('en-US')} T
{!hasEnoughBalance && <span className="text-red-500 block">Insufficient</span>}
{Number(walletBalance).toLocaleString('en-US')} {t.common.currencyShort}
{!hasEnoughBalance && <span className="text-red-500 block">{s.insufficient}</span>}
</p>
</button>
<button
type="button"
onClick={() => setPaymentMethod('gateway')}
className={`p-4 rounded-xl border-2 text-left ${
className={`p-4 rounded-xl border-2 text-left rtl:text-right ${
paymentMethod === 'gateway' ? 'border-primary-500 bg-primary-50' : 'border-gray-200'
}`}
>
<CreditCard className="w-5 h-5 text-emerald-600" />
<p className="font-semibold text-sm mt-2">Pay now</p>
<p className="font-semibold text-sm mt-2">{s.payNow}</p>
</button>
</div>
</div>
)}
</>
) : (
<p className="text-sm text-gray-500 text-center">Pricing unavailable</p>
<p className="text-sm text-gray-500 text-center">{s.pricingUnavailable}</p>
)}
</div>
)}
<div className="flex justify-between mt-8 pt-6 border-t border-gray-100">
<button type="button" onClick={() => setStep(step - 1)} disabled={step === 0} className="btn-ghost disabled:opacity-0">
Back
<span className="rtl:hidden"> </span>{s.back}<span className="ltr:hidden"> </span>
</button>
{step < 2 ? (
<button type="button" onClick={() => setStep(step + 1)} disabled={!canNext()} className="btn-primary disabled:opacity-50">
Next
{s.next}<span className="rtl:hidden"> </span><span className="ltr:hidden"> </span>
</button>
) : (
<button
@@ -570,7 +572,7 @@ export default function NewManagedServicePage() {
{walletPayMutation.isPending || gatewayPayMutation.isPending ? (
<Loader2 className="w-4 h-4 animate-spin inline" />
) : (
'Pay & provision'
s.payProvision
)}
</button>
)}
@@ -580,7 +582,7 @@ export default function NewManagedServicePage() {
{deployStage !== 'idle' && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-white rounded-2xl shadow-2xl max-w-md w-full p-6 space-y-4">
<h3 className="font-bold text-gray-900">Provisioning service</h3>
<h3 className="font-bold text-gray-900">{s.provisioning}</h3>
<div className="space-y-2 text-sm">
<div className="flex items-center gap-2">
{deployStage === 'creating' ? (
@@ -588,7 +590,7 @@ export default function NewManagedServicePage() {
) : (
<CheckCircle className="w-4 h-4 text-green-500" />
)}
Creating service
{s.creatingService}
</div>
{dbDumpFile && kind === 'managed_database' && (
<div className="space-y-1">
@@ -600,7 +602,7 @@ export default function NewManagedServicePage() {
) : (
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
)}
Uploading database dump
{s.uploadingDump}
{deployStage === 'uploading-db' && dbUploadProgress > 0 && (
<span className="text-primary-600 font-semibold">{dbUploadProgress}%</span>
)}
@@ -620,7 +622,7 @@ export default function NewManagedServicePage() {
) : (
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
)}
Payment
{s.payment}
</div>
<div className="flex items-center gap-2">
{deployStage === 'deploying' ? (
@@ -632,7 +634,7 @@ export default function NewManagedServicePage() {
) : (
<div className="w-4 h-4 rounded-full border-2 border-gray-200" />
)}
Deploying
{s.deploying}
</div>
</div>
</div>