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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user