瀏覽代碼

feat: add translations strings for my-apps page

Nicolas Meienberger 2 年之前
父節點
當前提交
91e48f8e01

+ 3 - 1
src/client/components/AppStatus/AppStatus.tsx

@@ -2,10 +2,12 @@ import clsx from 'clsx';
 import React from 'react';
 import { Tooltip } from 'react-tooltip';
 import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
+import { useTranslations } from 'next-intl';
 import styles from './AppStatus.module.scss';
 
 export const AppStatus: React.FC<{ status: AppStatusEnum; lite?: boolean }> = ({ status, lite }) => {
-  const formattedStatus = `${status[0]?.toUpperCase()}${status.substring(1, status.length).toLowerCase()}`;
+  const t = useTranslations('apps');
+  const formattedStatus = t(`status-${status}`);
 
   const classes = clsx('status-dot status-gray', {
     'status-dot-animated status-green': status === 'running',

+ 32 - 27
src/client/components/AppTile/AppTile.tsx

@@ -3,6 +3,7 @@ import React from 'react';
 import { IconDownload } from '@tabler/icons-react';
 import { Tooltip } from 'react-tooltip';
 import type { AppStatus as AppStatusEnum } from '@/server/db/schema';
+import { useTranslations } from 'next-intl';
 import { AppStatus } from '../AppStatus';
 import { AppLogo } from '../AppLogo/AppLogo';
 import { limitText } from '../../modules/AppStore/helpers/table.helpers';
@@ -11,35 +12,39 @@ import { AppInfo } from '../../core/types';
 
 type AppTileInfo = Pick<AppInfo, 'id' | 'name' | 'description' | 'short_desc'>;
 
-export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => (
-  <div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
-    <div className="card card-sm card-link">
-      <Link href={`/apps/${app.id}`} className="nav-link" passHref>
-        <div className="card-body">
-          <div className="d-flex align-items-center">
-            <span className="me-3">
-              <AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
-            </span>
-            <div>
-              <div className="d-flex h-3 align-items-center">
-                <span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
-                <div className={styles.statusContainer}>
-                  <AppStatus lite status={status} />
+export const AppTile: React.FC<{ app: AppTileInfo; status: AppStatusEnum; updateAvailable: boolean }> = ({ app, status, updateAvailable }) => {
+  const t = useTranslations('apps');
+
+  return (
+    <div data-testid={`app-tile-${app.id}`} className="col-sm-6 col-lg-4">
+      <div className="card card-sm card-link">
+        <Link href={`/apps/${app.id}`} className="nav-link" passHref>
+          <div className="card-body">
+            <div className="d-flex align-items-center">
+              <span className="me-3">
+                <AppLogo alt={`${app.name} logo`} className="mr-3 group-hover:scale-105 transition-all" id={app.id} size={60} />
+              </span>
+              <div>
+                <div className="d-flex h-3 align-items-center">
+                  <span className="h4 me-2 mt-1 fw-bolder">{app.name}</span>
+                  <div className={styles.statusContainer}>
+                    <AppStatus lite status={status} />
+                  </div>
                 </div>
+                <div className="text-muted">{limitText(app.short_desc, 50)}</div>
               </div>
-              <div className="text-muted">{limitText(app.short_desc, 50)}</div>
             </div>
           </div>
-        </div>
-        {updateAvailable && (
-          <>
-            <Tooltip anchorSelect=".updateAvailable">Update available</Tooltip>
-            <div className="updateAvailable ribbon bg-green ribbon-top">
-              <IconDownload size={20} />
-            </div>
-          </>
-        )}
-      </Link>
+          {updateAvailable && (
+            <>
+              <Tooltip anchorSelect=".updateAvailable">{t('update-available')}</Tooltip>
+              <div className="updateAvailable ribbon bg-green ribbon-top">
+                <IconDownload size={20} />
+              </div>
+            </>
+          )}
+        </Link>
+      </div>
     </div>
-  </div>
-);
+  );
+};

+ 80 - 0
src/client/messages/en.json

@@ -88,6 +88,86 @@
       }
     }
   },
+  "apps": {
+    "status-running": "Running",
+    "status-stopped": "Stopped",
+    "status-starting": "Starting",
+    "status-stopping": "Stopping",
+    "status-updating": "Updating",
+    "status-missing": "Missing",
+    "status-installing": "Installing",
+    "status-uninstalling": "Uninstalling",
+    "update-available": "Update available",
+    "my-apps": {
+      "title": "My Apps",
+      "empty-title": "No app installed",
+      "empty-subtitle": "Install an app from the app store to get started",
+      "empty-action": "Go to app store"
+    },
+    "app-store": {},
+    "app-details": {
+      "install-success": "App installed successfully",
+      "uninstall-success": "App uninstalled successfully",
+      "stop-success": "App stopped successfully",
+      "update-success": "App updated successfully",
+      "start-success": "App started successfully",
+      "update-config-success": "App config updated successfully. Restart the app to apply the changes",
+      "version": "Version",
+      "actions": {
+        "start": "Start",
+        "remove": "Remove",
+        "settings": "Settings",
+        "stop": "Stop",
+        "open": "Open",
+        "loading": "Loading",
+        "cancel": "Cancel",
+        "install": "Install",
+        "update": "Update"
+      },
+      "install-form": {
+        "title": "Install {name}",
+        "expose-app": "Expose app",
+        "domain-name": "Domain name",
+        "domain-name-hint": "Make sure this exact domain contains an A record pointing to your IP.",
+        "choose-option": "Choose an option...",
+        "sumbit-install": "Install",
+        "submit-update": "Update",
+        "errors": {
+          "required": "{label} is required",
+          "regex": "{label} must match the pattern {pattern}",
+          "max-length": "{label} must be less than {max} characters",
+          "min-length": "{label} must be at least {min} characters",
+          "between-length": "{label} must be between {min} and {max} characters",
+          "invalid-email": "{label} must be a valid email address",
+          "number": "{label} must be a number",
+          "fqdn": "{label} must be a valid domain",
+          "ip": "{label} must be a valid IP address",
+          "fqdnip": "{label} must be a valid domain or IP address",
+          "url": "{label} must be a valid URL"
+        }
+      },
+      "stop-form": {
+        "title": "Stop {name} ?",
+        "subtitle": "All data will be retained",
+        "submit": "Stop"
+      },
+      "uninstall-form": {
+        "title": "Uninstall {name} ?",
+        "subtitle": "All data for this app will be lost.",
+        "warning": "Are you sure? This action cannot be undone.",
+        "submit": "Uninstall"
+      },
+      "update-form": {
+        "title": "Update {name} ?",
+        "subtitle1": "Update app to latest verion :",
+        "subtitle2": "This will reset your custom configuration (e.g. changes in docker-compose.yml)",
+        "submit": "Update"
+      },
+      "update-settings-form": {
+        "title": "Update {name} config"
+      }
+    }
+  },
   "header": {
     "dashboard": "Dashboard",
     "my-apps": "My Apps",

+ 11 - 9
src/client/modules/Apps/components/AppActions/AppActions.tsx

@@ -3,6 +3,7 @@ import clsx from 'clsx';
 import React from 'react';
 import type { AppStatus } from '@/server/db/schema';
 
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { AppInfo } from '../../../../core/types';
 
@@ -43,19 +44,20 @@ const ActionButton: React.FC<BtnProps> = (props) => {
 };
 
 export const AppActions: React.FC<IProps> = ({ info, status, onInstall, onUninstall, onStart, onStop, onOpen, onUpdate, onCancel, updateAvailable, onUpdateSettings }) => {
+  const t = useTranslations('apps.app-details');
   const hasSettings = Object.keys(info.form_fields).length > 0 || info.exposable;
 
   const buttons: JSX.Element[] = [];
 
-  const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title="Start" color="success" />;
-  const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title="Remove" color="danger" />;
-  const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title="Settings" />;
-  const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title="Stop" color="danger" />;
-  const OpenButton = <ActionButton key="open" IconComponent={IconExternalLink} onClick={onOpen} title="Open" />;
-  const LoadingButtion = <ActionButton key="loading" loading onClick={() => null} color="success" title="Loading" />;
-  const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title="Cancel" />;
-  const InstallButton = <ActionButton key="install" onClick={onInstall} title="Install" color="success" />;
-  const UpdateButton = <ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title="Update" color="success" />;
+  const StartButton = <ActionButton key="start" IconComponent={IconPlayerPlay} onClick={onStart} title={t('actions.start')} color="success" />;
+  const RemoveButton = <ActionButton key="remove" IconComponent={IconTrash} onClick={onUninstall} title={t('actions.remove')} color="danger" />;
+  const SettingsButton = <ActionButton key="settings" IconComponent={IconSettings} onClick={onUpdateSettings} title={t('actions.settings')} />;
+  const StopButton = <ActionButton key="stop" IconComponent={IconPlayerPause} onClick={onStop} title={t('actions.stop')} color="danger" />;
+  const OpenButton = <ActionButton key="open" IconComponent={IconExternalLink} onClick={onOpen} title={t('actions.open')} />;
+  const LoadingButtion = <ActionButton key="loading" loading onClick={() => null} color="success" title={t('actions.loading')} />;
+  const CancelButton = <ActionButton key="cancel" IconComponent={IconX} onClick={onCancel} title={t('actions.cancel')} />;
+  const InstallButton = <ActionButton key="install" onClick={onInstall} title={t('actions.install')} color="success" />;
+  const UpdateButton = <ActionButton key="update" IconComponent={IconDownload} onClick={onUpdate} width={null} title={t('actions.update')} color="success" />;
 
   switch (status) {
     case 'stopped':

+ 7 - 7
src/client/modules/Apps/components/InstallForm/InstallForm.tsx

@@ -4,6 +4,7 @@ import { Controller, useForm } from 'react-hook-form';
 import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/Select';
 import { Tooltip } from 'react-tooltip';
 import clsx from 'clsx';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { Switch } from '../../../../components/ui/Switch';
 import { Input } from '../../../../components/ui/Input';
@@ -28,6 +29,7 @@ const hiddenTypes = ['random'];
 const typeFilter = (field: FormField) => !hiddenTypes.includes(field.type);
 
 export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, initalValues, loading }) => {
+  const t = useTranslations('apps.app-details.install-form');
   const {
     register,
     handleSubmit,
@@ -87,7 +89,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
           render={({ field: { onChange, value, ref, ...props } }) => (
             <Select value={value as string} defaultValue={field.default as string} onValueChange={onChange} {...props}>
               <SelectTrigger className="mb-3" error={errors[field.env_variable]?.message} label={label}>
-                <SelectValue placeholder="Choose an option..." />
+                <SelectValue placeholder={t('choose-option')} />
               </SelectTrigger>
               <SelectContent>
                 {field.options?.map((option) => (
@@ -122,15 +124,13 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
         name="exposed"
         defaultValue={false}
         render={({ field: { onChange, value, ref, ...props } }) => (
-          <Switch className="mb-3" disabled={info.force_expose} ref={ref} checked={value} onCheckedChange={onChange} {...props} label="Expose app" />
+          <Switch className="mb-3" disabled={info.force_expose} ref={ref} checked={value} onCheckedChange={onChange} {...props} label={t('expose-app')} />
         )}
       />
       {watchExposed && (
         <div className="mb-3">
-          <Input {...register('domain')} label="Domain name" error={errors.domain?.message} disabled={loading} placeholder="Domain name" />
-          <span className="text-muted">
-            Make sure this exact domain contains an <strong>A</strong> record pointing to your IP.
-          </span>
+          <Input {...register('domain')} label={t('domain-name')} error={errors.domain?.message} disabled={loading} placeholder={t('domain-name')} />
+          <span className="text-muted">{t('domain-name-hint')}</span>
         </div>
       )}
     </>
@@ -155,7 +155,7 @@ export const InstallForm: React.FC<IProps> = ({ formFields, info, onSubmit, init
       {formFields.filter(typeFilter).map(renderField)}
       {info.exposable && renderExposeForm()}
       <Button loading={loading} type="submit" className="btn-success">
-        {initalValues ? 'Update' : 'Install'}
+        {initalValues ? t('submit-update') : t('sumbit-install')}
       </Button>
     </form>
   );

+ 17 - 12
src/client/modules/Apps/components/InstallModal/InstallModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
+import { useTranslations } from 'next-intl';
 import { InstallForm } from '../InstallForm';
 import { AppInfo } from '../../../../core/types';
 import { FormValues } from '../InstallForm/InstallForm';
@@ -11,15 +12,19 @@ interface IProps {
   onSubmit: (values: FormValues) => void;
 }
 
-export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => (
-  <Dialog open={isOpen} onOpenChange={onClose}>
-    <DialogContent>
-      <DialogHeader>
-        <h5 className="modal-title">Install {info.name}</h5>
-      </DialogHeader>
-      <DialogDescription>
-        <InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} />
-      </DialogDescription>
-    </DialogContent>
-  </Dialog>
-);
+export const InstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onSubmit }) => {
+  const t = useTranslations('apps.app-details.install-form');
+
+  return (
+    <Dialog open={isOpen} onOpenChange={onClose}>
+      <DialogContent>
+        <DialogHeader>
+          <h5 className="modal-title">{t('title', { name: info.name })}</h5>
+        </DialogHeader>
+        <DialogDescription>
+          <InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} />
+        </DialogDescription>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 22 - 17
src/client/modules/Apps/components/StopModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../components/ui/Button';
 import { AppInfo } from '../../../core/types';
 
@@ -10,20 +11,24 @@ interface IProps {
   onConfirm: () => void;
 }
 
-export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
-  <Dialog open={isOpen} onOpenChange={onClose}>
-    <DialogContent size="sm">
-      <DialogHeader>
-        <h5 className="modal-title">Stop {info.name} ?</h5>
-      </DialogHeader>
-      <DialogDescription>
-        <div className="text-muted">All data will be retained</div>
-      </DialogDescription>
-      <DialogFooter>
-        <Button onClick={onConfirm} className="btn-danger">
-          Stop
-        </Button>
-      </DialogFooter>
-    </DialogContent>
-  </Dialog>
-);
+export const StopModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
+  const t = useTranslations('apps.app-details.stop-form');
+
+  return (
+    <Dialog open={isOpen} onOpenChange={onClose}>
+      <DialogContent size="sm">
+        <DialogHeader>
+          <h5 className="modal-title">{t('title', { name: info.name })}</h5>
+        </DialogHeader>
+        <DialogDescription>
+          <div className="text-muted">{t('subtitle')}</div>
+        </DialogDescription>
+        <DialogFooter>
+          <Button onClick={onConfirm} className="btn-danger">
+            {t('submit')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 24 - 19
src/client/modules/Apps/components/UninstallModal.tsx

@@ -1,6 +1,7 @@
 import { IconAlertTriangle } from '@tabler/icons-react';
 import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../components/ui/Button';
 import { AppInfo } from '../../../core/types';
 
@@ -11,22 +12,26 @@ interface IProps {
   onConfirm: () => void;
 }
 
-export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => (
-  <Dialog open={isOpen} onOpenChange={onClose}>
-    <DialogContent type="danger" size="sm">
-      <DialogHeader>
-        <h5 className="modal-title">Uninstall {info.name} ?</h5>
-      </DialogHeader>
-      <DialogDescription className="text-center py-4">
-        <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
-        <h3>Are you sure?</h3>
-        <div className="text-muted">All data for this app will be lost.</div>
-      </DialogDescription>
-      <DialogFooter>
-        <Button onClick={onConfirm} className="btn-danger">
-          Uninstall
-        </Button>
-      </DialogFooter>
-    </DialogContent>
-  </Dialog>
-);
+export const UninstallModal: React.FC<IProps> = ({ info, isOpen, onClose, onConfirm }) => {
+  const t = useTranslations('apps.app-details.uninstall-form');
+
+  return (
+    <Dialog open={isOpen} onOpenChange={onClose}>
+      <DialogContent type="danger" size="sm">
+        <DialogHeader>
+          <h5 className="modal-title">{t('title', { name: info.name })}</h5>
+        </DialogHeader>
+        <DialogDescription className="text-center py-4">
+          <IconAlertTriangle className="icon mb-2 text-danger icon-lg" />
+          <h3>{t('warning')}</h3>
+          <div className="text-muted">{t('subtitle')}</div>
+        </DialogDescription>
+        <DialogFooter>
+          <Button onClick={onConfirm} className="btn-danger">
+            {t('submit')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 25 - 20
src/client/modules/Apps/components/UpdateModal/UpdateModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader } from '@/components/ui/Dialog';
+import { useTranslations } from 'next-intl';
 import { Button } from '../../../../components/ui/Button';
 import { AppInfo } from '../../../../core/types';
 
@@ -11,23 +12,27 @@ interface IProps {
   onConfirm: () => void;
 }
 
-export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => (
-  <Dialog open={isOpen} onOpenChange={onClose}>
-    <DialogContent size="sm">
-      <DialogHeader>
-        <h5 className="modal-title">Update {info.name} ?</h5>
-      </DialogHeader>
-      <DialogDescription>
-        <div className="text-muted">
-          Update app to latest verion : <b>{newVersion}</b> ?<br />
-          This will reset your custom configuration (e.g. changes in docker-compose.yml)
-        </div>
-      </DialogDescription>
-      <DialogFooter>
-        <Button onClick={onConfirm} className="btn-success">
-          Update
-        </Button>
-      </DialogFooter>
-    </DialogContent>
-  </Dialog>
-);
+export const UpdateModal: React.FC<IProps> = ({ info, newVersion, isOpen, onClose, onConfirm }) => {
+  const t = useTranslations('apps.app-details.update-form');
+
+  return (
+    <Dialog open={isOpen} onOpenChange={onClose}>
+      <DialogContent size="sm">
+        <DialogHeader>
+          <h5 className="modal-title">{t('title', { name: info.name })}</h5>
+        </DialogHeader>
+        <DialogDescription>
+          <div className="text-muted">
+            {t('subtitle1')} <b>{newVersion}</b> ?<br />
+            {t('subtitle2')}
+          </div>
+        </DialogDescription>
+        <DialogFooter>
+          <Button onClick={onConfirm} className="btn-success">
+            {t('submit')}
+          </Button>
+        </DialogFooter>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 17 - 12
src/client/modules/Apps/components/UpdateSettingsModal.tsx

@@ -1,5 +1,6 @@
 import React from 'react';
 import { Dialog, DialogContent, DialogDescription, DialogHeader } from '@/components/ui/Dialog';
+import { useTranslations } from 'next-intl';
 import { InstallForm } from './InstallForm';
 import { AppInfo } from '../../../core/types';
 import { FormValues } from './InstallForm/InstallForm';
@@ -14,15 +15,19 @@ interface IProps {
   onSubmit: (values: FormValues) => void;
 }
 
-export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => (
-  <Dialog open={isOpen} onOpenChange={onClose}>
-    <DialogContent>
-      <DialogHeader>
-        <h5 className="modal-title">Update {info.name} config</h5>
-      </DialogHeader>
-      <DialogDescription>
-        <InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config, exposed, domain }} />
-      </DialogDescription>
-    </DialogContent>
-  </Dialog>
-);
+export const UpdateSettingsModal: React.FC<IProps> = ({ info, config, isOpen, onClose, onSubmit, exposed, domain }) => {
+  const t = useTranslations('apps.app-details.update-settings-form');
+
+  return (
+    <Dialog open={isOpen} onOpenChange={onClose}>
+      <DialogContent>
+        <DialogHeader>
+          <h5 className="modal-title">{t('title', { name: info.name })}</h5>
+        </DialogHeader>
+        <DialogDescription>
+          <InstallForm onSubmit={onSubmit} formFields={info.form_fields} info={info} initalValues={{ ...config, exposed, domain }} />
+        </DialogDescription>
+      </DialogContent>
+    </Dialog>
+  );
+};

+ 19 - 12
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.test.tsx

@@ -1,4 +1,5 @@
 import React from 'react';
+import { faker } from '@faker-js/faker';
 import { fireEvent, render, screen, waitFor } from '../../../../../../tests/test-utils';
 import { createAppEntity } from '../../../../mocks/fixtures/app.fixtures';
 import { getTRPCMock, getTRPCMockError } from '../../../../mocks/getTrpcMock';
@@ -126,11 +127,12 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display a toast error when install mutation fails', async () => {
       // Arrange
+      const error = faker.lorem.sentence();
       server.use(
         getTRPCMockError({
           path: ['app', 'installApp'],
           type: 'mutation',
-          message: 'my big error',
+          message: error,
         }),
       );
 
@@ -144,7 +146,7 @@ describe('Test: AppDetailsContainer', () => {
       fireEvent.click(installButton);
 
       await waitFor(() => {
-        expect(screen.getByText('Failed to install app: my big error')).toBeInTheDocument();
+        expect(screen.getByText(error)).toBeInTheDocument();
       });
     });
   });
@@ -169,7 +171,8 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display a toast error when update mutation fails', async () => {
       // Arrange
-      server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: 'my big error' }));
+      const error = faker.lorem.sentence();
+      server.use(getTRPCMockError({ path: ['app', 'updateApp'], type: 'mutation', message: error }));
       const app = createAppEntity({ overrides: { version: 2, latestVersion: 3 }, overridesInfo: { tipi_version: 3 } });
       render(<AppDetailsContainer app={app} />);
       const openModalButton = screen.getByRole('button', { name: 'Update' });
@@ -181,7 +184,7 @@ describe('Test: AppDetailsContainer', () => {
 
       // Assert
       await waitFor(() => {
-        expect(screen.getByText('Failed to update app: my big error')).toBeInTheDocument();
+        expect(screen.getByText(error)).toBeInTheDocument();
       });
     });
   });
@@ -207,7 +210,8 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display a toast error when uninstall mutation fails', async () => {
       // Arrange
-      server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: 'my big error' }));
+      const error = faker.lorem.sentence();
+      server.use(getTRPCMockError({ path: ['app', 'uninstallApp'], type: 'mutation', message: error }));
       const app = createAppEntity({ status: 'stopped' });
       render(<AppDetailsContainer app={app} />);
       const openModalButton = screen.getByRole('button', { name: 'Remove' });
@@ -219,7 +223,7 @@ describe('Test: AppDetailsContainer', () => {
 
       // Assert
       await waitFor(() => {
-        expect(screen.getByText('Failed to uninstall app: my big error')).toBeInTheDocument();
+        expect(screen.getByText(error)).toBeInTheDocument();
       });
     });
   });
@@ -243,7 +247,8 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display a toast error when start mutation fails', async () => {
       // Arrange
-      server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: 'my big error' }));
+      const error = faker.lorem.sentence();
+      server.use(getTRPCMockError({ path: ['app', 'startApp'], type: 'mutation', message: error }));
       const app = createAppEntity({ status: 'stopped' });
       render(<AppDetailsContainer app={app} />);
 
@@ -253,7 +258,7 @@ describe('Test: AppDetailsContainer', () => {
 
       // Assert
       await waitFor(() => {
-        expect(screen.getByText('Failed to start app: my big error')).toBeInTheDocument();
+        expect(screen.getByText(error)).toBeInTheDocument();
       });
     });
   });
@@ -279,7 +284,8 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display a toast error when stop mutation fails', async () => {
       // Arrange
-      server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: 'my big error' }));
+      const error = faker.lorem.sentence();
+      server.use(getTRPCMockError({ path: ['app', 'stopApp'], type: 'mutation', message: error }));
       const app = createAppEntity({ status: 'running' });
       render(<AppDetailsContainer app={app} />);
       const openModalButton = screen.getByRole('button', { name: 'Stop' });
@@ -291,7 +297,7 @@ describe('Test: AppDetailsContainer', () => {
 
       // Assert
       await waitFor(() => {
-        expect(screen.getByText('Failed to stop app: my big error')).toBeInTheDocument();
+        expect(screen.getByText(error)).toBeInTheDocument();
       });
     });
   });
@@ -317,7 +323,8 @@ describe('Test: AppDetailsContainer', () => {
 
     it('should display a toast error when update config mutation fails', async () => {
       // Arrange
-      server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: 'my big error' }));
+      const error = faker.lorem.sentence();
+      server.use(getTRPCMockError({ path: ['app', 'updateAppConfig'], type: 'mutation', message: error }));
       const app = createAppEntity({ status: 'running', overridesInfo: { exposable: true } });
       render(<AppDetailsContainer app={app} />);
       const openModalButton = screen.getByRole('button', { name: 'Settings' });
@@ -329,7 +336,7 @@ describe('Test: AppDetailsContainer', () => {
 
       // Assert
       await waitFor(() => {
-        expect(screen.getByText('Failed to update app config: my big error')).toBeInTheDocument();
+        expect(screen.getByText(error)).toBeInTheDocument();
       });
     });
   });

+ 17 - 13
src/client/modules/Apps/containers/AppDetailsContainer/AppDetailsContainer.tsx

@@ -1,5 +1,7 @@
 import React from 'react';
 import { toast } from 'react-hot-toast';
+import { useTranslations } from 'next-intl';
+import type { MessageKey } from '@/server/utils/errors';
 import { useDisclosure } from '../../../../hooks/useDisclosure';
 import { AppLogo } from '../../../../components/AppLogo/AppLogo';
 import { AppStatus } from '../../../../components/AppStatus';
@@ -20,6 +22,7 @@ interface IProps {
 }
 
 export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
+  const t = useTranslations();
   const installDisclosure = useDisclosure();
   const uninstallDisclosure = useDisclosure();
   const stopDisclosure = useDisclosure();
@@ -40,11 +43,12 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      toast.success('App installed successfully');
+      toast.success(t('apps.app-details.install-success'));
     },
     onError: (e) => {
       invalidate();
-      toast.error(`Failed to install app: ${e.message}`);
+      const toastMessage = t(e.data?.translatedError || (e.message as MessageKey));
+      toast.error(toastMessage);
     },
   });
 
@@ -55,9 +59,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      toast.success('App uninstalled successfully');
+      toast.success(t('apps.app-details.uninstall-success'));
     },
-    onError: (e) => toast.error(`Failed to uninstall app: ${e.message}`),
+    onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
   });
 
   const stop = trpc.app.stopApp.useMutation({
@@ -67,9 +71,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      toast.success('App stopped successfully');
+      toast.success(t('apps.app-details.stop-success'));
     },
-    onError: (e) => toast.error(`Failed to stop app: ${e.message}`),
+    onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
   });
 
   const update = trpc.app.updateApp.useMutation({
@@ -79,9 +83,9 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      toast.success('App updated successfully');
+      toast.success(t('apps.app-details.update-success'));
     },
-    onError: (e) => toast.error(`Failed to update app: ${e.message}`),
+    onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
   });
 
   const start = trpc.app.startApp.useMutation({
@@ -90,18 +94,18 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
     },
     onSuccess: () => {
       invalidate();
-      toast.success('App started successfully');
+      toast.success(t('apps.app-details.start-success'));
     },
-    onError: (e) => toast.error(`Failed to start app: ${e.message}`),
+    onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
   });
 
   const updateConfig = trpc.app.updateAppConfig.useMutation({
     onMutate: () => updateSettingsDisclosure.close(),
     onSuccess: () => {
       invalidate();
-      toast.success('App config updated successfully. Restart the app to apply the changes');
+      toast.success(t('apps.app-details.update-config-success'));
     },
-    onError: (e) => toast.error(`Failed to update app config: ${e.message}`),
+    onError: (e) => toast.error(t(e.data?.translatedError || (e.message as MessageKey))),
   });
 
   const updateAvailable = Number(app.version || 0) < Number(app?.latestVersion || 0);
@@ -164,7 +168,7 @@ export const AppDetailsContainer: React.FC<IProps> = ({ app }) => {
         <AppLogo id={app.id} size={130} alt={app.info.name} />
         <div className="w-100 d-flex flex-column ms-md-3 align-items-center align-items-md-start">
           <div>
-            <span className="mt-1 me-1">Version: </span>
+            <span className="mt-1 me-1">{t('apps.app-details.version')}: </span>
             <span className="badge bg-gray mt-2">{app.info.version}</span>
           </div>
           {app.domain && (

+ 5 - 2
src/client/modules/Apps/pages/AppDetailsPage/AppDetailsPage.tsx

@@ -1,6 +1,8 @@
 import { NextPage } from 'next';
 import React from 'react';
 import { useRouter } from 'next/router';
+import type { MessageKey } from '@/server/utils/errors';
+import { useTranslations } from 'next-intl';
 import { Layout } from '../../../../components/Layout';
 import { ErrorPage } from '../../../../components/ui/ErrorPage';
 import { trpc } from '../../../../utils/trpc';
@@ -18,6 +20,7 @@ const paths: Record<string, Path> = {
 
 export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
   const router = useRouter();
+  const t = useTranslations();
 
   const basePath = router.pathname.split('/').slice(1)[0];
   const { refSlug, refTitle } = paths[basePath || 'apps'] || { refSlug: 'apps', refTitle: 'Apps' };
@@ -31,9 +34,9 @@ export const AppDetailsPage: NextPage<IProps> = ({ appId }) => {
 
   // TODO: add loading state
   return (
-    <Layout title={data?.info.name} breadcrumbs={breadcrumb}>
+    <Layout title={data?.info.name || ''} breadcrumbs={breadcrumb}>
       {data?.info && <AppDetailsContainer app={data} />}
-      {error && <ErrorPage error={error.message} />}
+      {error && <ErrorPage error={t(error.data?.translatedError || (error.message as MessageKey))} />}
     </Layout>
   );
 };

+ 6 - 3
src/client/modules/Apps/pages/AppsPage/AppsPage.tsx

@@ -1,6 +1,8 @@
 import React from 'react';
 import { useRouter } from 'next/router';
 import { NextPage } from 'next';
+import { useTranslations } from 'next-intl';
+import { MessageKey } from '@/server/utils/errors';
 import { AppTile } from '../../../../components/AppTile';
 import { Layout } from '../../../../components/Layout';
 import { EmptyPage } from '../../../../components/ui/EmptyPage';
@@ -9,6 +11,7 @@ import { trpc } from '../../../../utils/trpc';
 import { AppRouterOutput } from '../../../../../server/routers/app/app.router';
 
 export const AppsPage: NextPage = () => {
+  const t = useTranslations();
   const { data, isLoading, error } = trpc.app.installedApps.useQuery();
 
   const renderApp = (app: AppRouterOutput['installedApps'][number]) => {
@@ -22,7 +25,7 @@ export const AppsPage: NextPage = () => {
   const router = useRouter();
 
   return (
-    <Layout title="My Apps">
+    <Layout title={t('apps.my-apps.title')}>
       <div>
         {Boolean(data?.length) && (
           <div className="row row-cards" data-testid="apps-list">
@@ -30,9 +33,9 @@ export const AppsPage: NextPage = () => {
           </div>
         )}
         {!isLoading && data?.length === 0 && (
-          <EmptyPage title="No app installed" subtitle="Install an app from the app store to get started" onAction={() => router.push('/app-store')} actionLabel="Go to app store" />
+          <EmptyPage title={t('apps.my-apps.empty-title')} subtitle={t('apps.my-apps.empty-subtitle')} onAction={() => router.push('/app-store')} actionLabel={t('apps.my-apps.empty-action')} />
         )}
-        {error && <ErrorPage error={error.message} />}
+        {error && <ErrorPage error={t(error.data?.translatedError || (error.message as MessageKey))} />}
       </div>
     </Layout>
   );

+ 16 - 12
src/client/modules/Apps/utils/validators/validators.ts

@@ -1,9 +1,12 @@
 import validator from 'validator';
+import { useUIStore } from '@/client/state/uiStore';
 import type { FormField } from '../../../../core/types';
 
 export const validateField = (field: FormField, value: string | undefined | boolean): string | undefined => {
+  const { translator } = useUIStore.getState();
+
   if (field.required && !value) {
-    return `${field.label} is required`;
+    return translator('apps.app-details.install-form.errors.required', { label: field.label });
   }
 
   if (!value || typeof value !== 'string') {
@@ -11,51 +14,51 @@ export const validateField = (field: FormField, value: string | undefined | bool
   }
 
   if (field.regex && !validator.matches(value, field.regex)) {
-    return field.pattern_error || `${field.label} must match the pattern ${field.regex}`;
+    return field.pattern_error || translator('apps.app-details.install-form.errors.regex', { label: field.label, pattern: field.regex });
   }
 
   switch (field.type) {
     case 'text':
       if (field.max && value.length > field.max) {
-        return `${field.label} must be less than ${field.max} characters`;
+        return translator('apps.app-details.install-form.errors.max-length', { label: field.label, max: field.max });
       }
       if (field.min && value.length < field.min) {
-        return `${field.label} must be at least ${field.min} characters`;
+        return translator('apps.app-details.install-form.errors.min-length', { label: field.label, min: field.min });
       }
       break;
     case 'password':
       if (!validator.isLength(value, { min: field.min || 0, max: field.max || 100 })) {
-        return `${field.label} must be between ${String(field.min)} and ${String(field.max)} characters`;
+        return translator('apps.app-details.install-form.errors.between-length', { label: field.label, min: field.min, max: field.max });
       }
       break;
     case 'email':
       if (!validator.isEmail(value)) {
-        return `${field.label} must be a valid email address`;
+        return translator('apps.app-details.install-form.errors.invalid-email', { label: field.label });
       }
       break;
     case 'number':
       if (!validator.isNumeric(value)) {
-        return `${field.label} must be a number`;
+        return translator('apps.app-details.install-form.errors.number', { label: field.label });
       }
       break;
     case 'fqdn':
       if (!validator.isFQDN(value)) {
-        return `${field.label} must be a valid domain`;
+        return translator('apps.app-details.install-form.errors.fqdn', { label: field.label });
       }
       break;
     case 'ip':
       if (!validator.isIP(value)) {
-        return `${field.label} must be a valid IP address`;
+        return translator('apps.app-details.install-form.errors.ip', { label: field.label });
       }
       break;
     case 'fqdnip':
       if (!validator.isFQDN(value || '') && !validator.isIP(value)) {
-        return `${field.label} must be a valid domain or IP address`;
+        return translator('apps.app-details.install-form.errors.fqdnip', { label: field.label });
       }
       break;
     case 'url':
       if (!validator.isURL(value)) {
-        return `${field.label} must be a valid URL`;
+        return translator('apps.app-details.install-form.errors.url', { label: field.label });
       }
       break;
     default:
@@ -67,7 +70,8 @@ export const validateField = (field: FormField, value: string | undefined | bool
 
 const validateDomain = (domain?: string | boolean): string | undefined => {
   if (typeof domain !== 'string' || !validator.isFQDN(domain || '')) {
-    return `${String(domain)} must be a valid domain`;
+    const { translator } = useUIStore.getState();
+    return translator('apps.app-details.install-form.errors.fqdn', { label: String(domain) });
   }
 
   return undefined;

+ 6 - 0
src/client/state/uiStore.ts

@@ -1,8 +1,13 @@
+import { createTranslator } from 'next-intl';
 import { create } from 'zustand';
+import englishMessages from '../messages/en.json';
+
+const defaultTranslator = createTranslator({ locale: 'en', messages: englishMessages });
 
 type UIStore = {
   menuItem: string;
   darkMode: boolean;
+  translator: typeof defaultTranslator;
   setMenuItem: (menuItem: string) => void;
   setDarkMode: (darkMode: boolean) => void;
 };
@@ -10,6 +15,7 @@ type UIStore = {
 export const useUIStore = create<UIStore>((set) => ({
   menuItem: 'dashboard',
   darkMode: false,
+  translator: defaultTranslator,
   setDarkMode: (darkMode: boolean) => {
     if (darkMode) {
       localStorage.setItem('darkMode', darkMode.toString());

+ 7 - 1
src/client/utils/page-helpers.ts

@@ -2,6 +2,8 @@ import nookies from 'nookies';
 import { GetServerSideProps } from 'next';
 import merge from 'lodash.merge';
 import { getLocaleFromString } from '@/shared/internationalization/locales';
+import { createTranslator } from 'next-intl';
+import { useUIStore } from '../state/uiStore';
 
 export const getAuthedPageProps: GetServerSideProps = async (ctx) => {
   const { userId } = ctx.req.session;
@@ -38,10 +40,14 @@ export const getMessagesPageProps: GetServerSideProps = async (ctx) => {
   }
 
   const messages = (await import(`../messages/${locale}.json`)).default;
+  const mergedMessages = merge(englishMessages, messages);
+
+  const translator = createTranslator({ locale, messages: mergedMessages });
+  useUIStore.setState({ translator });
 
   return {
     props: {
-      messages: merge(englishMessages, messages),
+      messages: mergedMessages,
     },
   };
 };