feat(mail-ingest): admin card, review mark, inbox events and links, translations

- Admin settings (desktop and phone, self-hosted only): the instance switch,
  how mail arrives (IMAP or a signed webhook) with its fields, the address
  template, the AI allowance per person and day, a connection test and the
  status in counts. Changed fields only are saved; secrets stay masked.
- The review mark of an imported booking takes its mark off: the pill in the
  desktop dialog and a row in the phone's transport sheet ('Looks right'),
  through the ordinary booking update. Its hint no longer speaks of airports.
- The inbox follows mailInbox:changed and mailInbox:settings, and
  ?inbox=mail (the 'bookings waiting' notification) opens it.
- mailIngestRepo: the online-only path the views take, answers parsed
  against the shared contracts.
- Dropped 'read with AI' and the unused mail-review contract: the server
  reads a passing AI failure again on its own.
- mailInbox, mailAdmin and the new reservation, settings and notification
  strings in all 28 locales; the pending notice is a plural group.
- The live flight line on the booking cards is one of the card's fields,
  full width on the phone; the phone's live card keeps its map to the middle.
- Admin flight and mail cards sit in the left column.
This commit is contained in:
Maurice
2026-11-13 18:31:07 +01:00
parent bd11cd347d
commit a7adef5353
209 changed files with 5915 additions and 343 deletions
+7
View File
@@ -142,6 +142,12 @@
"desktop": ["src/components/Settings/MailImportSection.tsx"],
"mobile": ["src/mobile/screens/settings/MMailImportSection.tsx"]
},
{
"feature": "Booking mail import: admin card",
"hooks": ["src/components/Admin/mailIngest/useMailIngestAdmin.ts", "src/components/Admin/mailIngest/mailIngestAdminModel.ts"],
"desktop": ["src/components/Admin/mailIngest/MailIngestAdminCard.tsx"],
"mobile": ["src/mobile/screens/admin/MMailIngestAdminCard.tsx"]
},
{
"feature": "Booking save follow-up: travellers and files before the editor closes",
"hooks": ["src/components/Planner/useBookingFollowUp.ts"],
@@ -1039,6 +1045,7 @@
}
],
"mobileOnly": {
"src/mobile/screens/trip/sheets/MReviewNotice.tsx": "The transport sheet's row asking for a look at an imported booking; the desktop dialog folds the same \"looks right\" action into its review pill (ReviewPill in bookingParts.tsx), so there is no separate twin.",
"src/mobile/screens/mail/MMailInboxHost.tsx": "Mounts the phone shell's one inbox sheet and reads the person's import settings; the desktop mounts its dialog from the Navbar, so there is no twin.",
"src/mobile/screens/admin/MAdminOidcCard.tsx": "Markup only; the OIDC card of MAdminSettingsSection, with the form state from useAdmin and the save from its useAdminSettingsActions handed in through props.",
"src/mobile/screens/admin/MAdminResetMfaSheet.tsx": "Markup only; the 2FA reset confirm that MAdminSheets opens, with the reset handed in through props from its useAdminUserActions.",
+9 -4
View File
@@ -9,9 +9,17 @@ import apiClient from './client';
*/
const data = (response: { data: unknown }) => response.data;
export type MailIngestAction = 'apply' | 'move' | 'file' | 'undo' | 'discard' | 'read-ai';
export type MailIngestAction = 'apply' | 'move' | 'file' | 'undo' | 'discard';
export const mailIngestApi = {
// The admin's view: how the import runs, in counts only, and whether the transport reaches its mail now.
adminStatus: (): Promise<unknown> => apiClient.get('/admin/mail-ingest/status').then(data),
adminTest: (): Promise<unknown> => apiClient.post('/admin/mail-ingest/test', {}).then(data),
/** The stored transport fields, passwords masked, from the admin's app settings. */
adminSettings: (): Promise<unknown> => apiClient.get('/auth/app-settings').then(data),
saveAdminSettings: (patch: Record<string, string | boolean>): Promise<unknown> =>
apiClient.put('/auth/app-settings', patch).then(data),
settings: (): Promise<unknown> => apiClient.get('/mail-ingest/settings').then(data),
updateSettings: (body: { enabled?: boolean; ai_enabled?: boolean; auto_add?: boolean }): Promise<unknown> =>
apiClient.patch('/mail-ingest/settings', body).then(data),
@@ -32,9 +40,6 @@ export const mailIngestApi = {
act: (candidateId: number, action: MailIngestAction, body: Record<string, unknown> = {}): Promise<unknown> =>
apiClient.post(`/mail-ingest/candidates/${candidateId}/${action}`, body).then(data),
review: (tripId: number, reservationId: number, fields?: string[]): Promise<unknown> =>
apiClient.post(`/trips/${tripId}/reservations/${reservationId}/mail-review`, fields ? { fields } : {}).then(data),
upload: (file: File): Promise<unknown> => {
const form = new FormData();
form.append('file', file);
+3 -3
View File
@@ -23,6 +23,9 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'tours:visits',
// store/flightLiveStore (watchFlightLive, mounted by useFlightLiveSync) merges live flight states.
'flight:live',
// store/mailIngestStore (watchMailInbox, mounted by Navbar and MMailInboxHost): the open count and the settings card.
'mailInbox:changed',
'mailInbox:settings',
// Collab — Collab/MCollab components + useTripWebSocket's collabFileSync
'collab:note:created',
'collab:note:updated',
@@ -161,9 +164,6 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
export const IGNORED_WS_EVENTS: Partial<Record<TrekWsEventName, string>> = {
'todo:assignees':
'No screen shows who looks after a todo category (todoApi.getCategoryAssignees has no caller), so there is nothing to refresh.',
// The mail import's inbox and settings card come with its client screens, which move these to their listener.
'mailInbox:changed': 'No screen shows the mail inbox or its open count yet, so there is nothing to refresh.',
'mailInbox:settings': 'No screen shows the mail import settings card yet, so there is nothing to read again.',
}
/**
@@ -0,0 +1,304 @@
import { CheckCircle, Copy, Loader2, Mail, PlugZap, Save, XCircle } from 'lucide-react';
import type { ReactNode } from 'react';
import { useTranslation } from '../../../i18n';
import type { TranslationFn } from '../../../types';
import { copyText } from '../../../utils/clipboard';
import { relativeTime } from '../../../utils/relativeTime';
import {
SETTINGS_BUTTON,
SETTINGS_BUTTON_PRIMARY,
SettingRow,
SettingRows,
SettingsCard,
SettingsHint,
StatusPill,
} from '../../Settings/settingsKit';
import ToggleSwitch from '../../Settings/ToggleSwitch';
import CustomSelect from '../../shared/CustomSelect';
import { EditorField, GRID_2, INPUT, READONLY_BOX } from '../../shared/dialogParts';
import { useToast } from '../../shared/Toast';
import {
TRANSPORTS,
problemText,
statusFacts,
transportLabel,
type MailAdminForm,
type MailAdminProblem,
} from './mailIngestAdminModel';
import { useMailIngestAdmin, type MailIngestAdmin } from './useMailIngestAdmin';
type T = TranslationFn;
/** The address a mail service posts to, shown so the admin can paste it into the sender's settings. */
function InboundUrl({ t }: { t: T }) {
const toast = useToast();
const url = `${window.location.origin}/api/mail-ingest/inbound`;
return (
<EditorField label={t('mailAdmin.inboundUrl')} hint={t('mailAdmin.inboundUrlHint')}>
<div className="flex gap-2">
<span className={READONLY_BOX}>{url}</span>
<button
type="button"
className={SETTINGS_BUTTON}
aria-label={t('mailAdmin.copyUrl')}
onClick={() => void copyText(url).then((ok) => (ok ? toast.success(t('common.copied')) : undefined))}
>
<Copy size={14} strokeWidth={2} />
</button>
</div>
</EditorField>
);
}
interface FieldsProps {
admin: MailIngestAdmin;
form: MailAdminForm;
t: T;
error: (p: MailAdminProblem) => ReactNode;
}
function ImapFields({ admin, form, t, error }: FieldsProps) {
return (
<div className="flex flex-col gap-3">
<div className={GRID_2}>
<EditorField label={t('mailAdmin.imap.host')} htmlFor="mail-imap-host">
<input
id="mail-imap-host"
className={INPUT}
value={form.imap_host}
placeholder="imap.example.com"
onChange={(e) => admin.set('imap_host', e.target.value)}
/>
</EditorField>
<div className={GRID_2}>
<EditorField label={t('mailAdmin.imap.port')} htmlFor="mail-imap-port" error={error('portInvalid')}>
<input
id="mail-imap-port"
className={INPUT}
inputMode="numeric"
value={form.imap_port}
onChange={(e) => admin.set('imap_port', e.target.value)}
/>
</EditorField>
<EditorField label={t('mailAdmin.imap.tls')}>
<CustomSelect
value={form.imap_tls}
onChange={(value) => admin.set('imap_tls', value === 'starttls' ? 'starttls' : 'tls')}
options={[
{ value: 'tls', label: 'TLS' },
{ value: 'starttls', label: 'STARTTLS' },
]}
/>
</EditorField>
</div>
</div>
<div className={GRID_2}>
<EditorField label={t('mailAdmin.imap.user')} htmlFor="mail-imap-user">
<input
id="mail-imap-user"
className={INPUT}
autoComplete="off"
value={form.imap_user}
onChange={(e) => admin.set('imap_user', e.target.value)}
/>
</EditorField>
<EditorField label={t('mailAdmin.imap.pass')} htmlFor="mail-imap-pass">
<input
id="mail-imap-pass"
className={INPUT}
type="password"
autoComplete="new-password"
value={form.imap_pass}
onChange={(e) => admin.set('imap_pass', e.target.value)}
/>
</EditorField>
</div>
<EditorField
label={t('mailAdmin.imap.mailbox')}
htmlFor="mail-imap-mailbox"
hint={t('mailAdmin.imap.mailboxHint')}
>
<input
id="mail-imap-mailbox"
className={INPUT}
value={form.imap_mailbox}
onChange={(e) => admin.set('imap_mailbox', e.target.value)}
/>
</EditorField>
</div>
);
}
function WebhookFields({ admin, form, t, error }: FieldsProps) {
return (
<div className="flex flex-col gap-3">
<EditorField
label={t('mailAdmin.webhook.secret')}
htmlFor="mail-webhook-secret"
hint={t('mailAdmin.webhook.secretHint')}
error={error('secretShort')}
>
<input
id="mail-webhook-secret"
className={INPUT}
type="password"
autoComplete="new-password"
value={form.webhook_secret}
onChange={(e) => admin.set('webhook_secret', e.target.value)}
/>
</EditorField>
<InboundUrl t={t} />
</div>
);
}
/** How the import runs, in counts only: no subject, sender or content of a mail is ever shown here. */
function StatusBlock({ admin, t, locale }: { admin: MailIngestAdmin; t: T; locale: string }) {
const status = admin.status;
if (!status) return null;
const facts = statusFacts(status);
const lastRead = status.last_ok_at
? relativeTime(Date.parse(status.last_ok_at), locale)
: t('mailAdmin.status.never');
return (
<div className="flex flex-col gap-2 rounded-[12px] border border-edge-faint bg-surface-secondary px-3.5 py-3">
<div className="flex flex-wrap items-center gap-2">
<span className="text-caption font-semibold uppercase tracking-[0.08em] text-content-faint">
{t('mailAdmin.status.title')}
</span>
<StatusPill tone={status.last_error ? 'warning' : 'neutral'}>
{t('mailAdmin.status.lastRead', { time: lastRead })}
</StatusPill>
</div>
<div className="grid grid-cols-4 gap-2 max-sm:grid-cols-2">
{[
{ label: t('mailAdmin.status.mails'), value: facts.mails },
{ label: t('mailAdmin.status.added'), value: facts.added },
{ label: t('mailAdmin.status.waiting'), value: facts.waiting },
{ label: t('mailAdmin.status.failed'), value: facts.failed },
].map((f) => (
<div key={f.label} className="min-w-0">
<div className="truncate text-caption text-content-faint">{f.label}</div>
<div className="text-body font-semibold tabular-nums text-content">{f.value}</div>
</div>
))}
</div>
{status.last_error && <SettingsHint className="text-warning">{status.last_error}</SettingsHint>}
</div>
);
}
function TestResult({ admin, t }: { admin: MailIngestAdmin; t: T }) {
if (!admin.test) return null;
return admin.test.ok ? (
<StatusPill tone="success" icon={<CheckCircle size={12} strokeWidth={2.2} />}>
{t('mailAdmin.test.ok')}
</StatusPill>
) : (
<StatusPill tone="danger" icon={<XCircle size={12} strokeWidth={2.2} />}>
{admin.test.error ?? t('mailAdmin.test.failed')}
</StatusPill>
);
}
/** Everything under the switch: how mail arrives, its fields, then save, test and the status. */
function Setup({ admin, form, t, locale }: { admin: MailIngestAdmin; form: MailAdminForm; t: T; locale: string }) {
const error = (problem: MailAdminProblem) => (admin.problems.includes(problem) ? problemText(problem, t) : undefined);
return (
<div className="flex flex-col gap-4">
<div className={GRID_2}>
<EditorField label={t('mailAdmin.transport.label')}>
<CustomSelect
value={form.transport}
onChange={(value) => admin.set('transport', TRANSPORTS.find((x) => x === value) ?? 'off')}
options={TRANSPORTS.map((x) => ({ value: x, label: transportLabel(x, t) }))}
/>
</EditorField>
<EditorField
label={t('mailAdmin.aiDaily')}
htmlFor="mail-ai-daily"
hint={t('mailAdmin.aiDailyHint')}
error={error('dailyInvalid')}
>
<input
id="mail-ai-daily"
className={INPUT}
inputMode="numeric"
value={form.ai_per_user_daily}
onChange={(e) => admin.set('ai_per_user_daily', e.target.value)}
/>
</EditorField>
</div>
<EditorField
label={t('mailAdmin.template')}
htmlFor="mail-template"
hint={t('mailAdmin.templateHint', { token: '{token}' })}
error={error('templateToken')}
>
<input
id="mail-template"
className={INPUT}
value={form.address_template}
placeholder="trek+{token}@example.com"
onChange={(e) => admin.set('address_template', e.target.value)}
/>
</EditorField>
{form.transport === 'imap' && <ImapFields admin={admin} form={form} t={t} error={error} />}
{form.transport === 'webhook' && <WebhookFields admin={admin} form={form} t={t} error={error} />}
<SettingsHint>{t('mailAdmin.envHint')}</SettingsHint>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className={SETTINGS_BUTTON_PRIMARY}
disabled={!admin.dirty || admin.problems.length > 0 || admin.saving}
onClick={admin.save}
>
{admin.saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} strokeWidth={2} />}
{t('common.save')}
</button>
<button
type="button"
className={SETTINGS_BUTTON}
disabled={admin.testing || admin.dirty || form.transport === 'off'}
onClick={admin.runTest}
>
{admin.testing ? <Loader2 size={14} className="animate-spin" /> : <PlugZap size={14} strokeWidth={2} />}
{t('mailAdmin.test.run')}
</button>
<TestResult admin={admin} t={t} />
</div>
<StatusBlock admin={admin} t={t} locale={locale} />
</div>
);
}
/**
* The booking mail import in the admin settings of a self-hosted install: the
* instance switch, how mail arrives (IMAP or a signed webhook), the forwarding
* address template, the AI allowance per person and day, a connection test and
* the status in counts. Not shown on a managed install, where the operator
* runs the TREK Cloud relay.
*/
export default function MailIngestAdminCard() {
const { t, locale } = useTranslation();
const admin = useMailIngestAdmin(true);
return (
<SettingsCard icon={Mail} title={t('mailAdmin.title')} hint={t('mailAdmin.hint')}>
<SettingRows>
<SettingRow
label={t('mailAdmin.enabled')}
hint={t('mailAdmin.enabledHint')}
control={
<ToggleSwitch
on={admin.enabled}
label={t('mailAdmin.enabled')}
disabled={!admin.loaded}
onToggle={() => admin.toggle(!admin.enabled)}
/>
}
/>
</SettingRows>
{admin.enabled && admin.form && <Setup admin={admin} form={admin.form} t={t} locale={locale} />}
</SettingsCard>
);
}
@@ -0,0 +1,99 @@
// FE-ADMIN-MAILINGEST-001 to FE-ADMIN-MAILINGEST-006
import type { MailIngestAdminStatusResponse } from '@trek/shared';
import { describe, expect, it } from 'vitest';
import {
MASKED,
TRANSPORTS,
changesOf,
enabledOf,
fieldsOf,
formOf,
problemText,
problemsOf,
statusFacts,
transportLabel,
} from './mailIngestAdminModel';
const t = (key: string, params?: Record<string, string | number | null>) =>
params ? `${key} ${JSON.stringify(params)}` : key;
describe('mailIngestAdminModel', () => {
it('FE-ADMIN-MAILINGEST-001: reads the stored settings, and the defaults for what the server left out', () => {
expect(formOf({})).toEqual({
transport: 'off',
address_template: '',
imap_host: '',
imap_port: '993',
imap_tls: 'tls',
imap_user: '',
imap_pass: '',
imap_mailbox: 'INBOX',
webhook_secret: '',
ai_per_user_daily: '10',
});
const form = formOf({
mail_ingest_transport: 'imap',
mail_ingest_imap_port: 143,
mail_ingest_imap_tls: 'starttls',
mail_ingest_imap_pass: MASKED,
});
expect(form).toMatchObject({ transport: 'imap', imap_port: '143', imap_tls: 'starttls', imap_pass: MASKED });
// The Cloud relay is the operator's, never a choice in this form.
expect(formOf({ mail_ingest_transport: 'cloud' }).transport).toBe('off');
expect(enabledOf({ mail_ingest_enabled: 'true' })).toBe(true);
expect(enabledOf({})).toBe(false);
});
it('FE-ADMIN-MAILINGEST-002: a save carries only what changed, trimmed, under the stored keys', () => {
const saved = formOf({ mail_ingest_imap_pass: MASKED });
expect(changesOf(saved, saved)).toEqual({});
const edited = { ...saved, transport: 'imap' as const, imap_host: ' imap.example.com ' };
expect(changesOf(edited, saved)).toEqual({
mail_ingest_transport: 'imap',
mail_ingest_imap_host: 'imap.example.com',
});
});
it('FE-ADMIN-MAILINGEST-003: each transport shows only its own fields', () => {
expect(fieldsOf('off')).toEqual([]);
expect(fieldsOf('webhook')).toEqual(['webhook_secret']);
expect(fieldsOf('imap')).toContain('imap_pass');
});
it('FE-ADMIN-MAILINGEST-004: what stops a save is found before the server answers', () => {
const base = formOf({});
expect(problemsOf(base)).toEqual([]);
expect(problemsOf({ ...base, transport: 'webhook', webhook_secret: 'short' })).toEqual(['secretShort']);
// A stored secret comes back masked, and an empty one switches the webhook off: neither is a problem.
expect(problemsOf({ ...base, transport: 'webhook', webhook_secret: MASKED })).toEqual([]);
expect(problemsOf({ ...base, transport: 'webhook', webhook_secret: '' })).toEqual([]);
expect(problemsOf({ ...base, transport: 'imap', imap_port: '70000' })).toEqual(['portInvalid']);
expect(problemsOf({ ...base, address_template: 'trek@example.com' })).toEqual(['templateToken']);
expect(problemsOf({ ...base, ai_per_user_daily: '2.5' })).toEqual(['dailyInvalid']);
});
it('FE-ADMIN-MAILINGEST-005: the status card adds up the last day in counts', () => {
const status: MailIngestAdminStatusResponse = {
transport: 'imap',
last_ok_at: null,
last_error: null,
lease_until: null,
last_day: { by_status: { processed: 7, failed: 2, queued: 1 }, by_reason: {} },
auto_applied: 5,
undone: 0,
ai_calls_today: 3,
backlog: [2, 4],
};
expect(statusFacts(status)).toEqual({ mails: 10, added: 5, waiting: 6, failed: 2 });
});
it('FE-ADMIN-MAILINGEST-006: every transport and problem has its words, {token} kept literal', () => {
expect(TRANSPORTS.map((x) => transportLabel(x, t))).toEqual([
'mailAdmin.transport.off',
'mailAdmin.transport.imap',
'mailAdmin.transport.webhook',
]);
expect(problemText('templateToken', t)).toContain('"token":"{token}"');
expect(problemText('secretShort', t)).toBe('mailAdmin.problem.secretShort');
});
});
@@ -0,0 +1,157 @@
import type { MailIngestAdminStatusResponse, MailIngestImapTls } from '@trek/shared';
import type { TranslationFn } from '../../../types';
/**
* The admin form of the booking mail import, as plain data: which stored app
* setting each field is, how the server's answer becomes the form, which
* fields changed, and what the status card says. Shared by the desktop card
* and the phone's admin section; no React here.
*/
/** The transports an admin picks on a self-hosted install; the TREK Cloud relay is set by the operator. */
type MailAdminTransport = 'off' | 'imap' | 'webhook';
export interface MailAdminForm {
transport: MailAdminTransport;
address_template: string;
imap_host: string;
imap_port: string;
imap_tls: MailIngestImapTls;
imap_user: string;
/** "••••••••" while a stored password is untouched; the server keeps it then. */
imap_pass: string;
imap_mailbox: string;
/** Masked like the password once stored. */
webhook_secret: string;
ai_per_user_daily: string;
}
export type MailAdminField = keyof MailAdminForm;
/** What the server sends instead of a stored secret, and takes back as "unchanged". */
export const MASKED = '••••••••';
/** The secret the webhook signs with: empty switches it off, else at least this long. */
const WEBHOOK_SECRET_MIN = 32;
const KEY: Record<MailAdminField, string> = {
transport: 'mail_ingest_transport',
address_template: 'mail_ingest_address_template',
imap_host: 'mail_ingest_imap_host',
imap_port: 'mail_ingest_imap_port',
imap_tls: 'mail_ingest_imap_tls',
imap_user: 'mail_ingest_imap_user',
imap_pass: 'mail_ingest_imap_pass',
imap_mailbox: 'mail_ingest_imap_mailbox',
webhook_secret: 'mail_ingest_webhook_secret',
ai_per_user_daily: 'mail_ingest_ai_per_user_daily',
};
export const ENABLED_KEY = 'mail_ingest_enabled';
const str = (value: unknown, fallback: string): string =>
typeof value === 'string' || typeof value === 'number' ? String(value) : fallback;
/** The form from the admin's app settings; a key the server leaves out has its default. */
export function formOf(settings: Record<string, unknown>): MailAdminForm {
const transport = str(settings[KEY.transport], 'off');
const tls = str(settings[KEY.imap_tls], 'tls');
return {
transport: transport === 'imap' || transport === 'webhook' ? transport : 'off',
address_template: str(settings[KEY.address_template], ''),
imap_host: str(settings[KEY.imap_host], ''),
imap_port: str(settings[KEY.imap_port], '993'),
imap_tls: tls === 'starttls' ? 'starttls' : 'tls',
imap_user: str(settings[KEY.imap_user], ''),
imap_pass: str(settings[KEY.imap_pass], ''),
imap_mailbox: str(settings[KEY.imap_mailbox], 'INBOX'),
webhook_secret: str(settings[KEY.webhook_secret], ''),
ai_per_user_daily: str(settings[KEY.ai_per_user_daily], '10'),
};
}
export function enabledOf(settings: Record<string, unknown>): boolean {
return String(settings[ENABLED_KEY] ?? 'false') === 'true';
}
/** Only what changed, under the stored keys, so a save never touches a field the admin left alone. */
export function changesOf(form: MailAdminForm, saved: MailAdminForm): Record<string, string> {
const patch: Record<string, string> = {};
for (const field of Object.keys(KEY) as MailAdminField[]) {
if (form[field] !== saved[field]) patch[KEY[field]] = form[field].trim();
}
return patch;
}
/** The fields the chosen transport shows, so the form never asks for what will not be used. */
export function fieldsOf(transport: MailAdminTransport): MailAdminField[] {
switch (transport) {
case 'imap':
return ['imap_host', 'imap_port', 'imap_tls', 'imap_user', 'imap_pass', 'imap_mailbox'];
case 'webhook':
return ['webhook_secret'];
case 'off':
return [];
}
}
export type MailAdminProblem = 'secretShort' | 'portInvalid' | 'templateToken' | 'dailyInvalid';
/** What stops a save, checked here so the admin hears it before the server answers. */
export function problemsOf(form: MailAdminForm): MailAdminProblem[] {
const problems: MailAdminProblem[] = [];
const secret = form.webhook_secret.trim();
if (form.transport === 'webhook' && secret !== MASKED && secret.length > 0 && secret.length < WEBHOOK_SECRET_MIN) {
problems.push('secretShort');
}
const port = Number(form.imap_port);
if (form.transport === 'imap' && !(Number.isInteger(port) && port > 0 && port < 65536)) problems.push('portInvalid');
if (form.address_template.trim() && !form.address_template.includes('{token}')) problems.push('templateToken');
const daily = Number(form.ai_per_user_daily);
if (!(Number.isInteger(daily) && daily >= 0 && daily <= 1000)) problems.push('dailyInvalid');
return problems;
}
/** The status card in numbers: mails of the last day, added on their own, open entries. */
export function statusFacts(status: MailIngestAdminStatusResponse): {
mails: number;
added: number;
waiting: number;
failed: number;
} {
const byStatus = status.last_day.by_status;
const mails = Object.values(byStatus).reduce<number>((sum, n) => sum + (n ?? 0), 0);
return {
mails,
added: status.auto_applied,
waiting: status.backlog.reduce((sum, n) => sum + n, 0),
failed: byStatus.failed ?? 0,
};
}
/** A problem in the admin's words; a switch of literal keys so the i18n check sees each one. */
export function problemText(problem: MailAdminProblem, t: TranslationFn): string {
switch (problem) {
case 'secretShort':
return t('mailAdmin.problem.secretShort');
case 'portInvalid':
return t('mailAdmin.problem.portInvalid');
case 'templateToken':
return t('mailAdmin.problem.templateToken', { token: '{token}' });
case 'dailyInvalid':
return t('mailAdmin.problem.dailyInvalid');
}
}
export const TRANSPORTS: MailAdminTransport[] = ['off', 'imap', 'webhook'];
export function transportLabel(transport: MailAdminTransport, t: TranslationFn): string {
switch (transport) {
case 'off':
return t('mailAdmin.transport.off');
case 'imap':
return t('mailAdmin.transport.imap');
case 'webhook':
return t('mailAdmin.transport.webhook');
}
}
@@ -0,0 +1,124 @@
import type { MailIngestAdminStatusResponse, MailIngestAdminTestResponse } from '@trek/shared';
import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from '../../../i18n';
import { mailIngestRepo } from '../../../repo/mailIngestRepo';
import { getApiErrorMessage } from '../../../types';
import { useToast } from '../../shared/Toast';
import {
ENABLED_KEY,
changesOf,
enabledOf,
formOf,
problemsOf,
type MailAdminField,
type MailAdminForm,
} from './mailIngestAdminModel';
/**
* The admin card of the booking mail import, desktop and phone alike: the
* instance switch (saved at once, as every switch in the admin settings), the
* transport fields (saved together, only the changed ones), the connection
* test and the status in counts. Loads once when shown.
*/
export function useMailIngestAdmin(active: boolean) {
const { t } = useTranslation();
const toast = useToast();
const [enabled, setEnabled] = useState(false);
const [saved, setSaved] = useState<MailAdminForm | null>(null);
const [form, setForm] = useState<MailAdminForm | null>(null);
const [status, setStatus] = useState<MailIngestAdminStatusResponse | null>(null);
const [test, setTest] = useState<MailIngestAdminTestResponse | null>(null);
const [saving, setSaving] = useState(false);
const [testing, setTesting] = useState(false);
const loadStatus = useCallback(async () => {
try {
setStatus(await mailIngestRepo.adminStatus());
} catch {
setStatus(null);
}
}, []);
useEffect(() => {
if (!active) return;
let cancelled = false;
void (async () => {
try {
const raw = await mailIngestRepo.adminSettings();
if (cancelled) return;
const loaded = formOf(raw);
setEnabled(enabledOf(raw));
setSaved(loaded);
setForm(loaded);
} catch {
if (!cancelled) toast.error(t('mailAdmin.loadError'));
}
if (!cancelled) await loadStatus();
})();
return () => {
cancelled = true;
};
}, [active, loadStatus, t, toast]);
const toggle = async (on: boolean) => {
setEnabled(on);
try {
await mailIngestRepo.saveAdminSettings({ [ENABLED_KEY]: on });
void loadStatus();
} catch (err: unknown) {
setEnabled(!on);
toast.error(getApiErrorMessage(err, t('common.error')));
}
};
const set = <K extends MailAdminField>(field: K, value: MailAdminForm[K]) =>
setForm((current) => (current ? { ...current, [field]: value } : current));
const patch = form && saved ? changesOf(form, saved) : {};
const problems = form ? problemsOf(form) : [];
const save = async () => {
if (!form || Object.keys(patch).length === 0 || problems.length > 0) return;
setSaving(true);
try {
await mailIngestRepo.saveAdminSettings(patch);
setSaved(form);
setTest(null);
toast.success(t('common.saved'));
void loadStatus();
} catch (err: unknown) {
toast.error(getApiErrorMessage(err, t('common.error')));
} finally {
setSaving(false);
}
};
const runTest = async () => {
setTesting(true);
try {
setTest(await mailIngestRepo.adminTest());
} catch (err: unknown) {
setTest({ transport: form?.transport ?? 'off', ok: false, error: getApiErrorMessage(err, t('common.error')) });
} finally {
setTesting(false);
}
};
return {
loaded: form !== null,
enabled,
toggle: (on: boolean) => void toggle(on),
form,
set,
dirty: Object.keys(patch).length > 0,
problems,
saving,
save: () => void save(),
status,
test,
testing,
runTest: () => void runTest(),
};
}
export type MailIngestAdmin = ReturnType<typeof useMailIngestAdmin>;
+8 -4
View File
@@ -14,8 +14,9 @@ import AssistantLauncher from '../Assistant/AssistantLauncher'
import { resolvePluginIcon } from '../shared/PluginIcon'
import { visibleManagedNavItems } from '../../managed'
import { useCollapseOnOverflow } from '../../hooks/useCollapseOnOverflow'
import { useMailIngestStore } from '../../store/mailIngestStore'
import { useMailIngestStore, watchMailInbox } from '../../store/mailIngestStore'
import { openMailInbox } from '../MailIngest/mailInboxOpener'
import { useMailInboxLink } from '../MailIngest/useMailInboxLink'
import MailInboxDialog from '../MailIngest/MailInboxDialog'
const ADDON_ICONS: Record<string, LucideIcon> = { CalendarDays, Briefcase, Globe, Compass, Bookmark }
@@ -50,9 +51,12 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
const mailImport = useMailIngestStore(s => s.settings)
const mailPending = useMailIngestStore(s => s.pending)
const mailImportOn = !!mailImport?.available && mailImport.prefs.enabled
useMailInboxLink(mailImportOn)
const userId = user?.id
useEffect(() => {
if (userId) void useMailIngestStore.getState().loadSettings()
if (!userId) return
void useMailIngestStore.getState().loadSettings()
return watchMailInbox()
}, [userId])
const darkMode = settings.dark_mode
const dark = darkMode === true || darkMode === 'dark' || (darkMode === 'auto' && window.matchMedia('(prefers-color-scheme: dark)').matches)
@@ -340,10 +344,10 @@ export default function Navbar({ tripTitle, tripId, onBack, showBack, onSettings
{/* Bookings from forwarded mails that wait for a decision; only where the import is on. */}
{mailImportOn && (
<button type="button" onClick={() => { setUserMenuOpen(false); openMailInbox() }}
className="flex w-full items-center gap-2 px-4 py-2 text-sm transition-colors text-content-secondary hover:bg-surface-hover">
className="flex w-full items-center gap-2 px-4 py-2 text-body transition-colors text-content-secondary hover:bg-surface-hover">
<Inbox className="w-4 h-4" />
<span className="min-w-0 flex-1 truncate text-start">{t('mailInbox.title')}</span>
{mailPending > 0 && <span className="rounded-full bg-accent px-1.5 text-xs font-semibold text-accent-text">{mailPending}</span>}
{mailPending > 0 && <span className="rounded-full bg-accent px-1.5 text-caption font-semibold text-accent-text">{mailPending}</span>}
</button>
)}
@@ -1,5 +1,5 @@
import type { MailIngestInboxEntry } from '@trek/shared';
import { Copy, FilePlus2, FileText, Inbox, Loader2, Mail, PencilLine, Plus, Sparkles, Trash2 } from 'lucide-react';
import { Copy, FilePlus2, FileText, Inbox, Loader2, Mail, PencilLine, Plus, Trash2 } from 'lucide-react';
import { useId, type ReactElement } from 'react';
import { useTranslation } from '../../i18n';
import { copyText } from '../../utils/clipboard';
@@ -28,8 +28,6 @@ function actionLook(action: InboxAction, t: (key: string) => string): { icon: Re
return { icon: <FilePlus2 size={14} strokeWidth={2} />, label: t('mailInbox.action.file') };
case 'manual':
return { icon: <PencilLine size={14} strokeWidth={2} />, label: t('mailInbox.action.manual') };
case 'readAi':
return { icon: <Sparkles size={14} strokeWidth={2} />, label: t('mailInbox.action.readAi') };
case 'discard':
return { icon: <Trash2 size={14} strokeWidth={2} />, label: t('mailInbox.action.discard') };
}
@@ -75,8 +73,8 @@ function Entry({ entry, inbox }: { entry: MailIngestInboxEntry; inbox: ReturnTyp
<DialogIconButton
label={t('mailInbox.settings.copy')}
icon={<Copy size={13} strokeWidth={2} />}
onClick={async () => {
if (await copyText(code)) toast.success(t('common.copied'));
onClick={() => {
void copyText(code).then((ok) => ok && toast.success(t('common.copied')));
}}
/>
</div>
@@ -1,8 +1,8 @@
import { mailIngestAddressResponseSchema, type MailIngestAddress } from '@trek/shared';
import type { MailIngestAddress } from '@trek/shared';
import { Copy, Mail, Plus } from 'lucide-react';
import { useEffect, useState, type ReactElement } from 'react';
import { mailIngestApi } from '../../api/mailIngest';
import { useTranslation } from '../../i18n';
import { mailIngestRepo } from '../../repo/mailIngestRepo';
import { useMailIngestStore } from '../../store/mailIngestStore';
import { copyText } from '../../utils/clipboard';
import { useToast } from '../shared/Toast';
@@ -30,13 +30,15 @@ export default function TripMailForward({ tripId }: { tripId: number }): ReactEl
useEffect(() => {
if (!on) return;
let live = true;
mailIngestApi
mailIngestRepo
.tripAddress(tripId)
.then((raw) => {
const parsed = mailIngestAddressResponseSchema.safeParse(raw);
if (live && parsed.success) setAddress(parsed.data.address);
.then((found) => {
if (live) setAddress(found);
})
.catch(() => {});
.catch(() => {
// No address yet, or none readable: the card offers to create one instead.
if (live) setAddress(null);
});
return () => {
live = false;
};
@@ -47,7 +49,7 @@ export default function TripMailForward({ tripId }: { tripId: number }): ReactEl
const create = async () => {
setCreating(true);
try {
setAddress(mailIngestAddressResponseSchema.parse(await mailIngestApi.createTripAddress(tripId)).address);
setAddress(await mailIngestRepo.createTripAddress(tripId));
} catch {
toast.error(t('mailInbox.settings.saveError'));
} finally {
@@ -52,20 +52,19 @@ function entry(
describe('mailInboxModel', () => {
it('FE-MAILINBOX-MODEL-001: a read booking with a suggested trip is added in one tap, with every way out after it', () => {
expect(actionsOf(entry(), false)).toEqual(['add', 'file', 'manual', 'discard']);
expect(actionsOf(entry())).toEqual(['add', 'file', 'manual', 'discard']);
});
it('FE-MAILINBOX-MODEL-002: without a suggestion the person chooses the trip', () => {
expect(actionsOf(entry({ suggested_trip: null }), false)[0]).toBe('choose');
expect(actionsOf(entry({ suggested_trip: null }))[0]).toBe('choose');
});
it('FE-MAILINBOX-MODEL-003: a mail without a booking offers the document, the form, and AI only where it can help', () => {
it('FE-MAILINBOX-MODEL-003: a mail without a booking offers the document and the form', () => {
const none = entry(
{ item: null, reason: 'not_a_booking' },
{ attachments: [{ file_name: 'invoice.pdf', kind: 'pdf', size: 1 }] }
);
expect(actionsOf(none, false)).toEqual(['file', 'manual', 'discard']);
expect(actionsOf(none, true)).toEqual(['file', 'manual', 'readAi', 'discard']);
expect(actionsOf(none)).toEqual(['file', 'manual', 'discard']);
});
it('FE-MAILINBOX-MODEL-004: a forwarding confirmation or a newsletter only informs', () => {
@@ -8,107 +8,71 @@ import type { MailIngestInboxEntry, MailIngestReason } from '@trek/shared';
*/
/** The ways out of an entry, in the order the row offers them. */
export type InboxAction = 'add' | 'choose' | 'file' | 'manual' | 'readAi' | 'discard';
/** Reasons where reading the mail again with AI is worth a try. */
const AI_RETRY: ReadonlySet<MailIngestReason> = new Set([
'ai_quota_exhausted',
'ai_user_cap',
'ai_provider_error',
'ai_disabled',
'not_a_booking',
'mail_unreadable',
]);
export type InboxAction = 'add' | 'choose' | 'file' | 'manual' | 'discard';
/** Reasons that need no booking decision at all: the entry only informs. */
const INFO_ONLY: ReadonlySet<MailIngestReason> = new Set(['forwarding_confirmation', 'newsletter', 'auto_reply']);
export function actionsOf(entry: MailIngestInboxEntry, aiAvailable: boolean): InboxAction[] {
/**
* A passing AI failure is read again by the server on its own; an allowance
* used up leaves the form and the document, so there is no "read again" here.
*/
export function actionsOf(entry: MailIngestInboxEntry): InboxAction[] {
const reason = entry.reason ?? entry.message.reason;
if (reason && INFO_ONLY.has(reason)) return ['discard'];
const actions: InboxAction[] = [];
if (entry.item) actions.push(entry.suggested_trip ? 'add' : 'choose');
if (entry.message.attachments.length > 0 || entry.item) actions.push('file');
actions.push('manual');
if (aiAvailable && reason && AI_RETRY.has(reason)) actions.push('readAi');
actions.push('discard');
return actions;
}
type Translate = (key: string) => string;
/**
* A reason in the reader's words. A switch of literal keys rather than a table,
* so the i18n check sees every key in use and a new code fails the type check
* here first.
* A reason in the reader's words. A table of literal keys, so the i18n check
* sees every key in use, and typed by the reason codes, so a new code fails
* the type check here first.
*/
export function reasonText(reason: MailIngestReason, t: (key: string) => string): string {
switch (reason) {
case 'forwarding_confirmation':
return t('mailInbox.reason.forwardingConfirmation');
case 'newsletter':
return t('mailInbox.reason.newsletter');
case 'auto_reply':
return t('mailInbox.reason.autoReply');
case 'empty_mail':
return t('mailInbox.reason.emptyMail');
case 'mail_unreadable':
return t('mailInbox.reason.mailUnreadable');
case 'not_a_booking':
return t('mailInbox.reason.notABooking');
case 'too_large':
return t('mailInbox.reason.tooLarge');
case 'address_invalid':
return t('mailInbox.reason.addressInvalid');
case 'address_daily_limit':
return t('mailInbox.reason.addressDailyLimit');
case 'processing_failed':
return t('mailInbox.reason.processingFailed');
case 'ai_quota_exhausted':
return t('mailInbox.reason.aiQuotaExhausted');
case 'ai_disabled':
return t('mailInbox.reason.aiDisabled');
case 'ai_user_cap':
return t('mailInbox.reason.aiUserCap');
case 'ai_provider_error':
return t('mailInbox.reason.aiProviderError');
case 'no_trip_in_range':
return t('mailInbox.reason.noTripInRange');
case 'trip_ambiguous':
return t('mailInbox.reason.tripAmbiguous');
case 'trip_without_dates':
return t('mailInbox.reason.tripWithoutDates');
case 'no_edit_permission':
return t('mailInbox.reason.noEditPermission');
case 'demo_mode':
return t('mailInbox.reason.demoMode');
case 'outside_trip_dates':
return t('mailInbox.reason.outsideTripDates');
case 'source_mismatch':
return t('mailInbox.reason.sourceMismatch');
case 'implausible':
return t('mailInbox.reason.implausible');
case 'not_in_text':
return t('mailInbox.reason.notInText');
case 'image_only':
return t('mailInbox.reason.imageOnly');
case 'missing_critical_field':
return t('mailInbox.reason.missingCriticalField');
case 'missing_confirmation':
return t('mailInbox.reason.missingConfirmation');
case 'too_many_bookings':
return t('mailInbox.reason.tooManyBookings');
case 'change_or_cancellation':
return t('mailInbox.reason.changeOrCancellation');
case 'cancelled':
return t('mailInbox.reason.cancelled');
case 'possible_duplicate':
return t('mailInbox.reason.possibleDuplicate');
case 'change_available':
return t('mailInbox.reason.changeAvailable');
case 'conflict':
return t('mailInbox.reason.conflict');
case 'ask_first':
return t('mailInbox.reason.askFirst');
}
const REASON_TEXT: Record<MailIngestReason, (t: Translate) => string> = {
forwarding_confirmation: (t) => t('mailInbox.reason.forwardingConfirmation'),
newsletter: (t) => t('mailInbox.reason.newsletter'),
auto_reply: (t) => t('mailInbox.reason.autoReply'),
empty_mail: (t) => t('mailInbox.reason.emptyMail'),
mail_unreadable: (t) => t('mailInbox.reason.mailUnreadable'),
not_a_booking: (t) => t('mailInbox.reason.notABooking'),
too_large: (t) => t('mailInbox.reason.tooLarge'),
address_invalid: (t) => t('mailInbox.reason.addressInvalid'),
address_daily_limit: (t) => t('mailInbox.reason.addressDailyLimit'),
processing_failed: (t) => t('mailInbox.reason.processingFailed'),
ai_quota_exhausted: (t) => t('mailInbox.reason.aiQuotaExhausted'),
ai_disabled: (t) => t('mailInbox.reason.aiDisabled'),
ai_user_cap: (t) => t('mailInbox.reason.aiUserCap'),
ai_provider_error: (t) => t('mailInbox.reason.aiProviderError'),
no_trip_in_range: (t) => t('mailInbox.reason.noTripInRange'),
trip_ambiguous: (t) => t('mailInbox.reason.tripAmbiguous'),
trip_without_dates: (t) => t('mailInbox.reason.tripWithoutDates'),
no_edit_permission: (t) => t('mailInbox.reason.noEditPermission'),
demo_mode: (t) => t('mailInbox.reason.demoMode'),
outside_trip_dates: (t) => t('mailInbox.reason.outsideTripDates'),
source_mismatch: (t) => t('mailInbox.reason.sourceMismatch'),
implausible: (t) => t('mailInbox.reason.implausible'),
not_in_text: (t) => t('mailInbox.reason.notInText'),
image_only: (t) => t('mailInbox.reason.imageOnly'),
missing_critical_field: (t) => t('mailInbox.reason.missingCriticalField'),
missing_confirmation: (t) => t('mailInbox.reason.missingConfirmation'),
too_many_bookings: (t) => t('mailInbox.reason.tooManyBookings'),
change_or_cancellation: (t) => t('mailInbox.reason.changeOrCancellation'),
cancelled: (t) => t('mailInbox.reason.cancelled'),
possible_duplicate: (t) => t('mailInbox.reason.possibleDuplicate'),
change_available: (t) => t('mailInbox.reason.changeAvailable'),
conflict: (t) => t('mailInbox.reason.conflict'),
ask_first: (t) => t('mailInbox.reason.askFirst'),
};
export function reasonText(reason: MailIngestReason, t: Translate): string {
return REASON_TEXT[reason](t);
}
/** The entry's headline: the booking's title, else the mail's subject, else nothing to show. */
@@ -1,7 +1,7 @@
import { mailIngestSettingsResponseSchema, mailIngestUploadResponseSchema, type MailIngestPrefs } from '@trek/shared';
import type { MailIngestPrefs } from '@trek/shared';
import { useEffect, useRef, useState } from 'react';
import { mailIngestApi } from '../../api/mailIngest';
import { useTranslation } from '../../i18n';
import { mailIngestRepo } from '../../repo/mailIngestRepo';
import { useMailIngestStore } from '../../store/mailIngestStore';
import { copyText } from '../../utils/clipboard';
import { useToast } from '../shared/Toast';
@@ -37,10 +37,7 @@ export function useMailImportSettings() {
if (!settings || saving) return;
setSaving(key);
try {
const parsed = mailIngestSettingsResponseSchema.parse(
await mailIngestApi.updateSettings({ [key]: !settings.prefs[key] })
);
setSettings(parsed);
setSettings(await mailIngestRepo.updateSettings({ [key]: !settings.prefs[key] }));
} catch {
toast.error(t('mailInbox.settings.saveError'));
} finally {
@@ -64,7 +61,7 @@ export function useMailImportSettings() {
setRenewArmed(false);
setRenewing(true);
try {
await mailIngestApi.renewAddress();
await mailIngestRepo.renewAddress();
await loadSettings();
toast.success(t('mailInbox.settings.renewed'));
} catch {
@@ -77,7 +74,7 @@ export function useMailImportSettings() {
const upload = async (file: File) => {
setUploading(true);
try {
const { duplicate } = mailIngestUploadResponseSchema.parse(await mailIngestApi.upload(file));
const { duplicate } = await mailIngestRepo.upload(file);
toast.success(duplicate ? t('mailInbox.settings.uploadDuplicate') : t('mailInbox.settings.uploaded'));
} catch {
toast.error(t('mailInbox.settings.uploadError'));
@@ -1,13 +1,11 @@
import {
mailIngestMessagePreviewResponseSchema,
type MailIngestInboxEntry,
type MailIngestMessagePreviewResponse,
} from '@trek/shared';
import type { MailIngestInboxEntry, MailIngestMessagePreviewResponse } from '@trek/shared';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router';
import { tripsApi } from '../../api/client';
import { mailIngestApi, type MailIngestAction } from '../../api/mailIngest';
import { useShallow } from 'zustand/react/shallow';
import type { MailIngestAction } from '../../api/mailIngest';
import { useTranslation } from '../../i18n';
import { mailIngestRepo } from '../../repo/mailIngestRepo';
import { tripRepo } from '../../repo/tripRepo';
import { useMailIngestStore } from '../../store/mailIngestStore';
import { useToast } from '../shared/Toast';
import { actionsOf, type InboxAction } from './mailInboxModel';
@@ -32,7 +30,18 @@ export function useMailInbox() {
const toast = useToast();
const navigate = useNavigate();
const open = useMailInboxOpen((s) => s.open);
const { entries, pending, nextBefore, loadingInbox, settings, loadInbox, loadSettings, act } = useMailIngestStore();
const { entries, pending, nextBefore, loadingInbox, settings, loadInbox, loadSettings, act } = useMailIngestStore(
useShallow((s) => ({
entries: s.entries,
pending: s.pending,
nextBefore: s.nextBefore,
loadingInbox: s.loadingInbox,
settings: s.settings,
loadInbox: s.loadInbox,
loadSettings: s.loadSettings,
act: s.act,
}))
);
const [trips, setTrips] = useState<InboxTrip[]>([]);
const [busy, setBusy] = useState<number | null>(null);
/** The entry and the action waiting for a trip to be picked. */
@@ -45,10 +54,17 @@ export function useMailInbox() {
if (!open) return;
void loadInbox();
if (!settings) void loadSettings();
tripsApi
tripRepo
.list()
.then((d: { trips?: InboxTrip[] }) =>
setTrips((d.trips ?? []).map(({ id, title, start_date, end_date }) => ({ id, title, start_date, end_date })))
.then(({ trips: active }) =>
setTrips(
active.map(({ id, title, start_date, end_date }) => ({
id,
title,
start_date: start_date ?? null,
end_date: end_date ?? null,
}))
)
)
.catch(() => setTrips([]));
}, [open, loadInbox, loadSettings, settings]);
@@ -81,8 +97,6 @@ export function useMailInbox() {
return tripId
? run(entry, 'file', { trip_id: tripId }, t('mailInbox.filed'))
: setPicking({ entry, action: 'file' });
case 'readAi':
return run(entry, 'read-ai', {}, t('mailInbox.readAgain'));
case 'discard':
return run(entry, 'discard', {}, t('mailInbox.discarded'));
case 'manual':
@@ -100,7 +114,7 @@ export function useMailInbox() {
const openForm = (entry: MailIngestInboxEntry, tripId: number) => {
closeMailInbox();
navigate(`/trips/${tripId}?mailPrefill=${entry.id}`);
void navigate(`/trips/${tripId}?mailPrefill=${entry.id}`);
};
const fillIn = (entry: MailIngestInboxEntry) => {
@@ -111,13 +125,12 @@ export function useMailInbox() {
const showMail = async (entry: MailIngestInboxEntry) => {
try {
setPreview(mailIngestMessagePreviewResponseSchema.parse(await mailIngestApi.preview(entry.message_id)));
setPreview(await mailIngestRepo.preview(entry.message_id));
} catch {
toast.error(t('mailInbox.previewError'));
}
};
const aiAvailable = !!settings?.ai_available && !!settings.prefs.ai_enabled;
return {
open,
close: closeMailInbox,
@@ -128,7 +141,7 @@ export function useMailInbox() {
loading: loadingInbox,
trips,
busy,
actionsOf: (entry: MailIngestInboxEntry) => actionsOf(entry, aiAvailable),
actionsOf,
perform: (entry: MailIngestInboxEntry, action: InboxAction) => void perform(entry, action),
picking,
pick,
@@ -0,0 +1,30 @@
import { useEffect } from 'react';
import { useLocation, useNavigate } from 'react-router';
import { openMailInbox } from './mailInboxOpener';
/** The query a link uses to open the inbox, e.g. the "bookings waiting" notification's `/dashboard?inbox=mail`. */
const INBOX_PARAM = 'inbox';
const INBOX_MAIL = 'mail';
/**
* Opens the inbox of forwarded bookings when the address asks for it, then
* takes the query off again so a reload or the back button does not reopen
* it. Mounted where the inbox itself is mounted: the Navbar on the desktop,
* MMailInboxHost on the phone.
*/
export function useMailInboxLink(enabled: boolean): void {
const location = useLocation();
const navigate = useNavigate();
useEffect(() => {
if (!enabled) return;
const params = new URLSearchParams(location.search);
if (params.get(INBOX_PARAM) !== INBOX_MAIL) return;
openMailInbox();
params.delete(INBOX_PARAM);
const search = params.toString();
void navigate(
{ pathname: location.pathname, search: search ? `?${search}` : '', hash: location.hash },
{ replace: true }
);
}, [enabled, location.pathname, location.search, location.hash, navigate]);
}
@@ -1,11 +1,9 @@
import {
isTransportReservationType,
mailIngestPrefillResponseSchema,
type BookingImportPreviewItem,
} from '@trek/shared';
import { isTransportReservationType, type BookingImportPreviewItem } from '@trek/shared';
import { useEffect, useRef } from 'react';
import type { SetURLSearchParams } from 'react-router';
import { mailIngestApi } from '../../api/mailIngest';
import { useTranslation } from '../../i18n';
import { mailIngestRepo } from '../../repo/mailIngestRepo';
import { useToast } from '../shared/Toast';
/**
* "Fill in yourself" from the mail inbox lands in the trip as ?mailPrefill=<id>:
@@ -25,6 +23,8 @@ export function useMailPrefillLink({
loaded: boolean;
startImportReview: (items: BookingImportPreviewItem[], files?: File[], kind?: 'transports' | 'bookings') => void;
}): void {
const { t } = useTranslation();
const toast = useToast();
const requested = searchParams.get('mailPrefill');
const handled = useRef<string | null>(null);
@@ -41,14 +41,14 @@ export function useMailPrefillLink({
);
const id = Number(requested);
if (!Number.isInteger(id) || id <= 0) return;
mailIngestApi
mailIngestRepo
.prefill(id)
.then((raw) => {
const { item } = mailIngestPrefillResponseSchema.parse(raw);
.then(({ item }) => {
startImportReview([item], [], isTransportReservationType(item.type) ? 'transports' : 'bookings');
})
.catch(() => {
// An entry decided meanwhile has nothing to prefill; the trip simply stays open.
// An entry decided meanwhile has nothing to prefill: the trip stays open, and the person hears why.
toast.error(t('mailInbox.actionError'));
});
}, [requested, loaded, setSearchParams, startImportReview]);
}, [requested, loaded, setSearchParams, startImportReview, t, toast]);
}
@@ -41,6 +41,9 @@ const KIND_BY_TITLE: Record<string, NotificationKind> = {
'notif.flight_departed.title': 'flight',
'notif.flight_delayed.title': 'flight',
'notif.flight_landed.title': 'flight',
'notif.mail_booking_added.title': 'booking',
'notif.mail_booking_pending.title': 'booking',
'notif.mail_ingest_paused.title': 'alert',
'notif.packing_tagged.title': 'packing',
'notif.todo_due.title': 'todo',
'notif.vacay_invite.title': 'vacay',
@@ -110,6 +110,15 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
if (await copyText(r.confirmation_number || '')) toast.success(t('common.copied'))
else toast.error(t('reservations.copyFailed'))
}
// "Looks right": the person checked what an import could not read for sure.
const confirmReview = async () => {
try {
await updateReservation(p.tripId, r.id, { needs_review: 0 })
toast.success(t('reservations.reviewConfirmed'))
} catch {
toast.error(t('reservations.toast.updateError'))
}
}
const rename = async () => {
const title = name.trim()
setRenaming(false)
@@ -143,7 +152,7 @@ export default function BookingDetailDialog(p: BookingDetailDialogProps) {
{facts.day.date && !facts.isHotel && <span className="font-medium text-content-muted">{facts.day.date}</span>}
</span>
)}
{!!r.needs_review && <ReviewPill pill />}
{!!r.needs_review && <ReviewPill pill onConfirm={p.canEdit ? () => void confirmReview() : undefined} />}
<AirTrailPill r={r} pill />
<MailPill r={r} pill />
{r.confirmation_number && (
@@ -1,11 +1,11 @@
// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-004
// FE-PLANNER-BKPARTS-001 to FE-PLANNER-BKPARTS-005
import { fireEvent, render, screen } from '@testing-library/react';
import { describe, expect, it, vi } from 'vitest';
import { buildTripFile } from '../../../../tests/helpers/factories';
import { downloadFile, openFile } from '../../../utils/fileDownload';
import type { BookingFacts } from './bookingFacts';
import { FileRows, WhenFields } from './bookingParts';
import { FileRows, ReviewPill, WhenFields } from './bookingParts';
vi.mock('../../../utils/fileDownload', () => ({
openFile: vi.fn(() => Promise.resolve()),
@@ -88,4 +88,13 @@ describe('FileRows', () => {
const { container } = render(<FileRows files={[]} />);
expect(container).toBeEmptyDOMElement();
});
it('FE-PLANNER-BKPARTS-005: the review pill takes its mark off only for someone who may edit', () => {
const onConfirm = vi.fn();
const { rerender } = render(<ReviewPill pill />);
expect(screen.queryByRole('button')).not.toBeInTheDocument();
rerender(<ReviewPill pill onConfirm={onConfirm} />);
fireEvent.click(screen.getByRole('button'));
expect(onConfirm).toHaveBeenCalledOnce();
});
});
@@ -167,15 +167,33 @@ export function TypeTile({ type, size = 32, raised = false }: { type: string; si
)
}
/** The warning icon beside a booking's name on a card or row, or with `pill` a header pill in the detail dialog. */
export function ReviewPill({ pill = false }: { pill?: boolean }) {
/**
* The warning icon beside a booking's name on a card or row, or with `pill` a
* header pill in the detail dialog. With `onConfirm` (the right to edit) the
* pill is the "looks right" switch that takes the mark off.
*/
export function ReviewPill({ pill = false, onConfirm }: { pill?: boolean; onConfirm?: () => void }) {
const { t } = useTranslation()
const content = (
<>
<AlertCircle size={13} strokeWidth={2.2} className="text-warning" />
{t('reservations.needsReview')}
</>
)
if (pill && onConfirm) {
return (
<Tooltip label={`${t('reservations.needsReviewHint')} ${t('reservations.reviewConfirmHint')}`}>
<button type="button" onClick={onConfirm} aria-label={t('reservations.reviewConfirm')} className={`${PILL} hover:opacity-80`} style={fs(12, 'body')}>
{content}
</button>
</Tooltip>
)
}
return (
<Tooltip label={t('reservations.needsReviewHint')}>
{pill ? (
<span className={PILL} style={fs(12, 'body')}>
<AlertCircle size={13} strokeWidth={2.2} className="text-warning" />
{t('reservations.needsReview')}
{content}
</span>
) : (
<span role="img" aria-label={t('reservations.needsReview')} className="grid flex-none place-items-center text-warning">
@@ -1,5 +1,7 @@
import { useTranslation } from '../../../i18n';
import type { Reservation } from '../../../types';
import type { DelayTone } from '../../../utils/flightLiveModel';
import { BOX, Eyebrow } from '../bookings/bookingParts';
import { useCurrentFlightLegIndex, useFlightLiveCard } from './useFlightLiveCard';
const DELAY_TEXT: Record<DelayTone, string> = { ok: 'text-success', minor: 'text-warning', major: 'text-danger' };
@@ -13,8 +15,9 @@ interface FlightLiveStripProps {
}
/**
* A flight's live state on the booking cards: what is happening in one line
* with the delay, and a thin bar across the card while it flies. The full
* A flight's live state on the booking cards, as one of the card's fields:
* what is happening in one line with the delay, and a thin bar along the
* field's foot while it flies. The full
* picture is one click away in the booking dialog. Nothing far ahead of the
* flight (the card shows its date already), nothing once it is long over.
*/
@@ -30,30 +33,30 @@ function Strip({
legIndex,
className,
}: Omit<FlightLiveStripProps, 'type'> & { legIndex: number }) {
const { t } = useTranslation();
const card = useFlightLiveCard(tripId, reservationId, legIndex);
if (!card || card.view.stage === 'upcoming') return null;
const { view } = card;
// The card's own field: the eyebrow over a framed value, like the booking code and the route under it.
return (
<div className={`flex min-w-0 flex-col gap-1.5 rounded-lg bg-surface-tertiary px-2.5 py-[7px] ${className}`}>
<div className="flex min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-content">{card.line}</span>
{card.delay && card.delay.tone !== 'ok' && (
<span className={`flex-none text-caption font-semibold tabular-nums ${DELAY_TEXT[card.delay.tone]}`}>
{card.delay.text}
<div className={`min-w-0 ${className}`}>
<Eyebrow className="mb-[3px]">{t('reservations.status')}</Eyebrow>
<div className={`${BOX} relative overflow-hidden px-[10px] py-[7px]`}>
<div className="flex min-w-0 items-center justify-center gap-2 text-body font-semibold text-content">
<span className="min-w-0 truncate">{card.line}</span>
{card.delay && card.delay.tone !== 'ok' && (
<span className={`flex-none tabular-nums ${DELAY_TEXT[card.delay.tone]}`}>{card.delay.text}</span>
)}
</div>
{view.stage === 'airborne' && (
<span className="absolute inset-x-0 bottom-0 block h-[3px] bg-surface-tertiary" aria-hidden="true">
<span
className="block h-full bg-info transition-[width] duration-700 motion-reduce:transition-none"
style={{ width: `${Math.round(view.progress * 100)}%` }}
/>
</span>
)}
</div>
{view.stage === 'airborne' && (
<span
className="block h-1 w-full overflow-hidden rounded-full bg-[color:var(--border-primary)]"
aria-hidden="true"
>
<span
className="block h-full rounded-full bg-accent transition-[width] duration-700 motion-reduce:transition-none"
style={{ width: `${Math.round(view.progress * 100)}%` }}
/>
</span>
)}
</div>
);
}
@@ -90,3 +90,14 @@
.flight-mini-map .leaflet-tooltip.flight-mini-code::before {
display: none;
}
/* A map kept to the middle of its pass (the phone): the edges still run out,
but the route fills most of it instead of a third. */
.flight-mini-map.flight-mini-map--narrow {
-webkit-mask-image:
linear-gradient(to right, transparent, #000 16%, #000 84%, transparent),
linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
mask-image:
linear-gradient(to right, transparent, #000 16%, #000 84%, transparent),
linear-gradient(to bottom, transparent, #000 18%, #000 82%, transparent);
}
@@ -32,6 +32,9 @@ export const EVENT_LABEL_KEYS: Record<string, string> = {
flight_departed: 'settings.notifyFlightDeparted',
flight_delayed: 'settings.notifyFlightDelayed',
flight_landed: 'settings.notifyFlightLanded',
mail_booking_added: 'settings.notifyMailBookingAdded',
mail_booking_pending: 'settings.notifyMailBookingPending',
mail_ingest_paused: 'settings.notifyMailIngestPaused',
}
/** Plugin channels have no i18n; the server sends their display name outright. */
@@ -7,6 +7,7 @@ import type { useAdmin } from '../../../pages/admin/useAdmin'
import MToggle from '../../components/MToggle'
import { MBlockDisclosure, MProviderBlock, MTrekApiBlock } from './MApiProviderBlocks'
import MAdminOidcCard from './MAdminOidcCard'
import MMailIngestAdminCard from './MMailIngestAdminCard'
import MSetPickerSheet from '../settings/MSetPickerSheet'
import {
MAdminButton,
@@ -196,6 +197,9 @@ export default function MAdminSettingsSection({ admin, t }: MAdminSettingsSectio
onChange={(v) => void handleToggleAuthSetting('flight_live_enabled', v, setFlightLive)}
/>
{/* Booking mail import: self-hosted only, the operator runs the TREK Cloud relay (desktop parity). */}
{!managed && <MMailIngestAdminCard />}
{/* Allowed file types */}
<MAdminCard>
<MAdminCardHead title={t('admin.fileTypes')} hint={t('admin.fileTypesHint')} />
@@ -0,0 +1,215 @@
import {
TRANSPORTS,
problemText,
statusFacts,
transportLabel,
type MailAdminForm,
type MailAdminProblem,
} from '../../../components/Admin/mailIngest/mailIngestAdminModel';
import { useMailIngestAdmin, type MailIngestAdmin } from '../../../components/Admin/mailIngest/useMailIngestAdmin';
import { useTranslation } from '../../../i18n';
import type { TranslationFn } from '../../../types';
import { relativeTime } from '../../../utils/relativeTime';
import MSegmented from '../../components/MSegmented';
import MToggle from '../../components/MToggle';
import {
MAdminButton,
MAdminCard,
MAdminCardHead,
MAdminField,
MAdminInput,
MAdminRow,
MAdminSecretInput,
} from './MAdminUi';
interface PartProps {
admin: MailIngestAdmin;
form: MailAdminForm;
t: TranslationFn;
}
/** The problem under a field, in the danger colour, or nothing. */
function Problem({ admin, problem, t }: { admin: MailIngestAdmin; problem: MailAdminProblem; t: TranslationFn }) {
if (!admin.problems.includes(problem)) return null;
return <p className="mt-1 text-caption text-[color:var(--m-st-danger)]">{problemText(problem, t)}</p>;
}
function ImapFields({ admin, form, t }: PartProps) {
return (
<>
<MAdminField label={t('mailAdmin.imap.host')}>
<MAdminInput
value={form.imap_host}
placeholder="imap.example.com"
onChange={(e) => admin.set('imap_host', e.target.value)}
/>
</MAdminField>
<div className="grid grid-cols-2 gap-2">
<MAdminField label={t('mailAdmin.imap.port')}>
<MAdminInput
inputMode="numeric"
value={form.imap_port}
onChange={(e) => admin.set('imap_port', e.target.value)}
/>
<Problem admin={admin} problem="portInvalid" t={t} />
</MAdminField>
<MAdminField label={t('mailAdmin.imap.tls')}>
<MSegmented
value={form.imap_tls}
onChange={(value) => admin.set('imap_tls', value)}
options={[
{ value: 'tls', label: 'TLS' },
{ value: 'starttls', label: 'STARTTLS' },
]}
/>
</MAdminField>
</div>
<MAdminField label={t('mailAdmin.imap.user')}>
<MAdminInput
autoComplete="off"
value={form.imap_user}
onChange={(e) => admin.set('imap_user', e.target.value)}
/>
</MAdminField>
<MAdminField label={t('mailAdmin.imap.pass')}>
<MAdminSecretInput
autoComplete="new-password"
value={form.imap_pass}
onChange={(e) => admin.set('imap_pass', e.target.value)}
/>
</MAdminField>
<MAdminField label={t('mailAdmin.imap.mailbox')} hint={t('mailAdmin.imap.mailboxHint')}>
<MAdminInput value={form.imap_mailbox} onChange={(e) => admin.set('imap_mailbox', e.target.value)} />
</MAdminField>
</>
);
}
function WebhookFields({ admin, form, t }: PartProps) {
return (
<>
<MAdminField label={t('mailAdmin.webhook.secret')} hint={t('mailAdmin.webhook.secretHint')}>
<MAdminSecretInput
autoComplete="new-password"
value={form.webhook_secret}
onChange={(e) => admin.set('webhook_secret', e.target.value)}
/>
<Problem admin={admin} problem="secretShort" t={t} />
</MAdminField>
<MAdminField label={t('mailAdmin.inboundUrl')} hint={t('mailAdmin.inboundUrlHint')}>
<p className="break-all font-geist text-caption text-m-muted">{`${window.location.origin}/api/mail-ingest/inbound`}</p>
</MAdminField>
</>
);
}
/** How the import runs, in counts only. */
function Status({ admin, t, locale }: { admin: MailIngestAdmin; t: TranslationFn; locale: string }) {
const status = admin.status;
if (!status) return null;
const facts = statusFacts(status);
const lastRead = status.last_ok_at
? relativeTime(Date.parse(status.last_ok_at), locale)
: t('mailAdmin.status.never');
return (
<div className="mt-3 rounded-[12px] bg-[color:var(--m-ic)] px-3 py-[10px]">
<p className="text-caption font-semibold text-m-ink">{t('mailAdmin.status.lastRead', { time: lastRead })}</p>
<div className="mt-2 grid grid-cols-2 gap-x-3 gap-y-[6px]">
{[
{ label: t('mailAdmin.status.mails'), value: facts.mails },
{ label: t('mailAdmin.status.added'), value: facts.added },
{ label: t('mailAdmin.status.waiting'), value: facts.waiting },
{ label: t('mailAdmin.status.failed'), value: facts.failed },
].map((f) => (
<div key={f.label} className="flex items-baseline justify-between gap-2">
<span className="truncate text-caption text-m-faint">{f.label}</span>
<span className="font-geist text-body font-bold tabular-nums text-m-ink">{f.value}</span>
</div>
))}
</div>
{status.last_error && <p className="mt-2 text-caption text-[color:var(--m-st-pending)]">{status.last_error}</p>}
</div>
);
}
function Setup({ admin, form, t, locale }: PartProps & { locale: string }) {
return (
<div className="flex flex-col gap-3 px-[14px] pb-[14px] pt-1">
<MAdminField label={t('mailAdmin.transport.label')}>
<MSegmented
value={form.transport}
onChange={(value) => admin.set('transport', value)}
options={TRANSPORTS.map((x) => ({ value: x, label: transportLabel(x, t) }))}
/>
</MAdminField>
<MAdminField label={t('mailAdmin.template')} hint={t('mailAdmin.templateHint', { token: '{token}' })}>
<MAdminInput
value={form.address_template}
placeholder="trek+{token}@example.com"
onChange={(e) => admin.set('address_template', e.target.value)}
/>
<Problem admin={admin} problem="templateToken" t={t} />
</MAdminField>
{form.transport === 'imap' && <ImapFields admin={admin} form={form} t={t} />}
{form.transport === 'webhook' && <WebhookFields admin={admin} form={form} t={t} />}
<MAdminField label={t('mailAdmin.aiDaily')} hint={t('mailAdmin.aiDailyHint')}>
<MAdminInput
inputMode="numeric"
value={form.ai_per_user_daily}
onChange={(e) => admin.set('ai_per_user_daily', e.target.value)}
/>
<Problem admin={admin} problem="dailyInvalid" t={t} />
</MAdminField>
<p className="text-caption text-m-faint">{t('mailAdmin.envHint')}</p>
<div className="flex flex-wrap items-center gap-2">
<MAdminButton busy={admin.saving} disabled={!admin.dirty || admin.problems.length > 0} onClick={admin.save}>
{t('common.save')}
</MAdminButton>
<MAdminButton
variant="ghost"
busy={admin.testing}
disabled={admin.dirty || form.transport === 'off'}
onClick={admin.runTest}
>
{t('mailAdmin.test.run')}
</MAdminButton>
{admin.test && (
<span
className={`text-caption font-semibold ${admin.test.ok ? 'text-[color:var(--m-st-confirmed)]' : 'text-[color:var(--m-st-danger)]'}`}
>
{admin.test.ok ? t('mailAdmin.test.ok') : (admin.test.error ?? t('mailAdmin.test.failed'))}
</span>
)}
</div>
<Status admin={admin} t={t} locale={locale} />
</div>
);
}
/**
* The booking mail import in the phone's admin settings, the same card as on
* the desktop (useMailIngestAdmin): the switch, how mail arrives, the address
* template, the AI allowance, the test and the status. Self-hosted only.
*/
export default function MMailIngestAdminCard() {
const { t, locale } = useTranslation();
const admin = useMailIngestAdmin(true);
return (
<MAdminCard>
<MAdminCardHead title={t('mailAdmin.title')} hint={t('mailAdmin.hint')} />
<MAdminRow
title={t('mailAdmin.enabled')}
hint={t('mailAdmin.enabledHint')}
trailing={
<MToggle
checked={admin.enabled}
ariaLabel={t('mailAdmin.enabled')}
disabled={!admin.loaded}
onChange={admin.toggle}
/>
}
/>
{admin.enabled && admin.form && <Setup admin={admin} form={admin.form} t={t} locale={locale} />}
</MAdminCard>
);
}
@@ -1,18 +1,24 @@
import { useEffect, type ReactElement } from 'react';
import { useMailInboxLink } from '../../../components/MailIngest/useMailInboxLink';
import { useAuthStore } from '../../../store/authStore';
import { useMailIngestStore } from '../../../store/mailIngestStore';
import { useMailIngestStore, watchMailInbox } from '../../../store/mailIngestStore';
import MMailInboxSheet from './MMailInboxSheet';
/**
* The phone shell's one inbox of bookings from forwarded mails: reads the
* person's import settings once they are signed in (the user menu's badge
* needs them) and mounts the sheet only where the import is on.
* needs them), follows the server's inbox events, and mounts the sheet only
* where the import is on.
*/
export default function MMailInboxHost(): ReactElement | null {
const userId = useAuthStore((s) => s.user?.id);
const settings = useMailIngestStore((s) => s.settings);
useEffect(() => {
if (userId) void useMailIngestStore.getState().loadSettings();
if (!userId) return;
void useMailIngestStore.getState().loadSettings();
return watchMailInbox();
}, [userId]);
return settings?.available && settings.prefs.enabled ? <MMailInboxSheet /> : null;
const on = !!settings?.available && settings.prefs.enabled;
useMailInboxLink(on);
return on ? <MMailInboxSheet /> : null;
}
@@ -1,5 +1,5 @@
import type { MailIngestInboxEntry } from '@trek/shared';
import { ChevronLeft, Copy, FilePlus2, FileText, Inbox, Mail, PencilLine, Plus, Sparkles, Trash2 } from 'lucide-react';
import { ChevronLeft, Copy, FilePlus2, FileText, Inbox, Mail, PencilLine, Plus, Trash2 } from 'lucide-react';
import type { ReactElement } from 'react';
import { entryDates, entryTitle, reasonText, type InboxAction } from '../../../components/MailIngest/mailInboxModel';
import { useMailInbox } from '../../../components/MailIngest/useMailInbox';
@@ -23,8 +23,6 @@ function icon(action: InboxAction): ReactElement {
return <FilePlus2 size={15} strokeWidth={2} />;
case 'manual':
return <PencilLine size={15} strokeWidth={2} />;
case 'readAi':
return <Sparkles size={15} strokeWidth={2} />;
case 'discard':
return <Trash2 size={15} strokeWidth={2} />;
}
@@ -40,8 +38,6 @@ function label(action: InboxAction, t: (key: string) => string): string {
return t('mailInbox.action.file');
case 'manual':
return t('mailInbox.action.manual');
case 'readAi':
return t('mailInbox.action.readAi');
case 'discard':
return t('mailInbox.action.discard');
}
@@ -97,8 +93,8 @@ function Entry({ entry, inbox }: { entry: MailIngestInboxEntry; inbox: ReturnTyp
type="button"
aria-label={t('mailInbox.settings.copy')}
className={ROUND}
onClick={async () => {
if (await copyText(code)) toast.success(t('common.copied'));
onClick={() => {
void copyText(code).then((ok) => ok && toast.success(t('common.copied')));
}}
>
<Copy size={15} strokeWidth={2} />
@@ -1,5 +1,7 @@
import { useCurrentFlightLegIndex, useFlightLiveCard } from '../../../components/Planner/flightLive/useFlightLiveCard';
import { useTranslation } from '../../../i18n';
import type { DelayTone } from '../../../utils/flightLiveModel';
import { Field } from './tabs/tabChrome';
const DELAY_COLOR: Record<DelayTone, string> = {
ok: 'var(--m-st-confirmed)',
@@ -18,8 +20,9 @@ interface MFlightLiveStripProps {
}
/**
* A flight's live state on the phone's transport cards: what is happening in
* one line with the delay, and a thin bar across the card while it flies. A
* A flight's live state on the phone's transport cards, as one of the card's
* fields: what is happening in one line with the delay, and a thin bar along
* the field's foot while it flies. A
* tap opens the transport sheet with the full card. Nothing far ahead of the
* flight (the card shows its date already), nothing once it is long over.
*/
@@ -32,37 +35,31 @@ export default function MFlightLiveStrip({ tripId, reservationId, onOpen, classN
}
function Strip({ tripId, reservationId, legIndex, onOpen, className }: MFlightLiveStripProps & { legIndex: number }) {
const { t } = useTranslation();
const card = useFlightLiveCard(tripId, reservationId, legIndex);
if (!card || card.view.stage === 'upcoming') return null;
const { view } = card;
// One of the card's own fields, full width: the label over the framed value, like the booking code below it.
return (
<button
type="button"
onClick={onOpen}
className={`flex min-w-0 flex-col gap-[6px] rounded-[10px] border border-[color:var(--m-rowbr)] bg-m-card px-[10px] py-[7px] text-start ${className}`}
>
<span className="flex w-full min-w-0 items-center gap-2">
<span className="min-w-0 flex-1 truncate text-caption font-semibold text-m-ink">{card.line}</span>
{card.delay && card.delay.tone !== 'ok' && (
<span
className="flex-none font-geist text-caption font-bold tabular-nums"
style={{ color: DELAY_COLOR[card.delay.tone] }}
>
{card.delay.text}
<button type="button" onClick={onOpen} className={`block text-start ${className}`}>
<Field label={t('reservations.status')} className="w-full">
<span className="inline-flex max-w-full items-center gap-2 align-middle">
<span className="min-w-0 truncate">{card.line}</span>
{card.delay && card.delay.tone !== 'ok' && (
<span className="flex-none tabular-nums" style={{ color: DELAY_COLOR[card.delay.tone] }}>
{card.delay.text}
</span>
)}
</span>
{view.stage === 'airborne' && (
<span className="-mx-[10px] -mb-[7px] mt-[6px] block h-[3px] bg-[color:var(--m-rowbr)]" aria-hidden="true">
<span
className="block h-full transition-[width] duration-700 motion-reduce:transition-none"
style={{ width: `${Math.round(view.progress * 100)}%`, background: LIVE }}
/>
</span>
)}
</span>
{view.stage === 'airborne' && (
<span
className="block h-[3px] w-full overflow-hidden rounded-full bg-[color:var(--m-rowbr)]"
aria-hidden="true"
>
<span
className="block h-full rounded-full transition-[width] duration-700 motion-reduce:transition-none"
style={{ width: `${Math.round(view.progress * 100)}%`, background: LIVE }}
/>
</span>
)}
</Field>
</button>
);
}
@@ -127,15 +127,12 @@ export default function MFlightLiveCard({ tripId, reservationId, legIndex, legLa
showPlane={view.stage !== 'upcoming' && view.stage !== 'soon'}
dimPlane={view.stale}
credits={card.attribution ? [card.attribution, FLIGHT_MAP_CREDIT] : [FLIGHT_MAP_CREDIT]}
inset={0.36}
className="!absolute inset-0 [--fm-ahead:var(--m-muted)] [--fm-code:var(--m-muted)] [--fm-end:var(--m-inner)] [--fm-flown:var(--m-st-info)] [--fm-halo:var(--m-inner)] [--fm-plane:var(--m-st-info)]"
inset={0.06}
className="flight-mini-map--narrow !absolute inset-y-2 end-[25%] start-[25%] [--fm-ahead:var(--m-muted)] [--fm-code:var(--m-muted)] [--fm-end:var(--m-inner)] [--fm-flown:var(--m-st-info)] [--fm-halo:var(--m-inner)] [--fm-plane:var(--m-st-info)]"
/>
<End label={t('reservations.span.departure')} code={leg.origin.code} city={leg.origin.name} cell={card.dep} />
<div className="relative z-[1] flex items-end justify-center">
<span className="rounded-full bg-[color:var(--m-inner)] px-[7px] py-px font-geist text-caption font-bold tracking-[.06em] text-m-muted shadow-sm">
{leg.flight_number}
</span>
</div>
{/* The middle column keeps the map clear; the flight number is in the sheet's title already. */}
<div aria-hidden="true" />
<End
label={t('reservations.span.arrival')}
code={leg.destination.code}
@@ -0,0 +1,32 @@
import { AlertCircle, Check } from 'lucide-react';
import { useTranslation } from '../../../../i18n';
interface MReviewNoticeProps {
/** With the right to edit the booking: "Looks right" takes the mark off. */
onConfirm?: () => void;
}
/**
* The phone's counterpart of the review pill in the desktop dialog: an import
* could not read every detail for sure, so the booking asks for a look. The
* one action says it was checked; editing the booking works as always.
*/
export default function MReviewNotice({ onConfirm }: MReviewNoticeProps) {
const { t } = useTranslation();
return (
<div className="mt-[14px] flex items-center gap-[10px] rounded-[14px] bg-[color-mix(in_srgb,var(--m-st-pending)_12%,transparent)] px-3 py-[10px]">
<AlertCircle size={16} strokeWidth={2.2} className="flex-none text-[color:var(--m-st-pending)]" />
<span className="min-w-0 flex-1 text-caption leading-snug text-m-ink">{t('reservations.needsReviewHint')}</span>
{onConfirm && (
<button
type="button"
onClick={onConfirm}
className="flex h-[30px] flex-none items-center gap-1 rounded-full bg-[color:var(--m-inner)] px-[11px] font-geist text-caption font-bold text-m-ink shadow-sm"
>
<Check size={13} strokeWidth={2.4} />
{t('reservations.reviewConfirm')}
</button>
)}
</div>
);
}
@@ -23,6 +23,7 @@ import { downloadAttachment, openAttachment } from '../../../../components/Plann
import { flowText } from '../../../../utils/flowText'
import { useFlightLiveLegs } from '../../../../store/flightLiveStore'
import MFlightLiveCard from './MFlightLiveCard'
import MReviewNotice from './MReviewNotice'
interface TransportSheetPayload {
reservationId?: number
@@ -231,6 +232,17 @@ export default function MTransportSheet({ planner, shell }: MTripSheetsProps) {
</div>
<div className="m-scroll-fade mt-2 min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px] pt-[14px]">
{/* ── An import could not read every detail for sure ── */}
{!!res.needs_review && (
<MReviewNotice
onConfirm={canEdit ? () => {
planner.tripActions.updateReservation(planner.tripId, res.id, { needs_review: 0 })
.then(() => planner.toast.success(t('reservations.reviewConfirmed')))
.catch(() => planner.toast.error(t('reservations.toast.updateError')))
} : undefined}
/>
)}
{/* ── A stay: check-in, check-out, nights ── */}
{hotelStats.length > 0 && (
<div className="mt-[14px] flex gap-[6px]">
@@ -158,7 +158,7 @@ function TransportCard({ res, planner, shell, canEdit, compact }: {
{/* A flight TREK follows live: its moment in one line, on the compact card too. */}
{res.type === 'flight' && (
<MFlightLiveStrip tripId={planner.tripId} reservationId={res.id} onOpen={openDetail} className={compact ? 'mx-3 my-2' : 'mx-3 mt-[9px]'} />
<MFlightLiveStrip tripId={planner.tripId} reservationId={res.id} onOpen={openDetail} className={compact ? 'mx-3 my-2 w-[calc(100%-24px)]' : 'mx-3 mt-[9px] w-[calc(100%-24px)]'} />
)}
{/* Body — split around the booking code so the reveal can be its own
+23 -18
View File
@@ -16,6 +16,7 @@ import GoogleOptions from './GoogleOptions'
import GoogleDailyLimitRow from '../../components/Admin/GoogleDailyLimitRow'
import ProviderBlock from './ProviderBlock'
import TrekApiCard from './TrekApiCard'
import MailIngestAdminCard from '../../components/Admin/mailIngest/MailIngestAdminCard'
import PlaceContribPanel from '../../components/Admin/PlaceContribPanel'
import { useAdminSettingsActions } from '../../components/Admin/useAdminSettingsActions'
import { placesGoogleOnlyHint } from '../../utils/placeSource'
@@ -391,6 +392,28 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
)}
{!managed && fileTypesCard}
{/* Live flight status: open ADS-B data, no key, on by default; the admin may keep it off. */}
<SettingsCard icon={Plane} title={t('flightLive.adminTitle')} hint={managed ? t('flightLive.adminHintManaged') : t('flightLive.adminHint')}>
<SettingRows>
<SettingRow
label={t('flightLive.adminToggle')}
hint={flightLiveLocked ? t('flightLive.envLocked') : undefined}
control={
<ToggleSwitch
on={flightLive}
label={t('flightLive.adminToggle')}
disabled={flightLiveLocked}
onToggle={() => void handleToggleAuthSetting('flight_live_enabled', !flightLive, setFlightLive)}
/>
}
/>
</SettingRows>
</SettingsCard>
{/* Booking mail import: how forwarded booking mails reach this install. On a managed
install the operator runs the TREK Cloud relay, so the card is not shown there. */}
{!managed && <MailIngestAdminCard />}
</div>
<div className="min-w-0">
@@ -517,24 +540,6 @@ export default function AdminSettingsTab({ admin, t }: AdminSettingsTabProps): R
</SettingsCard>
)}
{/* Live flight status: open ADS-B data, no key, on by default; the admin may keep it off. */}
<SettingsCard icon={Plane} title={t('flightLive.adminTitle')} hint={managed ? t('flightLive.adminHintManaged') : t('flightLive.adminHint')}>
<SettingRows>
<SettingRow
label={t('flightLive.adminToggle')}
hint={flightLiveLocked ? t('flightLive.envLocked') : undefined}
control={
<ToggleSwitch
on={flightLive}
label={t('flightLive.adminToggle')}
disabled={flightLiveLocked}
onToggle={() => void handleToggleAuthSetting('flight_live_enabled', !flightLive, setFlightLive)}
/>
}
/>
</SettingRows>
</SettingsCard>
{/* API Keys: only the keyed providers, Google, Unsplash and Amap. They are
the operator's on a managed install, so the card is not shown there
(server managed.ts), and neither is the provider pick in the card
+38
View File
@@ -0,0 +1,38 @@
// FE-MAILINGEST-REPO-001 to FE-MAILINGEST-REPO-003
import { afterEach, describe, expect, it, vi } from 'vitest';
vi.mock('../api/mailIngest', () => ({
mailIngestApi: { inboxCount: vi.fn(), tripAddress: vi.fn(), adminSettings: vi.fn() },
}));
import { mailIngestApi } from '../api/mailIngest';
import { _resetNetworkMode, setForcedOffline } from '../sync/networkMode';
import { mailIngestRepo } from './mailIngestRepo';
import { OnlineOnlyError } from './onlineOnly';
afterEach(() => {
_resetNetworkMode();
vi.mocked(mailIngestApi.inboxCount).mockReset();
});
describe('mailIngestRepo', () => {
it('FE-MAILINGEST-REPO-001: answers parsed against the shared contract', async () => {
vi.mocked(mailIngestApi.inboxCount).mockResolvedValue({ pending: 3 });
vi.mocked(mailIngestApi.tripAddress).mockResolvedValue({ address: null });
expect(await mailIngestRepo.inboxCount()).toBe(3);
expect(await mailIngestRepo.tripAddress(7)).toBeNull();
});
it('FE-MAILINGEST-REPO-002: an answer of another shape fails here, not in the view', async () => {
vi.mocked(mailIngestApi.inboxCount).mockResolvedValue({ pending: 'many' });
await expect(mailIngestRepo.inboxCount()).rejects.toThrow();
vi.mocked(mailIngestApi.adminSettings).mockResolvedValue(null);
expect(await mailIngestRepo.adminSettings()).toEqual({});
});
it('FE-MAILINGEST-REPO-003: offline nothing is asked, and the caller hears why', async () => {
setForcedOffline(true);
await expect(mailIngestRepo.inboxCount()).rejects.toBeInstanceOf(OnlineOnlyError);
expect(mailIngestApi.inboxCount).not.toHaveBeenCalled();
});
});
+104
View File
@@ -0,0 +1,104 @@
import {
mailIngestAddressResponseSchema,
mailIngestAdminStatusResponseSchema,
mailIngestAdminTestResponseSchema,
mailIngestCandidateResponseSchema,
mailIngestInboxCountResponseSchema,
mailIngestInboxResponseSchema,
mailIngestMessagePreviewResponseSchema,
mailIngestPrefillResponseSchema,
mailIngestSettingsResponseSchema,
mailIngestUploadResponseSchema,
} from '@trek/shared';
import { mailIngestApi, type MailIngestAction } from '../api/mailIngest';
import { requireOnline } from './onlineOnly';
/**
* The booking mail import: the person's settings and forwarding addresses,
* the inbox of bookings read from forwarded mails, the actions on them, and
* the admin's transport settings and status.
*
* **Deliberately online-only, and no Dexie table.** Every action asks the
* server to decide (which trip, whether it is a duplicate, whether the mail
* may still be undone), so there is nothing an offline copy could do.
* Answers are parsed against the shared contracts rather than cast, so a
* server that answered something else fails here, naming the field.
*/
export const mailIngestRepo = {
async settings() {
requireOnline();
return mailIngestSettingsResponseSchema.parse(await mailIngestApi.settings());
},
async updateSettings(body: { enabled?: boolean; ai_enabled?: boolean; auto_add?: boolean }) {
requireOnline();
return mailIngestSettingsResponseSchema.parse(await mailIngestApi.updateSettings(body));
},
async renewAddress() {
requireOnline();
return mailIngestAddressResponseSchema.parse(await mailIngestApi.renewAddress());
},
async tripAddress(tripId: number) {
requireOnline();
return mailIngestAddressResponseSchema.parse(await mailIngestApi.tripAddress(tripId)).address;
},
async createTripAddress(tripId: number) {
requireOnline();
return mailIngestAddressResponseSchema.parse(await mailIngestApi.createTripAddress(tripId)).address;
},
async inbox(before?: number) {
requireOnline();
return mailIngestInboxResponseSchema.parse(await mailIngestApi.inbox({ status: 'open', before }));
},
async inboxCount() {
requireOnline();
return mailIngestInboxCountResponseSchema.parse(await mailIngestApi.inboxCount()).pending;
},
async preview(messageId: number) {
requireOnline();
return mailIngestMessagePreviewResponseSchema.parse(await mailIngestApi.preview(messageId));
},
async prefill(candidateId: number) {
requireOnline();
return mailIngestPrefillResponseSchema.parse(await mailIngestApi.prefill(candidateId));
},
async act(candidateId: number, action: MailIngestAction, body: Record<string, unknown> = {}) {
requireOnline();
return mailIngestCandidateResponseSchema.parse(await mailIngestApi.act(candidateId, action, body)).candidate;
},
async upload(file: File) {
requireOnline();
return mailIngestUploadResponseSchema.parse(await mailIngestApi.upload(file));
},
async adminStatus() {
requireOnline();
return mailIngestAdminStatusResponseSchema.parse(await mailIngestApi.adminStatus());
},
async adminTest() {
requireOnline();
return mailIngestAdminTestResponseSchema.parse(await mailIngestApi.adminTest());
},
/** The admin's stored app settings (passwords masked), of which the mail form reads its own keys. */
async adminSettings(): Promise<Record<string, unknown>> {
requireOnline();
const raw = await mailIngestApi.adminSettings();
return raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
},
async saveAdminSettings(patch: Record<string, string | boolean>) {
requireOnline();
await mailIngestApi.saveAdminSettings(patch);
},
};
+28 -3
View File
@@ -1,13 +1,18 @@
// FE-STORE-MAILINGEST-001 to FE-STORE-MAILINGEST-004
// FE-STORE-MAILINGEST-001 to FE-STORE-MAILINGEST-005
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../api/mailIngest', () => ({
mailIngestApi: { settings: vi.fn(), inbox: vi.fn(), inboxCount: vi.fn(), act: vi.fn() },
}));
vi.mock('../sync/networkMode', () => ({ isEffectivelyOffline: vi.fn(() => false) }));
const listeners = vi.hoisted(() => new Set<(event: Record<string, unknown>) => void>());
vi.mock('../api/websocket', () => ({
addListener: (fn: (event: Record<string, unknown>) => void) => listeners.add(fn),
removeListener: (fn: (event: Record<string, unknown>) => void) => listeners.delete(fn),
}));
import { mailIngestApi } from '../api/mailIngest';
import { useMailIngestStore } from './mailIngestStore';
import { useMailIngestStore, watchMailInbox } from './mailIngestStore';
const candidate = (id: number, resolution = 'pending') => ({
id,
@@ -79,8 +84,28 @@ describe('mailIngestStore', () => {
it('FE-STORE-MAILINGEST-004: an entry still open after an action stays, updated', async () => {
useMailIngestStore.setState({ entries: [entry(1)] as never, pending: 1 });
vi.mocked(mailIngestApi.act).mockResolvedValue({ candidate: { ...candidate(1), reason: 'ai_provider_error' } });
await useMailIngestStore.getState().act(1, 'read-ai');
await useMailIngestStore.getState().act(1, 'apply');
expect(useMailIngestStore.getState().entries[0].reason).toBe('ai_provider_error');
expect(useMailIngestStore.getState().pending).toBe(1);
});
it('FE-STORE-MAILINGEST-005: the server inbox events move the count, read the list again and reload the settings', async () => {
vi.mocked(mailIngestApi.inbox).mockResolvedValue({ entries: [entry(3)], pending: 1, next_before: null });
vi.mocked(mailIngestApi.settings).mockResolvedValue({ nope: true });
const stop = watchMailInbox();
const send = (event: Record<string, unknown>) => listeners.forEach((fn) => fn(event));
send({ type: 'mailInbox:changed', pending: 1 });
expect(useMailIngestStore.getState().pending).toBe(1);
await vi.waitFor(() => expect(useMailIngestStore.getState().entries.map((e) => e.id)).toEqual([3]));
send({ type: 'mailInbox:settings' });
expect(mailIngestApi.settings).toHaveBeenCalled();
// A malformed event changes nothing, and after stopping nothing is heard.
send({ type: 'mailInbox:changed', pending: 'many' });
expect(useMailIngestStore.getState().pending).toBe(1);
stop();
expect(listeners.size).toBe(0);
});
});
+38 -18
View File
@@ -1,14 +1,8 @@
import {
mailIngestCandidateResponseSchema,
mailIngestInboxCountResponseSchema,
mailIngestInboxResponseSchema,
mailIngestSettingsResponseSchema,
type MailIngestCandidate,
type MailIngestInboxEntry,
type MailIngestSettingsResponse,
} from '@trek/shared';
import type { MailIngestCandidate, MailIngestInboxEntry, MailIngestSettingsResponse } from '@trek/shared';
import { create } from 'zustand';
import { mailIngestApi, type MailIngestAction } from '../api/mailIngest';
import type { MailIngestAction } from '../api/mailIngest';
import { addListener, removeListener } from '../api/websocket';
import { mailIngestRepo } from '../repo/mailIngestRepo';
import { isEffectivelyOffline } from '../sync/networkMode';
/**
@@ -43,16 +37,23 @@ export const useMailIngestStore = create<MailIngestState>((set, get) => ({
loadSettings: async () => {
if (isEffectivelyOffline()) return;
const parsed = mailIngestSettingsResponseSchema.safeParse(await mailIngestApi.settings().catch(() => null));
if (parsed.success) set({ settings: parsed.data, pending: parsed.data.pending });
try {
const settings = await mailIngestRepo.settings();
set({ settings, pending: settings.pending });
} catch {
// Without an answer the menus keep what they showed; the next sign-in or event reads it again.
}
},
setSettings: (settings) => set({ settings, pending: settings.pending }),
loadCount: async () => {
if (isEffectivelyOffline()) return;
const parsed = mailIngestInboxCountResponseSchema.safeParse(await mailIngestApi.inboxCount().catch(() => null));
if (parsed.success) set({ pending: parsed.data.pending });
try {
set({ pending: await mailIngestRepo.inboxCount() });
} catch {
// The badge keeps its last count until the next event brings a new one.
}
},
loadInbox: async (more = false) => {
@@ -60,9 +61,7 @@ export const useMailIngestStore = create<MailIngestState>((set, get) => ({
set({ loadingInbox: true });
try {
const before = more ? (get().nextBefore ?? undefined) : undefined;
const parsed = mailIngestInboxResponseSchema.safeParse(await mailIngestApi.inbox({ status: 'open', before }));
if (!parsed.success) return;
const { entries, pending, next_before } = parsed.data;
const { entries, pending, next_before } = await mailIngestRepo.inbox(before);
set((state) => ({ entries: more ? [...state.entries, ...entries] : entries, pending, nextBefore: next_before }));
} catch {
// The inbox stays as it was; the next open reads it again.
@@ -72,7 +71,7 @@ export const useMailIngestStore = create<MailIngestState>((set, get) => ({
},
act: async (candidateId, action, body = {}) => {
const { candidate } = mailIngestCandidateResponseSchema.parse(await mailIngestApi.act(candidateId, action, body));
const candidate = await mailIngestRepo.act(candidateId, action, body);
set((state) => {
const stillOpen = OPEN.has(candidate.resolution);
const entries = state.entries.flatMap((entry) =>
@@ -84,3 +83,24 @@ export const useMailIngestStore = create<MailIngestState>((set, get) => ({
return candidate;
},
}));
/**
* Keeps the inbox current while the app is open: the server says when the
* person's open count changed (a mail read, an entry decided on another
* device) and when their import settings did. The count moves at once; a
* list already read is read again, so an open inbox shows the new entry
* without a reload. Mounted once per signed-in shell (Navbar, MMailInboxHost).
*/
export function watchMailInbox(): () => void {
const onEvent = (event: Record<string, unknown>) => {
const store = useMailIngestStore.getState();
if (event?.type === 'mailInbox:changed' && typeof event.pending === 'number') {
useMailIngestStore.setState({ pending: event.pending });
if (store.entries.length > 0 || event.pending > 0) void store.loadInbox();
} else if (event?.type === 'mailInbox:settings') {
void store.loadSettings();
}
};
addListener(onEvent);
return () => removeListener(onEvent);
}
+3
View File
@@ -6,6 +6,7 @@ import { useAssistantHintsStore } from '../../src/store/assistantHintsStore';
import { useAuthStore } from '../../src/store/authStore';
import { useFlightLiveStore } from '../../src/store/flightLiveStore';
import { useInAppNotificationStore } from '../../src/store/inAppNotificationStore';
import { useMailIngestStore } from '../../src/store/mailIngestStore';
import { usePaddleObstacleStore } from '../../src/store/paddleObstacleStore';
import { usePermissionsStore } from '../../src/store/permissionsStore';
import { useSettingsStore } from '../../src/store/settingsStore';
@@ -51,6 +52,8 @@ export const RESET_STORES: Record<string, ResettableStore> = {
useAssistantHintsStore,
// Live flight states are per test: a leg seeded by one test must not show up in the next.
useFlightLiveStore,
// The mail inbox and its pending count, which the menus of every shell read.
useMailIngestStore,
};
/** Not reset so far; moving one into RESET_STORES changes what every file that resets starts from. */
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+45
View File
@@ -0,0 +1,45 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'استيراد رسائل الحجز',
'mailAdmin.hint': 'تتحول رسائل تأكيد الحجز المُعاد توجيهها إلى حجوزات في الرحلة المناسبة.',
'mailAdmin.enabled': 'استيراد رسائل الحجز',
'mailAdmin.enabledHint': 'يمنح كل مستخدم في هذا التثبيت عنوان إعادة توجيه. عند الإيقاف، لا يستقبل أي عنوان بريداً.',
'mailAdmin.transport.label': 'كيفية وصول البريد',
'mailAdmin.transport.off': 'الرفع فقط',
'mailAdmin.transport.imap': 'صندوق بريد IMAP',
'mailAdmin.transport.webhook': 'ويب هوك',
'mailAdmin.aiDaily': 'رسائل الذكاء الاصطناعي لكل شخص في اليوم',
'mailAdmin.aiDailyHint': 'فوق هذا الحد، تُقرأ بيانات الحجز القابلة للقراءة آلياً فقط.',
'mailAdmin.template': 'قالب العنوان',
'mailAdmin.templateHint': 'عنوان إعادة التوجيه، مع {token} في موضع رمز كل شخص.',
'mailAdmin.imap.host': 'الخادم',
'mailAdmin.imap.port': 'المنفذ',
'mailAdmin.imap.tls': 'التشفير',
'mailAdmin.imap.user': 'المستخدم',
'mailAdmin.imap.pass': 'كلمة المرور',
'mailAdmin.imap.mailbox': 'المجلد',
'mailAdmin.imap.mailboxHint': 'تُنقل الرسائل المقروءة إلى TREK/processed.',
'mailAdmin.webhook.secret': 'سر التوقيع',
'mailAdmin.webhook.secretHint': '32 حرفاً على الأقل؛ يوقّع المرسل كل رسالة به. ترك الحقل فارغاً يوقف ويب هوك.',
'mailAdmin.inboundUrl': 'عنوان ويب هوك',
'mailAdmin.inboundUrlHint': 'تنشر خدمة البريد لديك كل رسالة هنا، خاماً وموقّعاً.',
'mailAdmin.copyUrl': 'نسخ العنوان',
'mailAdmin.envHint': 'متغيرات البيئة (MAIL_INGEST_*) لها الأولوية على هذه الحقول.',
'mailAdmin.loadError': 'تعذّر تحميل إعدادات استيراد البريد.',
'mailAdmin.problem.secretShort': 'يجب أن يتكون السر من 32 حرفاً على الأقل.',
'mailAdmin.problem.portInvalid': 'أدخل منفذاً بين 1 و65535.',
'mailAdmin.problem.templateToken': 'يحتاج العنوان إلى {token} في موضع رمز كل شخص.',
'mailAdmin.problem.dailyInvalid': 'أدخل عدداً صحيحاً من 0 إلى 1000.',
'mailAdmin.status.title': 'الحالة',
'mailAdmin.status.lastRead': 'آخر قراءة {time}',
'mailAdmin.status.never': 'أبداً',
'mailAdmin.status.mails': 'الرسائل، 24 ساعة',
'mailAdmin.status.added': 'أُضيفت تلقائياً',
'mailAdmin.status.waiting': 'بالانتظار',
'mailAdmin.status.failed': 'فشلت',
'mailAdmin.test.run': 'اختبار الاتصال',
'mailAdmin.test.ok': 'تم الاتصال',
'mailAdmin.test.failed': 'لا يوجد اتصال',
};
export default mailAdmin;
+100
View File
@@ -0,0 +1,100 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'حجوزات قيد الانتظار',
'mailInbox.pendingCount': '{count} حجز بانتظارك',
'mailInbox.pendingCount.zero': '{count} حجز بانتظارك',
'mailInbox.pendingCount.one': '{count} حجز بانتظارك',
'mailInbox.pendingCount.two': '{count} حجزان بانتظارك',
'mailInbox.pendingCount.few': '{count} حجوزات بانتظارك',
'mailInbox.pendingCount.many': '{count} حجزاً بانتظارك',
'mailInbox.empty': 'لا شيء بالانتظار',
'mailInbox.emptyHint': 'الحجوزات التي لم يتمكن TREK من إضافتها تلقائياً تظهر هنا، مع الخيارات المتاحة لكل منها.',
'mailInbox.untitled': 'بريد حجز',
'mailInbox.showMail': 'عرض البريد',
'mailInbox.backToList': 'العودة إلى القائمة',
'mailInbox.loadMore': 'عرض المزيد',
'mailInbox.pickTrip': 'أي رحلة؟',
'mailInbox.added': 'تمت إضافة الحجز',
'mailInbox.filed': 'تم الحفظ في ملفات الرحلة',
'mailInbox.discarded': 'تم التجاهل',
'mailInbox.actionError': 'لم تنجح العملية. حاول مرة أخرى بعد قليل.',
'mailInbox.previewError': 'تعذّر عرض البريد.',
'mailInbox.action.add': 'إضافة',
'mailInbox.action.choose': 'إضافة إلى رحلة',
'mailInbox.action.file': 'حفظ كمستند',
'mailInbox.action.manual': 'الإدخال يدوياً',
'mailInbox.action.discard': 'تجاهل',
'mailInbox.reason.forwardingConfirmation': 'يطلب مزوّد بريدك تأكيد إعادة التوجيه. أدخل هذا الرمز هناك.',
'mailInbox.reason.newsletter': 'نشرة بريدية وليست حجزاً',
'mailInbox.reason.autoReply': 'رد تلقائي',
'mailInbox.reason.emptyMail': 'لا يحتوي البريد على نص أو مرفق يمكن قراءته',
'mailInbox.reason.mailUnreadable': 'تعذّرت قراءة البريد',
'mailInbox.reason.notABooking': 'لم يُعثر على حجز في هذا البريد',
'mailInbox.reason.tooLarge': 'البريد كبير جداً للقراءة',
'mailInbox.reason.addressInvalid': 'وصل إلى عنوان لم يعد صالحاً',
'mailInbox.reason.addressDailyLimit': 'بلغ عنوانك الحد اليومي من الرسائل',
'mailInbox.reason.processingFailed': 'فشلت قراءته عدة مرات',
'mailInbox.reason.aiQuotaExhausted': 'استُنفدت حصة الذكاء الاصطناعي لهذه الفترة',
'mailInbox.reason.aiDisabled': 'قُرئ بدون ذكاء اصطناعي ولم يُعثر على حجز',
'mailInbox.reason.aiUserCap': 'بلغت حدك اليومي من الرسائل المقروءة بالذكاء الاصطناعي',
'mailInbox.reason.aiProviderError': 'تعذّرت قراءته بالذكاء الاصطناعي الآن',
'mailInbox.reason.noTripInRange': 'لا توجد رحلة تغطي هذه التواريخ',
'mailInbox.reason.tripAmbiguous': 'قد تكون المقصودة أكثر من رحلة',
'mailInbox.reason.tripWithoutDates': 'ليس للرحلة تواريخ للمطابقة معها',
'mailInbox.reason.noEditPermission': 'ليس لديك إذن بإضافة حجوزات إلى هذه الرحلة',
'mailInbox.reason.demoMode': 'النسخة التجريبية لا تضيف حجوزات',
'mailInbox.reason.outsideTripDates': 'هناك تاريخ خارج فترة الرحلة',
'mailInbox.reason.sourceMismatch': 'تفاصيل البريد متعارضة فيما بينها',
'mailInbox.reason.implausible': 'هناك قيمة لا يمكن أن تكون صحيحة',
'mailInbox.reason.notInText': 'قيمة مقروءة غير موجودة في البريد',
'mailInbox.reason.imageOnly': 'قُرئ من صورة فقط، لذا يحتاج إلى مراجعتك',
'mailInbox.reason.missingCriticalField': 'النوع أو التاريخ أو المسار مفقود',
'mailInbox.reason.missingConfirmation': 'لم يُعثر على رقم مرجع الحجز',
'mailInbox.reason.tooManyBookings': 'عدد الحجوزات في البريد الواحد أكبر من الحد المسموح لإضافتها تلقائياً',
'mailInbox.reason.changeOrCancellation': 'تعديل أو إلغاء لحجز سابق',
'mailInbox.reason.cancelled': 'الحجز ملغى',
'mailInbox.reason.possibleDuplicate': 'قد تحتوي الرحلة على هذا الحجز بالفعل',
'mailInbox.reason.changeAvailable': 'الرحلة تحتوي على هذا الحجز، والبريد يعدّله',
'mailInbox.reason.conflict': 'الرحلة تحتوي على هذا الحجز بتفاصيل مختلفة',
'mailInbox.reason.askFirst': 'طلبت تأكيد كل حجز',
'mailInbox.settings.title': 'بريد الحجوزات',
'mailInbox.settings.on': 'مفعّل',
'mailInbox.settings.off': 'متوقف',
'mailInbox.settings.yourAddress': 'عنوانك',
'mailInbox.settings.copy': 'نسخ العنوان',
'mailInbox.settings.copyError': 'تعذّر النسخ',
'mailInbox.settings.renew': 'عنوان جديد',
'mailInbox.settings.renewConfirm': 'اضغط مرة أخرى: سيتوقف العنوان القديم عن العمل',
'mailInbox.settings.renewed': 'عنوانك الجديد جاهز',
'mailInbox.settings.saveError': 'تعذّر الحفظ',
'mailInbox.settings.addressHint':
'أعد توجيه رسائل تأكيد الحجز إلى هذا العنوان وسيضيفها TREK إلى الرحلة المناسبة. احتفظ به لنفسك كأنه كلمة مرور.',
'mailInbox.settings.howTo': 'إعادة التوجيه تلقائياً',
'mailInbox.settings.howToGmail':
'جيميل: الإعدادات، إعادة التوجيه وPOP/IMAP، إضافة عنوان لإعادة التوجيه. يرسل جيميل رمزاً يظهر ضمن الحجوزات قيد الانتظار. بعد ذلك أضف عامل تصفية لمرسلي الحجوزات يعيد التوجيه إلى TREK.',
'mailInbox.settings.howToOutlook':
'أوتلوك: الإعدادات، البريد، القواعد، أضف قاعدة تعيد توجيه رسائل الحجز. بعض حسابات العمل تمنع إعادة التوجيه التلقائي؛ أعد التوجيه يدوياً في هذه الحالة.',
'mailInbox.settings.howToManual': 'يدوياً: أعد توجيه أي رسالة حجز مع مرفقاتها.',
'mailInbox.settings.enabled': 'استيراد رسائل الحجز',
'mailInbox.settings.enabledHint': 'عند الإيقاف، لا تُقرأ الرسائل الواردة إلى عنوانك.',
'mailInbox.settings.ai': 'القراءة بالذكاء الاصطناعي',
'mailInbox.settings.aiHint':
'يقرأ أي رسالة حجز ويحدد رحلتها. يُرسل البريد وتواريخ رحلاتك ووجهاتها إلى مزوّد الذكاء الاصطناعي.',
'mailInbox.settings.aiUnavailable': 'غير متاح هنا: تُقرأ فقط الرسائل التي تحتوي على بيانات حجز قابلة للقراءة آلياً.',
'mailInbox.settings.autoAdd': 'الإضافة تلقائياً',
'mailInbox.settings.autoAddHint':
'الحجز الذي يتأكد منه TREK يُضاف مباشرة إلى رحلته، ويمكنك التراجع عنه خلال 7 أيام. عند الإيقاف، ينتظر كل حجز موافقتك.',
'mailInbox.settings.upload': 'رفع ملف بريد',
'mailInbox.settings.uploaded': 'تم استلام البريد وجارٍ قراءته',
'mailInbox.settings.uploadDuplicate': 'هذا البريد مضاف بالفعل',
'mailInbox.settings.uploadError': 'تعذّرت قراءة الملف كرسالة بريد',
'mailInbox.settings.privacy':
'تُحفظ الرسائل المُعاد توجيهها مشفّرة لمدة 30 يوماً كحد أقصى ثم تُحذف. يُرجى عدم إعادة توجيه وثائق الهوية أو المستندات الصحية.',
'mailInbox.trip.title': 'أو أعد التوجيه بالبريد',
'mailInbox.trip.hint': 'رسالة الحجز المرسلة إلى عنوان هذه الرحلة تصل إلى هنا دون أي تخمين.',
'mailInbox.trip.create': 'إنشاء عنوان لهذه الرحلة',
'mailInbox.fromMail': 'من بريد',
'mailInbox.fromMailHint': 'أُضيف من رسالة حجز مُعاد توجيهها',
};
export default mailInbox;
+6 -1
View File
@@ -63,7 +63,12 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': 'تمت إضافة {booking} إلى {trip}',
'notif.mail_booking_added.shared': 'أضاف {actor} الحجز {booking} إلى {trip} من بريد مُعاد توجيهه',
'notif.mail_booking_pending.title': 'حجوزات مُعاد توجيهها للمراجعة',
'notif.mail_booking_pending.text': 'حجوزات مُعاد توجيهها بانتظارك: {count}',
'notif.mail_booking_pending.text': '{count} حجز مُعاد توجيهه بانتظارك',
'notif.mail_booking_pending.text.zero': '{count} حجز مُعاد توجيهه بانتظارك',
'notif.mail_booking_pending.text.one': '{count} حجز مُعاد توجيهه بانتظارك',
'notif.mail_booking_pending.text.two': '{count} حجزان مُعاد توجيههما بانتظارك',
'notif.mail_booking_pending.text.few': '{count} حجوزات مُعاد توجيهها بانتظارك',
'notif.mail_booking_pending.text.many': '{count} حجزاً مُعاد توجيهه بانتظارك',
'notif.mail_ingest_paused.title': 'تم إيقاف استيراد البريد مؤقتًا',
'notif.mail_ingest_paused.text':
'لا تُقبل رسائل الحجز المُعاد توجيهها في الوقت الحالي. تبقى عناوينك وستعمل مجددًا عند عودة الاستيراد.',
+4 -1
View File
@@ -35,7 +35,10 @@ const reservations: TranslationStrings = {
'reservations.layover.connection': 'رحلة متّصلة',
'reservations.layover.layover': 'توقف بيني',
'reservations.needsReview': 'مراجعة',
'reservations.needsReviewHint': 'تعذّر مطابقة المطار تلقائياً — يرجى تأكيد الموقع.',
'reservations.needsReviewHint': 'عُبّئت بعض التفاصيل تلقائياً وتعذّرت قراءتها بشكل مؤكد. يُرجى التحقق منها.',
'reservations.reviewConfirm': 'تبدو صحيحة',
'reservations.reviewConfirmHint': 'اضغط لتحديدها كمراجَعة.',
'reservations.reviewConfirmed': 'تم تحديدها كمراجَعة',
'reservations.searchLocation': 'ابحث عن محطة، ميناء، عنوان...',
'reservations.meta.trainNumber': 'رقم القطار',
'reservations.meta.platform': 'المنصة',
+3
View File
@@ -110,6 +110,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'تأخر الرحلات',
'settings.notifyFlightLanded': 'هبوط الرحلات',
'settings.notifyTripReminder': 'تذكيرات الرحلات',
'settings.notifyMailBookingAdded': 'حجوزات أُضيفت من رسائل البريد',
'settings.notifyMailBookingPending': 'حجوزات مُعاد توجيهها للمراجعة',
'settings.notifyMailIngestPaused': 'تم إيقاف استيراد البريد مؤقتاً',
'settings.notifyTodoDue': 'مهمة مستحقة',
'settings.notifyVacayInvite': 'دعوات دمج الإجازات',
'settings.notifyVacayShare': 'مشاركات تقويم Vacay',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Rezervasiya məktublarının idxalı',
'mailAdmin.hint': 'Yönləndirilən rezervasiya təsdiqləri düzgün səyahətdə rezervasiyaya çevrilir.',
'mailAdmin.enabled': 'Rezervasiya məktublarını idxal et',
'mailAdmin.enabledHint':
'Bu quraşdırmadakı hər kəsə yönləndirmə ünvanı verir. Deaktiv olduqda heç bir ünvan məktub qəbul etmir.',
'mailAdmin.transport.label': 'Məktub necə gəlir',
'mailAdmin.transport.off': 'Yalnız yükləmə',
'mailAdmin.transport.imap': 'IMAP poçt qutusu',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'Adambaşı gündəlik süni intellekt məktubu',
'mailAdmin.aiDailyHint': 'Bundan yuxarıda yalnız maşın tərəfindən oxuna bilən rezervasiya məlumatı oxunur.',
'mailAdmin.template': 'Ünvan şablonu',
'mailAdmin.templateHint': 'Yönləndirmə ünvanı; hər şəxsin kodu gələn yerdə {token} olur.',
'mailAdmin.imap.host': 'Server', // same-as-en
'mailAdmin.imap.port': 'Port', // same-as-en
'mailAdmin.imap.tls': 'Şifrələmə',
'mailAdmin.imap.user': 'İstifadəçi',
'mailAdmin.imap.pass': 'Parol',
'mailAdmin.imap.mailbox': 'Qovluq',
'mailAdmin.imap.mailboxHint': 'Oxunan məktublar TREK/processed qovluğuna köçürülür.',
'mailAdmin.webhook.secret': 'İmza açarı',
'mailAdmin.webhook.secretHint':
'Ən azı 32 simvol; göndərən hər məktubu bununla imzalayır. Boş buraxılsa webhook söndürülür.',
'mailAdmin.inboundUrl': 'Webhook ünvanı',
'mailAdmin.inboundUrlHint': 'Poçt xidmətin hər məktubu xam və imzalı şəkildə bura göndərir.',
'mailAdmin.copyUrl': 'Ünvanı kopyala',
'mailAdmin.envHint': 'Mühit dəyişənləri (MAIL_INGEST_*) bu sahələrdən üstündür.',
'mailAdmin.loadError': 'Məktub idxalı ayarları yüklənmədi.',
'mailAdmin.problem.secretShort': 'Açar ən azı 32 simvol olmalıdır.',
'mailAdmin.problem.portInvalid': '1 ilə 65535 arasında port daxil et.',
'mailAdmin.problem.templateToken': 'Ünvanda hər şəxsin kodu üçün {token} olmalıdır.',
'mailAdmin.problem.dailyInvalid': '0 ilə 1000 arasında tam ədəd daxil et.',
'mailAdmin.status.title': 'Status', // same-as-en
'mailAdmin.status.lastRead': 'Son oxuma {time}',
'mailAdmin.status.never': 'heç vaxt',
'mailAdmin.status.mails': 'Məktublar, 24 saat',
'mailAdmin.status.added': 'Özbaşına əlavə edilən',
'mailAdmin.status.waiting': 'Gözləyən',
'mailAdmin.status.failed': 'Uğursuz',
'mailAdmin.test.run': 'Bağlantını yoxla',
'mailAdmin.test.ok': 'Qoşuldu',
'mailAdmin.test.failed': 'Bağlantı yoxdur',
};
export default mailAdmin;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Gözləyən rezervasiyalar',
'mailInbox.pendingCount': '{count} rezervasiya səni gözləyir',
'mailInbox.pendingCount.one': '{count} rezervasiya səni gözləyir',
'mailInbox.empty': 'Gözləyən heç nə yoxdur',
'mailInbox.emptyHint':
'TREK-in özbaşına əlavə edə bilmədiyi rezervasiyalar burada görünür, hər biri üçün edə biləcəklərinlə birlikdə.',
'mailInbox.untitled': 'Rezervasiya məktubu',
'mailInbox.showMail': 'Məktubu göstər',
'mailInbox.backToList': 'Siyahıya qayıt',
'mailInbox.loadMore': 'Daha çox göstər',
'mailInbox.pickTrip': 'Hansı səyahət?',
'mailInbox.added': 'Rezervasiya əlavə edildi',
'mailInbox.filed': 'Səyahətin fayllarına saxlanıldı',
'mailInbox.discarded': 'Silindi',
'mailInbox.actionError': 'Əməliyyat alınmadı. Bir azdan yenidən yoxla.',
'mailInbox.previewError': 'Məktub göstərilə bilmədi.',
'mailInbox.action.add': 'Əlavə et',
'mailInbox.action.choose': 'Səyahətə əlavə et',
'mailInbox.action.file': 'Sənəd kimi saxla',
'mailInbox.action.manual': 'Özün doldur',
'mailInbox.action.discard': 'Sil',
'mailInbox.reason.forwardingConfirmation':
'Poçt provayderin yönləndirməni təsdiqləməyi istəyir. Bu kodu orada daxil et.',
'mailInbox.reason.newsletter': 'Xəbər bülleteni, rezervasiya deyil',
'mailInbox.reason.autoReply': 'Avtomatik cavab',
'mailInbox.reason.emptyMail': 'Məktubda oxunacaq mətn və ya əlavə yoxdur',
'mailInbox.reason.mailUnreadable': 'Məktub oxuna bilmədi',
'mailInbox.reason.notABooking': 'Bu məktubda rezervasiya tapılmadı',
'mailInbox.reason.tooLarge': 'Məktub oxunmaq üçün çox böyükdür',
'mailInbox.reason.addressInvalid': 'Artıq işləməyən bir ünvana gəlib',
'mailInbox.reason.addressDailyLimit': 'Ünvanın bugünkü məktub limitinə çatıb',
'mailInbox.reason.processingFailed': 'Oxuma bir neçə dəfə uğursuz oldu',
'mailInbox.reason.aiQuotaExhausted': 'Bu dövrün süni intellekt limiti bitib',
'mailInbox.reason.aiDisabled': 'Süni intellektsiz oxundu, rezervasiya tapılmadı',
'mailInbox.reason.aiUserCap': 'Süni intellektlə oxunan məktublar üçün gündəlik limitinə çatdın',
'mailInbox.reason.aiProviderError': 'Süni intellekt hazırda oxuya bilmədi',
'mailInbox.reason.noTripInRange': 'Bu tarixləri əhatə edən səyahət yoxdur',
'mailInbox.reason.tripAmbiguous': 'Birdən çox səyahət nəzərdə tutulmuş ola bilər',
'mailInbox.reason.tripWithoutDates': 'Səyahətin müqayisə üçün tarixi yoxdur',
'mailInbox.reason.noEditPermission': 'Bu səyahətə rezervasiya əlavə etmək icazən yoxdur',
'mailInbox.reason.demoMode': 'Demo rezervasiya əlavə etmir',
'mailInbox.reason.outsideTripDates': 'Bir tarix səyahətdən kənardadır',
'mailInbox.reason.sourceMismatch': 'Məktubdakı məlumatlar bir-biri ilə uyğun gəlmir',
'mailInbox.reason.implausible': 'Bir dəyər düzgün ola bilməz',
'mailInbox.reason.notInText': 'Oxunan bir dəyər məktubda yoxdur',
'mailInbox.reason.imageOnly': 'Yalnız şəkildən oxundu, ona görə baxmalısan',
'mailInbox.reason.missingCriticalField': 'Növ, tarix və ya marşrut çatışmır',
'mailInbox.reason.missingConfirmation': 'Rezervasiya nömrəsi tapılmadı',
'mailInbox.reason.tooManyBookings': 'Bir məktubda özbaşına əlavə edilənlərdən çox rezervasiya var',
'mailInbox.reason.changeOrCancellation': 'Əvvəlki rezervasiyanın dəyişikliyi və ya ləğvi',
'mailInbox.reason.cancelled': 'Rezervasiya ləğv edilib',
'mailInbox.reason.possibleDuplicate': 'Səyahətdə bu rezervasiya artıq ola bilər',
'mailInbox.reason.changeAvailable': 'Səyahətdə bu rezervasiya var, məktub onu dəyişir',
'mailInbox.reason.conflict': 'Səyahətdə bu rezervasiya fərqli məlumatlarla var',
'mailInbox.reason.askFirst': 'Hər rezervasiyanı təsdiqləməyi istəmisən',
'mailInbox.settings.title': 'Rezervasiya məktubları',
'mailInbox.settings.on': 'Aktiv',
'mailInbox.settings.off': 'Deaktiv',
'mailInbox.settings.yourAddress': 'Ünvanın',
'mailInbox.settings.copy': 'Ünvanı kopyala',
'mailInbox.settings.copyError': 'Kopyalanmadı',
'mailInbox.settings.renew': 'Yeni ünvan',
'mailInbox.settings.renewConfirm': 'Yenidən toxun: köhnə ünvan işləməyəcək',
'mailInbox.settings.renewed': 'Yeni ünvanın hazırdır',
'mailInbox.settings.saveError': 'Yadda saxlanmadı',
'mailInbox.settings.addressHint':
'Rezervasiya təsdiqlərini bu ünvana yönləndir, TREK onları düzgün səyahətə əlavə edəcək. Parol kimi onu özündə saxla.',
'mailInbox.settings.howTo': 'Avtomatik yönləndir',
'mailInbox.settings.howToGmail':
'Gmail: Ayarlar, Yönləndirmə və POP/IMAP, Yönləndirmə ünvanı əlavə et. Gmail kod göndərir, kod Gözləyən rezervasiyalar altında görünür. Sonra rezervasiya göndərənləri üçün TREK-ə yönləndirən filtr əlavə et.',
'mailInbox.settings.howToOutlook':
'Outlook: Ayarlar, Poçt, Qaydalar, rezervasiya məktublarını yönləndirən qayda əlavə et. Bəzi iş hesabları avtomatik yönləndirməni bloklayır; orada əllə yönləndir.',
'mailInbox.settings.howToManual': 'Əllə: istənilən rezervasiya məktubunu əlavələri ilə birlikdə yönləndir.',
'mailInbox.settings.enabled': 'Rezervasiya məktublarını idxal et',
'mailInbox.settings.enabledHint': 'Deaktiv olduqda ünvanına gələn məktublar oxunmur.',
'mailInbox.settings.ai': 'Süni intellektlə oxu',
'mailInbox.settings.aiHint':
'İstənilən rezervasiya məktubunu oxuyur və səyahətini tapır. Məktub, səyahətlərinin tarixləri və təyinat məntəqələri süni intellekt provayderinə göndərilir.',
'mailInbox.settings.aiUnavailable':
'Burada əlçatan deyil: yalnız maşın tərəfindən oxuna bilən rezervasiya məlumatı olan məktublar oxunur.',
'mailInbox.settings.autoAdd': 'Özbaşına əlavə et',
'mailInbox.settings.autoAddHint':
'TREK-in əmin olduğu rezervasiya birbaşa səyahətinə düşür və 7 gün ərzində geri ala bilərsən. Deaktiv olduqda hər biri təsdiqini gözləyir.',
'mailInbox.settings.upload': 'Məktub faylı yüklə',
'mailInbox.settings.uploaded': 'Məktub qəbul edildi, oxunur',
'mailInbox.settings.uploadDuplicate': 'Bu məktub artıq əlavə edilib',
'mailInbox.settings.uploadError': 'Fayl məktub kimi oxuna bilmədi',
'mailInbox.settings.privacy':
'Yönləndirilən məktublar ən çox 30 gün şifrələnmiş saxlanılır, sonra silinir. Xahiş edirik, şəxsiyyət və ya sağlamlıq sənədlərini yönləndirmə.',
'mailInbox.trip.title': 'Və ya məktubla yönləndir',
'mailInbox.trip.hint': 'Bu səyahətin ünvanına göndərilən rezervasiya məktubu təxmin edilmədən birbaşa bura düşür.',
'mailInbox.trip.create': 'Bu səyahət üçün ünvan yarat',
'mailInbox.fromMail': 'Məktubdan',
'mailInbox.fromMailHint': 'Yönləndirilmiş rezervasiya məktubundan əlavə edilib',
};
export default mailInbox;
+2 -1
View File
@@ -60,7 +60,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.shared':
'{actor} yönləndirilmiş məktubdan {booking} rezervasiyasını {trip} səyahətinə əlavə etdi',
'notif.mail_booking_pending.title': 'Yoxlanılacaq yönləndirilmiş rezervasiyalar',
'notif.mail_booking_pending.text': 'Sizi gözləyən yönləndirilmiş rezervasiyalar: {count}',
'notif.mail_booking_pending.text': '{count} yönləndirilmiş rezervasiya səni gözləyir',
'notif.mail_booking_pending.text.one': '{count} yönləndirilmiş rezervasiya səni gözləyir',
'notif.mail_ingest_paused.title': 'Poçt idxalı dayandırılıb',
'notif.mail_ingest_paused.text':
'Yönləndirilmiş rezervasiya məktubları hələlik qəbul edilmir. Ünvanlarınız qalır və idxal geri qayıdanda yenidən işləyəcək.',
+4 -1
View File
@@ -43,7 +43,10 @@ const reservations: TranslationStrings = {
'reservations.layover.layover': 'Tranzit gözləmə',
'reservations.needsReview': 'Yoxlayın',
'reservations.needsReviewHint': 'Hava limanını avtomatik uyğunlaşdırmaq mümkün olmadı — məkanı təsdiqləyin.',
'reservations.needsReviewHint': 'Bəzi məlumatlar avtomatik dolduruldu və dəqiq oxuna bilmədi. Xahiş edirik, yoxla.',
'reservations.reviewConfirm': 'Düzgün görünür',
'reservations.reviewConfirmHint': 'Yoxlanıldı kimi işarələmək üçün klik et.',
'reservations.reviewConfirmed': 'Yoxlanıldı kimi işarələndi',
'reservations.searchLocation': 'Stansiya, liman və ya ünvan axtarın…',
'reservations.meta.trainNumber': 'Qatar nömrəsi',
+3
View File
@@ -145,6 +145,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Reyslərin gecikməsi',
'settings.notifyFlightLanded': 'Reyslərin enişi',
'settings.notifyTripReminder': 'Səyahət xatırlatmaları',
'settings.notifyMailBookingAdded': 'Məktublardan əlavə edilən rezervasiyalar',
'settings.notifyMailBookingPending': 'Yoxlanılacaq yönləndirilmiş rezervasiyalar',
'settings.notifyMailIngestPaused': 'Məktub idxalı dayandırıldı',
'settings.notifyTodoDue': 'Son tarixi yaxınlaşan tapşırıqlar',
'settings.notifyVacayInvite': 'Vacay birləşdirmə dəvətləri',
'settings.notifyVacayShare': 'Vacay təqvim paylaşımları',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Importação de e-mails de reserva',
'mailAdmin.hint': 'As confirmações de reserva encaminhadas viram reservas na viagem certa.',
'mailAdmin.enabled': 'Importar e-mails de reserva',
'mailAdmin.enabledHint':
'Dá a todos nesta instalação um endereço de encaminhamento. Desativado, nenhum endereço recebe e-mails.',
'mailAdmin.transport.label': 'Como o e-mail chega',
'mailAdmin.transport.off': 'Somente envio de arquivo',
'mailAdmin.transport.imap': 'Caixa de correio IMAP',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'E-mails por IA por pessoa e dia',
'mailAdmin.aiDailyHint': 'Acima disso, só dados de reserva legíveis por máquina são lidos.',
'mailAdmin.template': 'Modelo de endereço',
'mailAdmin.templateHint': 'O endereço de encaminhamento, com {token} no lugar do código de cada pessoa.',
'mailAdmin.imap.host': 'Servidor',
'mailAdmin.imap.port': 'Porta',
'mailAdmin.imap.tls': 'Criptografia',
'mailAdmin.imap.user': 'Usuário',
'mailAdmin.imap.pass': 'Senha',
'mailAdmin.imap.mailbox': 'Pasta',
'mailAdmin.imap.mailboxHint': 'Os e-mails lidos são movidos para TREK/processed.',
'mailAdmin.webhook.secret': 'Segredo de assinatura',
'mailAdmin.webhook.secretHint':
'Pelo menos 32 caracteres; o remetente assina cada e-mail com ele. Vazio, o webhook fica desativado.',
'mailAdmin.inboundUrl': 'Endereço do webhook',
'mailAdmin.inboundUrlHint': 'Seu serviço de e-mail envia cada e-mail para cá, bruto e assinado.',
'mailAdmin.copyUrl': 'Copiar endereço',
'mailAdmin.envHint': 'As variáveis de ambiente (MAIL_INGEST_*) têm prioridade sobre estes campos.',
'mailAdmin.loadError': 'Não foi possível carregar as configurações de importação de e-mails.',
'mailAdmin.problem.secretShort': 'O segredo precisa ter pelo menos 32 caracteres.',
'mailAdmin.problem.portInvalid': 'Digite uma porta entre 1 e 65535.',
'mailAdmin.problem.templateToken': 'O endereço precisa de {token} para o código de cada pessoa.',
'mailAdmin.problem.dailyInvalid': 'Digite um número inteiro de 0 a 1000.',
'mailAdmin.status.title': 'Status', // same-as-en
'mailAdmin.status.lastRead': 'Última leitura {time}',
'mailAdmin.status.never': 'nunca',
'mailAdmin.status.mails': 'E-mails, 24 h',
'mailAdmin.status.added': 'Adicionados automaticamente',
'mailAdmin.status.waiting': 'Aguardando',
'mailAdmin.status.failed': 'Com falha',
'mailAdmin.test.run': 'Testar conexão',
'mailAdmin.test.ok': 'Conectado',
'mailAdmin.test.failed': 'Sem conexão',
};
export default mailAdmin;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Reservas pendentes',
'mailInbox.pendingCount': '{count} reservas esperam por você',
'mailInbox.pendingCount.one': '{count} reserva espera por você',
'mailInbox.empty': 'Nada pendente',
'mailInbox.emptyHint':
'As reservas que o TREK não conseguiu adicionar sozinho aparecem aqui, com o que você pode fazer em cada caso.',
'mailInbox.untitled': 'E-mail de reserva',
'mailInbox.showMail': 'Mostrar e-mail',
'mailInbox.backToList': 'Voltar à lista',
'mailInbox.loadMore': 'Mostrar mais',
'mailInbox.pickTrip': 'Qual viagem?',
'mailInbox.added': 'Reserva adicionada',
'mailInbox.filed': 'Salvo nos arquivos da viagem',
'mailInbox.discarded': 'Descartado',
'mailInbox.actionError': 'Não deu certo. Tente de novo em instantes.',
'mailInbox.previewError': 'Não foi possível mostrar o e-mail.',
'mailInbox.action.add': 'Adicionar',
'mailInbox.action.choose': 'Adicionar a uma viagem',
'mailInbox.action.file': 'Salvar como documento',
'mailInbox.action.manual': 'Preencher você mesmo',
'mailInbox.action.discard': 'Descartar',
'mailInbox.reason.forwardingConfirmation':
'Seu provedor de e-mail pede para confirmar o encaminhamento. Digite este código lá.',
'mailInbox.reason.newsletter': 'Uma newsletter, não uma reserva',
'mailInbox.reason.autoReply': 'Uma resposta automática',
'mailInbox.reason.emptyMail': 'O e-mail não tem texto nem anexo para ler',
'mailInbox.reason.mailUnreadable': 'Não foi possível ler o e-mail',
'mailInbox.reason.notABooking': 'Nenhuma reserva encontrada neste e-mail',
'mailInbox.reason.tooLarge': 'O e-mail é grande demais para ser lido',
'mailInbox.reason.addressInvalid': 'Chegou a um endereço que não funciona mais',
'mailInbox.reason.addressDailyLimit': 'Seu endereço já recebeu todos os e-mails de hoje',
'mailInbox.reason.processingFailed': 'A leitura falhou várias vezes',
'mailInbox.reason.aiQuotaExhausted': 'A cota de IA deste período acabou',
'mailInbox.reason.aiDisabled': 'Lido sem IA, que não encontrou nenhuma reserva',
'mailInbox.reason.aiUserCap': 'Você atingiu o limite diário de e-mails lidos por IA',
'mailInbox.reason.aiProviderError': 'A IA não conseguiu ler agora',
'mailInbox.reason.noTripInRange': 'Nenhuma viagem cobre essas datas',
'mailInbox.reason.tripAmbiguous': 'Pode ser mais de uma viagem',
'mailInbox.reason.tripWithoutDates': 'A viagem não tem datas para comparar',
'mailInbox.reason.noEditPermission': 'Você não pode adicionar reservas a essa viagem',
'mailInbox.reason.demoMode': 'A demonstração não adiciona reservas',
'mailInbox.reason.outsideTripDates': 'Uma data está fora da viagem',
'mailInbox.reason.sourceMismatch': 'Os dados do e-mail não batem entre si',
'mailInbox.reason.implausible': 'Um valor não pode estar certo',
'mailInbox.reason.notInText': 'Um valor lido não consta no e-mail',
'mailInbox.reason.imageOnly': 'Lido só a partir de uma imagem, então precisa da sua conferência',
'mailInbox.reason.missingCriticalField': 'Faltam o tipo, a data ou o trajeto',
'mailInbox.reason.missingConfirmation': 'Nenhum código de reserva encontrado',
'mailInbox.reason.tooManyBookings': 'Há mais reservas em um só e-mail do que o limite para adicionar sozinho',
'mailInbox.reason.changeOrCancellation': 'Uma alteração ou cancelamento de uma reserva anterior',
'mailInbox.reason.cancelled': 'A reserva foi cancelada',
'mailInbox.reason.possibleDuplicate': 'A viagem talvez já tenha essa reserva',
'mailInbox.reason.changeAvailable': 'A viagem já tem essa reserva, e o e-mail a altera',
'mailInbox.reason.conflict': 'A viagem tem essa reserva com outros dados',
'mailInbox.reason.askFirst': 'Você pediu para confirmar cada reserva',
'mailInbox.settings.title': 'E-mails de reserva',
'mailInbox.settings.on': 'Ativado',
'mailInbox.settings.off': 'Desativado',
'mailInbox.settings.yourAddress': 'Seu endereço',
'mailInbox.settings.copy': 'Copiar endereço',
'mailInbox.settings.copyError': 'Não foi possível copiar',
'mailInbox.settings.renew': 'Novo endereço',
'mailInbox.settings.renewConfirm': 'Toque de novo: o endereço antigo deixa de funcionar',
'mailInbox.settings.renewed': 'Seu novo endereço está pronto',
'mailInbox.settings.saveError': 'Não foi possível salvar',
'mailInbox.settings.addressHint':
'Encaminhe as confirmações de reserva para este endereço e o TREK as adiciona à viagem certa. Mantenha-o em sigilo, como uma senha.',
'mailInbox.settings.howTo': 'Encaminhar automaticamente',
'mailInbox.settings.howToGmail':
'Gmail: Configurações, Encaminhamento e POP/IMAP, Adicionar um endereço de encaminhamento. O Gmail envia um código, que aparece em Reservas pendentes. Depois, crie um filtro para os remetentes de reservas que encaminhe ao TREK.',
'mailInbox.settings.howToOutlook':
'Outlook: Configurações, E-mail, Regras, crie uma regra que encaminhe os e-mails de reserva. Algumas contas corporativas bloqueiam o encaminhamento automático; nesses casos, encaminhe manualmente.',
'mailInbox.settings.howToManual': 'Manualmente: encaminhe qualquer e-mail de reserva, com os anexos.',
'mailInbox.settings.enabled': 'Importar e-mails de reserva',
'mailInbox.settings.enabledHint': 'Desativado, os e-mails enviados ao seu endereço não são lidos.',
'mailInbox.settings.ai': 'Ler com IA',
'mailInbox.settings.aiHint':
'Lê qualquer e-mail de reserva e descobre a viagem correspondente. O e-mail e as datas e destinos das suas viagens são enviados ao provedor de IA.',
'mailInbox.settings.aiUnavailable':
'Indisponível aqui: só são lidos e-mails com dados de reserva legíveis por máquina.',
'mailInbox.settings.autoAdd': 'Adicionar automaticamente',
'mailInbox.settings.autoAddHint':
'Uma reserva da qual o TREK tem certeza vai direto para a viagem, e você pode desfazer por 7 dias. Desativado, cada uma espera o seu OK.',
'mailInbox.settings.upload': 'Enviar um arquivo de e-mail',
'mailInbox.settings.uploaded': 'E-mail recebido, está sendo lido',
'mailInbox.settings.uploadDuplicate': 'Este e-mail já foi importado',
'mailInbox.settings.uploadError': 'Não foi possível ler o arquivo como e-mail',
'mailInbox.settings.privacy':
'Os e-mails encaminhados ficam armazenados criptografados por no máximo 30 dias e depois são excluídos. Não encaminhe documentos de identidade nem de saúde.',
'mailInbox.trip.title': 'Ou encaminhe por e-mail',
'mailInbox.trip.hint': 'Um e-mail de reserva enviado ao endereço desta viagem chega aqui sem adivinhação.',
'mailInbox.trip.create': 'Criar um endereço para esta viagem',
'mailInbox.fromMail': 'Do e-mail',
'mailInbox.fromMailHint': 'Adicionado a partir de um e-mail de reserva encaminhado',
};
export default mailInbox;
+2 -1
View File
@@ -59,7 +59,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} foi adicionada a {trip}',
'notif.mail_booking_added.shared': '{actor} adicionou {booking} a {trip} a partir de um e-mail encaminhado',
'notif.mail_booking_pending.title': 'Reservas encaminhadas para revisar',
'notif.mail_booking_pending.text': 'Reservas encaminhadas aguardando você: {count}',
'notif.mail_booking_pending.text': '{count} reservas encaminhadas esperam por você',
'notif.mail_booking_pending.text.one': '{count} reserva encaminhada espera por você',
'notif.mail_ingest_paused.title': 'Importação de e-mails pausada',
'notif.mail_ingest_paused.text':
'Por enquanto, os e-mails de reserva encaminhados não são aceitos. Seus endereços continuam e voltam a funcionar quando a importação voltar.',
+5 -1
View File
@@ -35,7 +35,11 @@ const reservations: TranslationStrings = {
'reservations.layover.connection': 'Conexão',
'reservations.layover.layover': 'Escala',
'reservations.needsReview': 'Verificar',
'reservations.needsReviewHint': 'Aeroporto não pôde ser identificado automaticamente — confirme o local.',
'reservations.needsReviewHint':
'Alguns dados foram preenchidos automaticamente e não puderam ser lidos com certeza. Confira-os.',
'reservations.reviewConfirm': 'Está certo',
'reservations.reviewConfirmHint': 'Clique para marcar como conferido.',
'reservations.reviewConfirmed': 'Marcado como conferido',
'reservations.searchLocation': 'Buscar estação, porto, endereço...',
'reservations.meta.trainNumber': 'Nº do trem',
'reservations.meta.platform': 'Plataforma',
+3
View File
@@ -117,6 +117,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Atrasos de voos',
'settings.notifyFlightLanded': 'Pousos',
'settings.notifyTripReminder': 'Lembretes de viagem',
'settings.notifyMailBookingAdded': 'Reservas adicionadas por e-mail',
'settings.notifyMailBookingPending': 'Reservas encaminhadas para revisar',
'settings.notifyMailIngestPaused': 'Importação de e-mails pausada',
'settings.notifyTodoDue': 'Tarefa com vencimento',
'settings.notifyVacayInvite': 'Convites de fusão Vacay',
'settings.notifyVacayShare': 'Compartilhamentos de calendário Vacay',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Importació de correus de reserva',
'mailAdmin.hint': 'Les confirmacions de reserva reenviades es converteixen en reserves del viatge correcte.',
'mailAdmin.enabled': 'Importa correus de reserva',
'mailAdmin.enabledHint':
'Dona a tothom en aquesta instal·lació una adreça de reenviament. Desactivat, cap adreça rep correu.',
'mailAdmin.transport.label': 'Com arriba el correu',
'mailAdmin.transport.off': 'Només pujada de fitxers',
'mailAdmin.transport.imap': 'Bústia IMAP',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'Correus amb IA per persona i dia',
'mailAdmin.aiDailyHint': 'Per sobre d’aquest nombre, només es llegeixen dades de reserva llegibles per màquina.',
'mailAdmin.template': 'Plantilla d’adreça',
'mailAdmin.templateHint': 'L’adreça de reenviament, amb {token} allà on va el codi de cada persona.',
'mailAdmin.imap.host': 'Servidor',
'mailAdmin.imap.port': 'Port', // same-as-en
'mailAdmin.imap.tls': 'Xifratge',
'mailAdmin.imap.user': 'Usuari',
'mailAdmin.imap.pass': 'Contrasenya',
'mailAdmin.imap.mailbox': 'Carpeta',
'mailAdmin.imap.mailboxHint': 'Els correus llegits es mouen a TREK/processed.',
'mailAdmin.webhook.secret': 'Secret de signatura',
'mailAdmin.webhook.secretHint':
'Almenys 32 caràcters; el remitent signa cada correu amb ell. Buit, el webhook es desactiva.',
'mailAdmin.inboundUrl': 'Adreça del webhook',
'mailAdmin.inboundUrlHint': 'El teu servei de correu hi envia cada correu, en brut i signat.',
'mailAdmin.copyUrl': 'Copia l’adreça',
'mailAdmin.envHint': 'Les variables d’entorn (MAIL_INGEST_*) tenen prioritat sobre aquests camps.',
'mailAdmin.loadError': 'No s’ha pogut carregar la configuració d’importació de correu.',
'mailAdmin.problem.secretShort': 'El secret necessita almenys 32 caràcters.',
'mailAdmin.problem.portInvalid': 'Introdueix un port entre 1 i 65535.',
'mailAdmin.problem.templateToken': 'L’adreça necessita {token} per al codi de cada persona.',
'mailAdmin.problem.dailyInvalid': 'Introdueix un nombre enter de 0 a 1000.',
'mailAdmin.status.title': 'Estat',
'mailAdmin.status.lastRead': 'Darrera lectura {time}',
'mailAdmin.status.never': 'mai',
'mailAdmin.status.mails': 'Correus, 24 h',
'mailAdmin.status.added': 'Afegits automàticament',
'mailAdmin.status.waiting': 'En espera',
'mailAdmin.status.failed': 'Fallits',
'mailAdmin.test.run': 'Prova la connexió',
'mailAdmin.test.ok': 'Connectat',
'mailAdmin.test.failed': 'Sense connexió',
};
export default mailAdmin;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Reserves pendents',
'mailInbox.pendingCount': '{count} reserves t’esperen',
'mailInbox.pendingCount.one': '{count} reserva t’espera',
'mailInbox.empty': 'Res pendent',
'mailInbox.emptyHint':
'Les reserves que TREK no ha pogut afegir per ell mateix apareixen aquí, amb el que pots fer en cada cas.',
'mailInbox.untitled': 'Correu de reserva',
'mailInbox.showMail': 'Mostra el correu',
'mailInbox.backToList': 'Torna a la llista',
'mailInbox.loadMore': 'Mostra’n més',
'mailInbox.pickTrip': 'Quin viatge?',
'mailInbox.added': 'Reserva afegida',
'mailInbox.filed': 'Desat als fitxers del viatge',
'mailInbox.discarded': 'Descartat',
'mailInbox.actionError': 'No ha funcionat. Torna-ho a provar d’aquí a un moment.',
'mailInbox.previewError': 'No s’ha pogut mostrar el correu.',
'mailInbox.action.add': 'Afegeix',
'mailInbox.action.choose': 'Afegeix a un viatge',
'mailInbox.action.file': 'Desa com a document',
'mailInbox.action.manual': 'Omple-ho tu mateix',
'mailInbox.action.discard': 'Descarta',
'mailInbox.reason.forwardingConfirmation':
'El teu proveïdor de correu demana confirmar el reenviament. Introdueix-hi aquest codi.',
'mailInbox.reason.newsletter': 'Un butlletí, no una reserva',
'mailInbox.reason.autoReply': 'Una resposta automàtica',
'mailInbox.reason.emptyMail': 'El correu no té text ni adjunt per llegir',
'mailInbox.reason.mailUnreadable': 'No s’ha pogut llegir el correu',
'mailInbox.reason.notABooking': 'No s’ha trobat cap reserva en aquest correu',
'mailInbox.reason.tooLarge': 'El correu és massa gran per llegir-lo',
'mailInbox.reason.addressInvalid': 'Va arribar a una adreça que ja no funciona',
'mailInbox.reason.addressDailyLimit': 'La teva adreça ja ha rebut tots els correus d’avui',
'mailInbox.reason.processingFailed': 'La lectura ha fallat diverses vegades',
'mailInbox.reason.aiQuotaExhausted': 'S’ha esgotat la quota d’IA d’aquest període',
'mailInbox.reason.aiDisabled': 'Llegit sense IA, que no ha trobat cap reserva',
'mailInbox.reason.aiUserCap': 'Has arribat al teu límit diari de correus llegits amb IA',
'mailInbox.reason.aiProviderError': 'La IA no ho ha pogut llegir ara',
'mailInbox.reason.noTripInRange': 'Cap viatge cobreix aquestes dates',
'mailInbox.reason.tripAmbiguous': 'Podria tractar-se de més d’un viatge',
'mailInbox.reason.tripWithoutDates': 'El viatge no té dates amb què comparar',
'mailInbox.reason.noEditPermission': 'No pots afegir reserves a aquest viatge',
'mailInbox.reason.demoMode': 'La demo no afegeix reserves',
'mailInbox.reason.outsideTripDates': 'Una data queda fora del viatge',
'mailInbox.reason.sourceMismatch': 'Les dades del correu no coincideixen entre elles',
'mailInbox.reason.implausible': 'Un valor no pot ser correcte',
'mailInbox.reason.notInText': 'Un valor llegit no apareix al correu',
'mailInbox.reason.imageOnly': 'Llegit només a partir d’una imatge, per tant necessita la teva revisió',
'mailInbox.reason.missingCriticalField': 'Falta el tipus, la data o el trajecte',
'mailInbox.reason.missingConfirmation': 'No s’ha trobat cap localitzador de reserva',
'mailInbox.reason.tooManyBookings': 'Hi ha més reserves en un sol correu de les que s’afegeixen automàticament',
'mailInbox.reason.changeOrCancellation': 'Un canvi o una cancel·lació d’una reserva anterior',
'mailInbox.reason.cancelled': 'La reserva està cancel·lada',
'mailInbox.reason.possibleDuplicate': 'Potser el viatge ja té aquesta reserva',
'mailInbox.reason.changeAvailable': 'El viatge ja té aquesta reserva, i el correu la modifica',
'mailInbox.reason.conflict': 'El viatge té aquesta reserva amb altres dades',
'mailInbox.reason.askFirst': 'Has demanat confirmar cada reserva',
'mailInbox.settings.title': 'Correus de reserva',
'mailInbox.settings.on': 'Activat',
'mailInbox.settings.off': 'Desactivat',
'mailInbox.settings.yourAddress': 'La teva adreça',
'mailInbox.settings.copy': 'Copia l’adreça',
'mailInbox.settings.copyError': 'No s’ha pogut copiar',
'mailInbox.settings.renew': 'Nova adreça',
'mailInbox.settings.renewConfirm': 'Toca de nou: l’adreça anterior deixarà de funcionar',
'mailInbox.settings.renewed': 'La teva nova adreça està a punt',
'mailInbox.settings.saveError': 'No s’ha pogut desar',
'mailInbox.settings.addressHint':
'Reenvia les confirmacions de reserva a aquesta adreça i TREK les afegeix al viatge correcte. Mantén-la en privat, com una contrasenya.',
'mailInbox.settings.howTo': 'Reenvia automàticament',
'mailInbox.settings.howToGmail':
'Gmail: Configuració, Reenviament i correu POP/IMAP, Afegeix una adreça de reenviament. Gmail envia un codi, que apareix a Reserves pendents. Després afegeix un filtre per als remitents de reserves que reenviï a TREK.',
'mailInbox.settings.howToOutlook':
'Outlook: Configuració, Correu, Regles, afegeix una regla que reenviï els correus de reserva. Alguns comptes de feina bloquegen el reenviament automàtic; allà reenvia a mà.',
'mailInbox.settings.howToManual': 'A mà: reenvia qualsevol correu de reserva, amb els adjunts.',
'mailInbox.settings.enabled': 'Importa correus de reserva',
'mailInbox.settings.enabledHint': 'Desactivat, els correus enviats a la teva adreça no es llegeixen.',
'mailInbox.settings.ai': 'Llegeix amb IA',
'mailInbox.settings.aiHint':
'Llegeix qualsevol correu de reserva i en troba el viatge. El correu i les dates i destinacions dels teus viatges s’envien al proveïdor d’IA.',
'mailInbox.settings.aiUnavailable':
'No disponible aquí: només es llegeixen correus amb dades de reserva llegibles per màquina.',
'mailInbox.settings.autoAdd': 'Afegeix automàticament',
'mailInbox.settings.autoAddHint':
'Una reserva de la qual TREK n’està segur va directa al seu viatge, i pots desfer-ho durant 7 dies. Desactivat, cada una espera la teva aprovació.',
'mailInbox.settings.upload': 'Puja un fitxer de correu',
'mailInbox.settings.uploaded': 'Correu rebut, s’està llegint',
'mailInbox.settings.uploadDuplicate': 'Aquest correu ja està importat',
'mailInbox.settings.uploadError': 'No s’ha pogut llegir el fitxer com a correu',
'mailInbox.settings.privacy':
'Els correus reenviats es conserven xifrats un màxim de 30 dies i després s’eliminen. Si us plau, no reenviïs documents d’identitat ni de salut.',
'mailInbox.trip.title': 'O reenvia per correu',
'mailInbox.trip.hint': 'Un correu de reserva enviat a l’adreça d’aquest viatge arriba aquí sense endevinar res.',
'mailInbox.trip.create': 'Crea una adreça per a aquest viatge',
'mailInbox.fromMail': 'Des de correu',
'mailInbox.fromMailHint': 'Afegit des d’un correu de reserva reenviat',
};
export default mailInbox;
+2 -1
View File
@@ -64,7 +64,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} s’ha afegit a {trip}',
'notif.mail_booking_added.shared': '{actor} ha afegit {booking} a {trip} des d’un correu reenviat',
'notif.mail_booking_pending.title': 'Reserves reenviades per revisar',
'notif.mail_booking_pending.text': 'Reserves reenviades pendents: {count}',
'notif.mail_booking_pending.text': '{count} reserves reenviades t’esperen',
'notif.mail_booking_pending.text.one': '{count} reserva reenviada t’espera',
'notif.mail_ingest_paused.title': 'Importació de correus en pausa',
'notif.mail_ingest_paused.text':
'De moment no s’accepten els correus de reserva reenviats. Les teves adreces es mantenen i tornaran a funcionar quan torni la importació.',
+4 -1
View File
@@ -119,7 +119,10 @@ const reservations: TranslationStrings = {
'reservations.layover.layover': 'Escala',
'reservations.needsReview': 'Revisa',
'reservations.needsReviewHint':
"No s'ha pogut identificar l'aeroport automàticament — si us plau, confirma la ubicació.",
'Algunes dades s’han emplenat automàticament i no s’han pogut llegir amb seguretat. Si us plau, revisa-les.',
'reservations.reviewConfirm': 'És correcte',
'reservations.reviewConfirmHint': 'Fes clic per marcar-ho com a revisat.',
'reservations.reviewConfirmed': 'Marcat com a revisat',
'reservations.searchLocation': 'Cerca estació, port, adreça...',
'reservations.meta.trainNumber': 'Núm. de tren',
'reservations.meta.platform': 'Andana',
+3
View File
@@ -98,6 +98,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Retards de vols',
'settings.notifyFlightLanded': 'Aterratges',
'settings.notifyTripReminder': 'Recordatoris de viatge',
'settings.notifyMailBookingAdded': 'Reserves afegides des de correus',
'settings.notifyMailBookingPending': 'Reserves reenviades per revisar',
'settings.notifyMailIngestPaused': 'Importació de correu en pausa',
'settings.notifyTodoDue': 'Tasca propera',
'settings.notifyVacayInvite': 'Invitacions de fusió Vacay',
'settings.notifyVacayShare': 'Comparticions de calendari Vacay',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Import e-mailů s rezervacemi',
'mailAdmin.hint': 'Přeposlaná potvrzení rezervací se stanou rezervacemi ve správném výletu.',
'mailAdmin.enabled': 'Importovat e-maily s rezervacemi',
'mailAdmin.enabledHint':
'Dá každému na této instalaci adresu pro přeposílání. Při vypnutí žádná adresa nepřijímá poštu.',
'mailAdmin.transport.label': 'Jak e-maily přicházejí',
'mailAdmin.transport.off': 'Pouze nahrávání',
'mailAdmin.transport.imap': 'Schránka IMAP',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'E-maily s AI na osobu a den',
'mailAdmin.aiDailyHint': 'Nad tento limit se čtou jen strojově čitelné údaje o rezervaci.',
'mailAdmin.template': 'Šablona adresy',
'mailAdmin.templateHint': 'Adresa pro přeposílání, kde {token} nahradí kód každého uživatele.',
'mailAdmin.imap.host': 'Server', // same-as-en
'mailAdmin.imap.port': 'Port', // same-as-en
'mailAdmin.imap.tls': 'Šifrování',
'mailAdmin.imap.user': 'Uživatel',
'mailAdmin.imap.pass': 'Heslo',
'mailAdmin.imap.mailbox': 'Složka',
'mailAdmin.imap.mailboxHint': 'Přečtené e-maily se přesunou do TREK/processed.',
'mailAdmin.webhook.secret': 'Podpisový klíč',
'mailAdmin.webhook.secretHint':
'Alespoň 32 znaků; odesílatel jím podepisuje každý e-mail. Prázdný klíč webhook vypne.',
'mailAdmin.inboundUrl': 'Adresa webhooku',
'mailAdmin.inboundUrlHint': 'Tvoje e-mailová služba sem posílá každý e-mail, nezpracovaný a podepsaný.',
'mailAdmin.copyUrl': 'Kopírovat adresu',
'mailAdmin.envHint': 'Proměnné prostředí (MAIL_INGEST_*) mají přednost před těmito poli.',
'mailAdmin.loadError': 'Nastavení importu e-mailů se nepodařilo načíst.',
'mailAdmin.problem.secretShort': 'Klíč musí mít alespoň 32 znaků.',
'mailAdmin.problem.portInvalid': 'Zadej port mezi 1 a 65535.',
'mailAdmin.problem.templateToken': 'Adresa musí obsahovat {token} pro kód každého uživatele.',
'mailAdmin.problem.dailyInvalid': 'Zadej celé číslo od 0 do 1000.',
'mailAdmin.status.title': 'Stav',
'mailAdmin.status.lastRead': 'Naposledy přečteno {time}',
'mailAdmin.status.never': 'nikdy',
'mailAdmin.status.mails': 'E-maily, 24 h',
'mailAdmin.status.added': 'Přidáno automaticky',
'mailAdmin.status.waiting': 'Čeká',
'mailAdmin.status.failed': 'Selhalo',
'mailAdmin.test.run': 'Otestovat připojení',
'mailAdmin.test.ok': 'Připojeno',
'mailAdmin.test.failed': 'Bez připojení',
};
export default mailAdmin;
+98
View File
@@ -0,0 +1,98 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Rezervace čekající na schválení',
'mailInbox.pendingCount': '{count} rezervací čeká na tebe',
'mailInbox.pendingCount.one': '{count} rezervace čeká na tebe',
'mailInbox.pendingCount.few': '{count} rezervace čekají na tebe',
'mailInbox.empty': 'Nic nečeká',
'mailInbox.emptyHint': 'Rezervace, které TREK nedokázal přidat sám, se zobrazí tady i s možnostmi, co s nimi udělat.',
'mailInbox.untitled': 'E-mail s rezervací',
'mailInbox.showMail': 'Zobrazit e-mail',
'mailInbox.backToList': 'Zpět na seznam',
'mailInbox.loadMore': 'Zobrazit více',
'mailInbox.pickTrip': 'Který výlet?',
'mailInbox.added': 'Rezervace přidána',
'mailInbox.filed': 'Uloženo do souborů výletu',
'mailInbox.discarded': 'Zahozeno',
'mailInbox.actionError': 'To se nepovedlo. Zkus to za chvíli znovu.',
'mailInbox.previewError': 'E-mail se nepodařilo zobrazit.',
'mailInbox.action.add': 'Přidat',
'mailInbox.action.choose': 'Přidat k výletu',
'mailInbox.action.file': 'Uložit jako dokument',
'mailInbox.action.manual': 'Vyplnit ručně',
'mailInbox.action.discard': 'Zahodit',
'mailInbox.reason.forwardingConfirmation':
'Tvůj poskytovatel e-mailu žádá o potvrzení přeposílání. Zadej tam tento kód.',
'mailInbox.reason.newsletter': 'Newsletter, ne rezervace',
'mailInbox.reason.autoReply': 'Automatická odpověď',
'mailInbox.reason.emptyMail': 'E-mail neobsahuje žádný text ani přílohu ke čtení',
'mailInbox.reason.mailUnreadable': 'E-mail se nepodařilo přečíst',
'mailInbox.reason.notABooking': 'V tomto e-mailu nebyla nalezena žádná rezervace',
'mailInbox.reason.tooLarge': 'E-mail je příliš velký ke čtení',
'mailInbox.reason.addressInvalid': 'Přišel na adresu, která už nefunguje',
'mailInbox.reason.addressDailyLimit': 'Tvoje adresa dnes přijala maximum e-mailů',
'mailInbox.reason.processingFailed': 'Čtení se několikrát nezdařilo',
'mailInbox.reason.aiQuotaExhausted': 'Limit AI pro toto období je vyčerpán',
'mailInbox.reason.aiDisabled': 'Přečteno bez AI, která nenašla žádnou rezervaci',
'mailInbox.reason.aiUserCap': 'Dosáhl jsi denního limitu e-mailů čtených pomocí AI',
'mailInbox.reason.aiProviderError': 'AI to teď nedokázala přečíst',
'mailInbox.reason.noTripInRange': 'Žádný výlet nepokrývá tato data',
'mailInbox.reason.tripAmbiguous': 'Může jít o více než jeden výlet',
'mailInbox.reason.tripWithoutDates': 'Výlet nemá data, se kterými by se dalo porovnat',
'mailInbox.reason.noEditPermission': 'Do tohoto výletu nesmíš přidávat rezervace',
'mailInbox.reason.demoMode': 'Demo nepřidává žádné rezervace',
'mailInbox.reason.outsideTripDates': 'Datum leží mimo výlet',
'mailInbox.reason.sourceMismatch': 'Údaje v e-mailu si navzájem odporují',
'mailInbox.reason.implausible': 'Některá hodnota nemůže být správná',
'mailInbox.reason.notInText': 'Načtená hodnota v e-mailu není',
'mailInbox.reason.imageOnly': 'Přečteno jen z obrázku, proto je potřeba, aby ses na to podíval',
'mailInbox.reason.missingCriticalField': 'Chybí typ, datum nebo trasa',
'mailInbox.reason.missingConfirmation': 'Nebylo nalezeno číslo rezervace',
'mailInbox.reason.tooManyBookings': 'V jednom e-mailu je více rezervací, než se přidává automaticky',
'mailInbox.reason.changeOrCancellation': 'Změna nebo zrušení dřívější rezervace',
'mailInbox.reason.cancelled': 'Rezervace je zrušena',
'mailInbox.reason.possibleDuplicate': 'Výlet už tuto rezervaci možná obsahuje',
'mailInbox.reason.changeAvailable': 'Výlet tuto rezervaci obsahuje a e-mail ji mění',
'mailInbox.reason.conflict': 'Výlet obsahuje tuto rezervaci s jinými údaji',
'mailInbox.reason.askFirst': 'Chtěl jsi potvrzovat každou rezervaci',
'mailInbox.settings.title': 'E-maily s rezervacemi',
'mailInbox.settings.on': 'Zapnuto',
'mailInbox.settings.off': 'Vypnuto',
'mailInbox.settings.yourAddress': 'Tvoje adresa',
'mailInbox.settings.copy': 'Kopírovat adresu',
'mailInbox.settings.copyError': 'Nepodařilo se zkopírovat',
'mailInbox.settings.renew': 'Nová adresa',
'mailInbox.settings.renewConfirm': 'Klepni znovu: stará adresa přestane fungovat',
'mailInbox.settings.renewed': 'Tvoje nová adresa je připravena',
'mailInbox.settings.saveError': 'Nepodařilo se uložit',
'mailInbox.settings.addressHint':
'Přeposílej potvrzení rezervací na tuto adresu a TREK je přidá do správného výletu. Chraň ji jako heslo.',
'mailInbox.settings.howTo': 'Přeposílat automaticky',
'mailInbox.settings.howToGmail':
'Gmail: Nastavení, Přeposílání a POP/IMAP, Přidat adresu pro přeposílání. Gmail pošle kód, který se zobrazí v části Rezervace čekající na schválení. Poté přidej filtr pro odesílatele rezervací, který přeposílá na TREK.',
'mailInbox.settings.howToOutlook':
'Outlook: Nastavení, Pošta, Pravidla, přidej pravidlo, které přeposílá e-maily s rezervacemi. Některé pracovní účty automatické přeposílání blokují, tam přeposílej ručně.',
'mailInbox.settings.howToManual': 'Ručně: přepošli jakýkoli e-mail s rezervací, včetně příloh.',
'mailInbox.settings.enabled': 'Importovat e-maily s rezervacemi',
'mailInbox.settings.enabledHint': 'Při vypnutí se e-maily na tvou adresu nečtou.',
'mailInbox.settings.ai': 'Číst pomocí AI',
'mailInbox.settings.aiHint':
'Přečte jakýkoli e-mail s rezervací a najde k němu výlet. E-mail a data a cíle tvých výletů se odesílají poskytovateli AI.',
'mailInbox.settings.aiUnavailable': 'Zde není dostupné: čtou se jen e-maily se strojově čitelnými údaji o rezervaci.',
'mailInbox.settings.autoAdd': 'Přidávat automaticky',
'mailInbox.settings.autoAddHint':
'Rezervace, ve které si je TREK jistý, se rovnou přidá do výletu a 7 dní ji můžeš vrátit zpět. Při vypnutí každá čeká na tvé potvrzení.',
'mailInbox.settings.upload': 'Nahrát soubor e-mailu',
'mailInbox.settings.uploaded': 'E-mail přijat, právě se čte',
'mailInbox.settings.uploadDuplicate': 'Tento e-mail už je zde',
'mailInbox.settings.uploadError': 'Soubor se nepodařilo přečíst jako e-mail',
'mailInbox.settings.privacy':
'Přeposlané e-maily se uchovávají zašifrované nejdéle 30 dní a poté se smažou. Nepřeposílej prosím doklady totožnosti ani zdravotní dokumenty.',
'mailInbox.trip.title': 'Nebo přepošli e-mailem',
'mailInbox.trip.hint': 'E-mail s rezervací poslaný na adresu tohoto výletu skončí tady, bez hádání.',
'mailInbox.trip.create': 'Vytvořit adresu pro tento výlet',
'mailInbox.fromMail': 'Z e-mailu',
'mailInbox.fromMailHint': 'Přidáno z přeposlaného e-mailu s rezervací',
};
export default mailInbox;
+3 -1
View File
@@ -60,7 +60,9 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} byla přidána do {trip}',
'notif.mail_booking_added.shared': '{actor} přidal(a) {booking} do {trip} z přeposlaného e-mailu',
'notif.mail_booking_pending.title': 'Přeposlané rezervace ke kontrole',
'notif.mail_booking_pending.text': 'Přeposlané rezervace čekající na vás: {count}',
'notif.mail_booking_pending.text': '{count} přeposlaných rezervací čeká na vás',
'notif.mail_booking_pending.text.one': '{count} přeposlaná rezervace čeká na vás',
'notif.mail_booking_pending.text.few': '{count} přeposlané rezervace čekají na vás',
'notif.mail_ingest_paused.title': 'Import e-mailů pozastaven',
'notif.mail_ingest_paused.text':
'Přeposlané e-maily s rezervacemi se zatím nepřijímají. Vaše adresy zůstávají a znovu budou fungovat, až se import vrátí.',
+5 -1
View File
@@ -35,7 +35,11 @@ const reservations: TranslationStrings = {
'reservations.layover.connection': 'Přípoj',
'reservations.layover.layover': 'Mezipřistání',
'reservations.needsReview': 'Zkontrolovat',
'reservations.needsReviewHint': 'Letiště nebylo možné automaticky rozpoznat — potvrďte prosím místo.',
'reservations.needsReviewHint':
'Některé údaje se vyplnily automaticky a nešlo je přečíst s jistotou. Zkontroluj je prosím.',
'reservations.reviewConfirm': 'Souhlasí',
'reservations.reviewConfirmHint': 'Kliknutím označíš jako zkontrolované.',
'reservations.reviewConfirmed': 'Označeno jako zkontrolované',
'reservations.searchLocation': 'Hledat stanici, přístav, adresu...',
'reservations.meta.trainNumber': 'Číslo vlaku',
'reservations.meta.platform': 'Nástupiště',
+3
View File
@@ -112,6 +112,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Zpoždění letů',
'settings.notifyFlightLanded': 'Přistání',
'settings.notifyTripReminder': 'Připomínky cest',
'settings.notifyMailBookingAdded': 'Rezervace přidané z e-mailů',
'settings.notifyMailBookingPending': 'Přeposlané rezervace ke kontrole',
'settings.notifyMailIngestPaused': 'Import e-mailů pozastaven',
'settings.notifyTodoDue': 'Úkol se blíží',
'settings.notifyVacayInvite': 'Pozvánky k propojení Vacay',
'settings.notifyVacayShare': 'Sdílení kalendáře Vacay',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Buchungsmail-Import',
'mailAdmin.hint': 'Weitergeleitete Buchungsbestätigungen werden zu Buchungen in der passenden Reise.',
'mailAdmin.enabled': 'Buchungsmails importieren',
'mailAdmin.enabledHint':
'Gibt allen auf dieser Instanz eine Weiterleitungsadresse. Aus, nimmt keine Adresse Mails an.',
'mailAdmin.transport.label': 'Wie Mails ankommen',
'mailAdmin.transport.off': 'Nur Hochladen',
'mailAdmin.transport.imap': 'IMAP-Postfach',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'AI-Mails pro Person und Tag',
'mailAdmin.aiDailyHint': 'Darüber werden nur maschinenlesbare Buchungsdaten gelesen.',
'mailAdmin.template': 'Adressvorlage',
'mailAdmin.templateHint': 'Die Weiterleitungsadresse, mit {token} an der Stelle des persönlichen Codes.',
'mailAdmin.imap.host': 'Server', // same-as-en
'mailAdmin.imap.port': 'Port', // same-as-en
'mailAdmin.imap.tls': 'Verschlüsselung',
'mailAdmin.imap.user': 'Benutzer',
'mailAdmin.imap.pass': 'Passwort',
'mailAdmin.imap.mailbox': 'Ordner',
'mailAdmin.imap.mailboxHint': 'Gelesene Mails wandern weiter nach TREK/processed.',
'mailAdmin.webhook.secret': 'Signaturschlüssel',
'mailAdmin.webhook.secretHint':
'Mindestens 32 Zeichen, der Absender signiert damit jede Mail. Leer schaltet den Webhook aus.',
'mailAdmin.inboundUrl': 'Webhook-Adresse',
'mailAdmin.inboundUrlHint': 'Dein Mail-Dienst schickt jede Mail roh und signiert an diese Adresse.',
'mailAdmin.copyUrl': 'Adresse kopieren',
'mailAdmin.envHint': 'Umgebungsvariablen (MAIL_INGEST_*) haben Vorrang vor diesen Feldern.',
'mailAdmin.loadError': 'Die Einstellungen des Mail-Imports konnten nicht geladen werden.',
'mailAdmin.problem.secretShort': 'Der Schlüssel braucht mindestens 32 Zeichen.',
'mailAdmin.problem.portInvalid': 'Gib einen Port zwischen 1 und 65535 ein.',
'mailAdmin.problem.templateToken': 'Die Adresse braucht {token} für den persönlichen Code.',
'mailAdmin.problem.dailyInvalid': 'Gib eine ganze Zahl von 0 bis 1000 ein.',
'mailAdmin.status.title': 'Status', // same-as-en
'mailAdmin.status.lastRead': 'Zuletzt gelesen {time}',
'mailAdmin.status.never': 'noch nie',
'mailAdmin.status.mails': 'Mails, 24 Std.',
'mailAdmin.status.added': 'Selbst eingetragen',
'mailAdmin.status.waiting': 'Offen',
'mailAdmin.status.failed': 'Fehlgeschlagen',
'mailAdmin.test.run': 'Verbindung testen',
'mailAdmin.test.ok': 'Verbunden',
'mailAdmin.test.failed': 'Keine Verbindung',
};
export default mailAdmin;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Offene Buchungen',
'mailInbox.pendingCount': '{count} Buchungen warten auf dich',
'mailInbox.pendingCount.one': '{count} Buchung wartet auf dich',
'mailInbox.empty': 'Nichts offen',
'mailInbox.emptyHint':
'Hier erscheinen Buchungen, die TREK nicht selbst eintragen konnte, mit dem, was du jeweils tun kannst.',
'mailInbox.untitled': 'Buchungsmail',
'mailInbox.showMail': 'Mail anzeigen',
'mailInbox.backToList': 'Zurück zur Liste',
'mailInbox.loadMore': 'Mehr anzeigen',
'mailInbox.pickTrip': 'Welche Reise?',
'mailInbox.added': 'Buchung eingetragen',
'mailInbox.filed': 'In den Dateien der Reise abgelegt',
'mailInbox.discarded': 'Verworfen',
'mailInbox.actionError': 'Das hat nicht geklappt. Versuch es gleich noch einmal.',
'mailInbox.previewError': 'Die Mail konnte nicht angezeigt werden.',
'mailInbox.action.add': 'Eintragen',
'mailInbox.action.choose': 'Zu einer Reise',
'mailInbox.action.file': 'Als Dokument ablegen',
'mailInbox.action.manual': 'Selbst ausfüllen',
'mailInbox.action.discard': 'Verwerfen',
'mailInbox.reason.forwardingConfirmation':
'Dein Mailanbieter möchte die Weiterleitung bestätigt haben. Gib dort diesen Code ein.',
'mailInbox.reason.newsletter': 'Ein Newsletter, keine Buchung',
'mailInbox.reason.autoReply': 'Eine automatische Antwort',
'mailInbox.reason.emptyMail': 'Die Mail hat keinen Text und keinen Anhang zum Lesen',
'mailInbox.reason.mailUnreadable': 'Die Mail konnte nicht gelesen werden',
'mailInbox.reason.notABooking': 'In dieser Mail wurde keine Buchung gefunden',
'mailInbox.reason.tooLarge': 'Die Mail ist zu groß zum Lesen',
'mailInbox.reason.addressInvalid': 'Sie kam an eine Adresse, die nicht mehr gilt',
'mailInbox.reason.addressDailyLimit': 'Deine Adresse hat für heute alle Mails angenommen',
'mailInbox.reason.processingFailed': 'Das Lesen ist mehrmals fehlgeschlagen',
'mailInbox.reason.aiQuotaExhausted': 'Das AI-Kontingent dieses Zeitraums ist aufgebraucht',
'mailInbox.reason.aiDisabled': 'Ohne AI gelesen, dabei wurde keine Buchung gefunden',
'mailInbox.reason.aiUserCap': 'Dein Tageslimit für Mails mit AI ist erreicht',
'mailInbox.reason.aiProviderError': 'Die AI konnte sie gerade nicht lesen',
'mailInbox.reason.noTripInRange': 'Keine Reise passt zu diesen Daten',
'mailInbox.reason.tripAmbiguous': 'Mehrere Reisen kommen infrage',
'mailInbox.reason.tripWithoutDates': 'Die Reise hat keine Daten zum Abgleichen',
'mailInbox.reason.noEditPermission': 'Du darfst in dieser Reise keine Buchungen eintragen',
'mailInbox.reason.demoMode': 'Die Demo trägt keine Buchungen ein',
'mailInbox.reason.outsideTripDates': 'Ein Datum liegt außerhalb der Reise',
'mailInbox.reason.sourceMismatch': 'Die Angaben der Mail widersprechen sich',
'mailInbox.reason.implausible': 'Ein Wert kann nicht stimmen',
'mailInbox.reason.notInText': 'Ein gelesener Wert steht nicht in der Mail',
'mailInbox.reason.imageOnly': 'Nur aus einem Bild gelesen, deshalb bitte prüfen',
'mailInbox.reason.missingCriticalField': 'Art, Datum oder Strecke fehlen',
'mailInbox.reason.missingConfirmation': 'Keine Buchungsnummer gefunden',
'mailInbox.reason.tooManyBookings': 'Mehr Buchungen in einer Mail, als von selbst eingetragen werden',
'mailInbox.reason.changeOrCancellation': 'Eine Änderung oder Stornierung einer früheren Buchung',
'mailInbox.reason.cancelled': 'Die Buchung ist storniert',
'mailInbox.reason.possibleDuplicate': 'Die Reise enthält diese Buchung vielleicht schon',
'mailInbox.reason.changeAvailable': 'Die Reise enthält diese Buchung, und die Mail ändert sie',
'mailInbox.reason.conflict': 'Die Reise enthält diese Buchung mit anderen Angaben',
'mailInbox.reason.askFirst': 'Du möchtest jede Buchung erst bestätigen',
'mailInbox.settings.title': 'Buchungen per Mail',
'mailInbox.settings.on': 'An',
'mailInbox.settings.off': 'Aus',
'mailInbox.settings.yourAddress': 'Deine Adresse',
'mailInbox.settings.copy': 'Adresse kopieren',
'mailInbox.settings.copyError': 'Kopieren nicht möglich',
'mailInbox.settings.renew': 'Neue Adresse',
'mailInbox.settings.renewConfirm': 'Nochmal tippen: Die alte Adresse gilt dann nicht mehr',
'mailInbox.settings.renewed': 'Deine neue Adresse ist bereit',
'mailInbox.settings.saveError': 'Speichern nicht möglich',
'mailInbox.settings.addressHint':
'Leite Buchungsbestätigungen an diese Adresse weiter, TREK trägt sie in die passende Reise ein. Behandle sie wie ein Passwort.',
'mailInbox.settings.howTo': 'Automatisch weiterleiten',
'mailInbox.settings.howToGmail':
'Gmail: Einstellungen, Weiterleitung und POP/IMAP, Weiterleitungsadresse hinzufügen. Gmail schickt einen Code, der unter Offene Buchungen erscheint. Danach einen Filter für Buchungsabsender anlegen, der an TREK weiterleitet.',
'mailInbox.settings.howToOutlook':
'Outlook: Einstellungen, E-Mail, Regeln, eine Regel zum Weiterleiten von Buchungsmails anlegen. Manche Firmenkonten sperren das automatische Weiterleiten; dort von Hand weiterleiten.',
'mailInbox.settings.howToManual': 'Von Hand: Jede Buchungsmail mit Anhängen einfach weiterleiten.',
'mailInbox.settings.enabled': 'Buchungsmails importieren',
'mailInbox.settings.enabledHint': 'Aus, werden Mails an deine Adresse nicht gelesen.',
'mailInbox.settings.ai': 'Mit AI lesen',
'mailInbox.settings.aiHint':
'Liest jede Buchungsmail und findet die Reise. Die Mail sowie Daten und Ziele deiner Reisen gehen an den AI-Anbieter.',
'mailInbox.settings.aiUnavailable':
'Hier nicht verfügbar: Gelesen werden nur Mails mit maschinenlesbaren Buchungsdaten.',
'mailInbox.settings.autoAdd': 'Selbst eintragen',
'mailInbox.settings.autoAddHint':
'Eine sichere Buchung landet direkt in der Reise und lässt sich 7 Tage lang rückgängig machen. Aus, wartet jede auf dein OK.',
'mailInbox.settings.upload': 'Mail-Datei hochladen',
'mailInbox.settings.uploaded': 'Mail angekommen, sie wird gelesen',
'mailInbox.settings.uploadDuplicate': 'Diese Mail ist schon da',
'mailInbox.settings.uploadError': 'Die Datei konnte nicht als Mail gelesen werden',
'mailInbox.settings.privacy':
'Weitergeleitete Mails werden verschlüsselt höchstens 30 Tage aufbewahrt und dann gelöscht. Bitte keine Ausweis- oder Gesundheitsdokumente weiterleiten.',
'mailInbox.trip.title': 'Oder per Mail weiterleiten',
'mailInbox.trip.hint': 'Eine Buchungsmail an die Adresse dieser Reise landet ohne Raten genau hier.',
'mailInbox.trip.create': 'Adresse für diese Reise anlegen',
'mailInbox.fromMail': 'Aus Mail',
'mailInbox.fromMailHint': 'Aus einer weitergeleiteten Buchungsmail eingetragen',
};
export default mailInbox;
+2 -1
View File
@@ -59,7 +59,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} wurde in {trip} eingetragen',
'notif.mail_booking_added.shared': '{actor} hat {booking} aus einer weitergeleiteten Mail in {trip} eingetragen',
'notif.mail_booking_pending.title': 'Weitergeleitete Buchungen prüfen',
'notif.mail_booking_pending.text': 'Weitergeleitete Buchungen warten auf dich: {count}',
'notif.mail_booking_pending.text': '{count} weitergeleitete Buchungen warten auf dich',
'notif.mail_booking_pending.text.one': '{count} weitergeleitete Buchung wartet auf dich',
'notif.mail_ingest_paused.title': 'Mail-Import pausiert',
'notif.mail_ingest_paused.text':
'Weitergeleitete Buchungsmails werden vorerst nicht angenommen. Deine Adressen bleiben und funktionieren wieder, sobald der Import zurück ist.',
+5 -1
View File
@@ -38,7 +38,11 @@ const reservations: TranslationStrings = {
'reservations.layover.connection': 'Anschlussflug',
'reservations.layover.layover': 'Zwischenstopp',
'reservations.needsReview': 'Prüfen',
'reservations.needsReviewHint': 'Flughafen konnte nicht automatisch erkannt werden — bitte Ort bestätigen.',
'reservations.needsReviewHint':
'Einige Angaben wurden automatisch eingetragen und konnten nicht sicher gelesen werden. Bitte prüfen.',
'reservations.reviewConfirm': 'Passt so',
'reservations.reviewConfirmHint': 'Klicken, um sie als geprüft zu markieren.',
'reservations.reviewConfirmed': 'Als geprüft markiert',
'reservations.searchLocation': 'Bahnhof, Hafen, Adresse suchen…',
'reservations.meta.trainNumber': 'Zugnr.',
'reservations.meta.platform': 'Gleis',
+3
View File
@@ -117,6 +117,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Flugverspätungen',
'settings.notifyFlightLanded': 'Landungen',
'settings.notifyTripReminder': 'Trip-Erinnerungen',
'settings.notifyMailBookingAdded': 'Buchungen aus Mails',
'settings.notifyMailBookingPending': 'Weitergeleitete Buchungen zum Prüfen',
'settings.notifyMailIngestPaused': 'Mail-Import pausiert',
'settings.notifyTodoDue': 'Aufgabe bald fällig',
'settings.notifyVacayInvite': 'Vacay Fusion-Einladungen',
'settings.notifyVacayShare': 'Vacay Kalender-Freigaben',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+46
View File
@@ -0,0 +1,46 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Booking mail import',
'mailAdmin.hint': 'Forwarded booking confirmations become bookings in the right trip.',
'mailAdmin.enabled': 'Import booking mails',
'mailAdmin.enabledHint': 'Gives everyone on this install a forwarding address. Off, no address takes mail.',
'mailAdmin.transport.label': 'How mail arrives',
'mailAdmin.transport.off': 'Upload only',
'mailAdmin.transport.imap': 'IMAP mailbox',
'mailAdmin.transport.webhook': 'Webhook',
'mailAdmin.aiDaily': 'AI mails per person and day',
'mailAdmin.aiDailyHint': 'Above it, only machine-readable booking data is read.',
'mailAdmin.template': 'Address template',
'mailAdmin.templateHint': 'The forwarding address, with {token} where each person’s code goes.',
'mailAdmin.imap.host': 'Server',
'mailAdmin.imap.port': 'Port',
'mailAdmin.imap.tls': 'Encryption',
'mailAdmin.imap.user': 'User',
'mailAdmin.imap.pass': 'Password',
'mailAdmin.imap.mailbox': 'Folder',
'mailAdmin.imap.mailboxHint': 'Read mails move on to TREK/processed.',
'mailAdmin.webhook.secret': 'Signing secret',
'mailAdmin.webhook.secretHint':
'At least 32 characters; the sender signs every mail with it. Empty switches the webhook off.',
'mailAdmin.inboundUrl': 'Webhook address',
'mailAdmin.inboundUrlHint': 'Your mail service posts each mail here, raw and signed.',
'mailAdmin.copyUrl': 'Copy address',
'mailAdmin.envHint': 'Environment variables (MAIL_INGEST_*) win over these fields.',
'mailAdmin.loadError': 'The mail import settings could not be loaded.',
'mailAdmin.problem.secretShort': 'The secret needs at least 32 characters.',
'mailAdmin.problem.portInvalid': 'Enter a port between 1 and 65535.',
'mailAdmin.problem.templateToken': 'The address needs {token} for each person’s code.',
'mailAdmin.problem.dailyInvalid': 'Enter a whole number from 0 to 1000.',
'mailAdmin.status.title': 'Status',
'mailAdmin.status.lastRead': 'Last read {time}',
'mailAdmin.status.never': 'never',
'mailAdmin.status.mails': 'Mails, 24 h',
'mailAdmin.status.added': 'Added on their own',
'mailAdmin.status.waiting': 'Waiting',
'mailAdmin.status.failed': 'Failed',
'mailAdmin.test.run': 'Test connection',
'mailAdmin.test.ok': 'Connected',
'mailAdmin.test.failed': 'No connection',
};
export default mailAdmin;
+97
View File
@@ -0,0 +1,97 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Pending bookings',
'mailInbox.pendingCount': '{count} bookings wait for you',
'mailInbox.pendingCount.one': '{count} booking waits for you',
'mailInbox.empty': 'Nothing waiting',
'mailInbox.emptyHint': 'Bookings TREK could not add on its own show up here, with what you can do about each.',
'mailInbox.untitled': 'Booking mail',
'mailInbox.showMail': 'Show mail',
'mailInbox.backToList': 'Back to the list',
'mailInbox.loadMore': 'Show more',
'mailInbox.pickTrip': 'Which trip?',
'mailInbox.added': 'Booking added',
'mailInbox.filed': 'Saved to the trip’s files',
'mailInbox.discarded': 'Discarded',
'mailInbox.actionError': 'That did not work. Try again in a moment.',
'mailInbox.previewError': 'The mail could not be shown.',
'mailInbox.action.add': 'Add',
'mailInbox.action.choose': 'Add to a trip',
'mailInbox.action.file': 'Save as a document',
'mailInbox.action.manual': 'Fill in yourself',
'mailInbox.action.discard': 'Discard',
'mailInbox.reason.forwardingConfirmation':
'Your mail provider asks to confirm the forwarding. Enter this code there.',
'mailInbox.reason.newsletter': 'A newsletter, not a booking',
'mailInbox.reason.autoReply': 'An automatic reply',
'mailInbox.reason.emptyMail': 'The mail has no text or attachment to read',
'mailInbox.reason.mailUnreadable': 'The mail could not be read',
'mailInbox.reason.notABooking': 'No booking found in this mail',
'mailInbox.reason.tooLarge': 'The mail is too large to read',
'mailInbox.reason.addressInvalid': 'It came in on an address that no longer works',
'mailInbox.reason.addressDailyLimit': 'Your address took all its mails for today',
'mailInbox.reason.processingFailed': 'Reading it failed several times',
'mailInbox.reason.aiQuotaExhausted': 'The AI allowance of this period is used up',
'mailInbox.reason.aiDisabled': 'Read without AI, which found no booking',
'mailInbox.reason.aiUserCap': 'Your daily limit of AI-read mails is reached',
'mailInbox.reason.aiProviderError': 'The AI could not read it right now',
'mailInbox.reason.noTripInRange': 'No trip covers these dates',
'mailInbox.reason.tripAmbiguous': 'More than one trip could be meant',
'mailInbox.reason.tripWithoutDates': 'The trip has no dates to check against',
'mailInbox.reason.noEditPermission': 'You may not add bookings to that trip',
'mailInbox.reason.demoMode': 'The demo adds no bookings',
'mailInbox.reason.outsideTripDates': 'A date lies outside the trip',
'mailInbox.reason.sourceMismatch': 'The mail’s details disagree with each other',
'mailInbox.reason.implausible': 'A value cannot be right',
'mailInbox.reason.notInText': 'A value read is not in the mail',
'mailInbox.reason.imageOnly': 'Read from an image alone, so it needs your look',
'mailInbox.reason.missingCriticalField': 'Type, date or route is missing',
'mailInbox.reason.missingConfirmation': 'No booking reference found',
'mailInbox.reason.tooManyBookings': 'More bookings in one mail than are added on their own',
'mailInbox.reason.changeOrCancellation': 'A change or cancellation of an earlier booking',
'mailInbox.reason.cancelled': 'The booking is cancelled',
'mailInbox.reason.possibleDuplicate': 'The trip may hold this booking already',
'mailInbox.reason.changeAvailable': 'The trip holds this booking, and the mail changes it',
'mailInbox.reason.conflict': 'The trip holds this booking with other details',
'mailInbox.reason.askFirst': 'You asked to confirm each booking',
'mailInbox.settings.title': 'Booking mails',
'mailInbox.settings.on': 'On',
'mailInbox.settings.off': 'Off',
'mailInbox.settings.yourAddress': 'Your address',
'mailInbox.settings.copy': 'Copy address',
'mailInbox.settings.copyError': 'Could not copy',
'mailInbox.settings.renew': 'New address',
'mailInbox.settings.renewConfirm': 'Tap again: the old address stops working',
'mailInbox.settings.renewed': 'Your new address is ready',
'mailInbox.settings.saveError': 'Could not save',
'mailInbox.settings.addressHint':
'Forward booking confirmations to this address and TREK adds them to the right trip. Keep it to yourself like a password.',
'mailInbox.settings.howTo': 'Forward automatically',
'mailInbox.settings.howToGmail':
'Gmail: Settings, Forwarding and POP/IMAP, Add a forwarding address. Gmail sends a code, which shows up under Pending bookings. Then add a filter for booking senders that forwards to TREK.',
'mailInbox.settings.howToOutlook':
'Outlook: Settings, Mail, Rules, add a rule that forwards booking mails. Some work accounts block automatic forwarding; forward by hand there.',
'mailInbox.settings.howToManual': 'By hand: forward any booking mail, attachments included.',
'mailInbox.settings.enabled': 'Import booking mails',
'mailInbox.settings.enabledHint': 'Off, mails to your address are not read.',
'mailInbox.settings.ai': 'Read with AI',
'mailInbox.settings.aiHint':
'Reads any booking mail and finds its trip. The mail and the dates and destinations of your trips go to the AI provider.',
'mailInbox.settings.aiUnavailable': 'Not available here: only mails with machine-readable booking data are read.',
'mailInbox.settings.autoAdd': 'Add on its own',
'mailInbox.settings.autoAddHint':
'A booking TREK is sure about goes straight into its trip, and you can undo it for 7 days. Off, each one waits for your OK.',
'mailInbox.settings.upload': 'Upload a mail file',
'mailInbox.settings.uploaded': 'Mail received, it is being read',
'mailInbox.settings.uploadDuplicate': 'This mail is in already',
'mailInbox.settings.uploadError': 'The file could not be read as a mail',
'mailInbox.settings.privacy':
'Forwarded mails are kept encrypted for at most 30 days and then deleted. Please do not forward ID or health documents.',
'mailInbox.trip.title': 'Or forward by mail',
'mailInbox.trip.hint': 'A booking mail sent to this trip’s address lands here without any guessing.',
'mailInbox.trip.create': 'Create an address for this trip',
'mailInbox.fromMail': 'From mail',
'mailInbox.fromMailHint': 'Added from a forwarded booking mail',
};
export default mailInbox;
+2 -1
View File
@@ -59,7 +59,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} was added to {trip}',
'notif.mail_booking_added.shared': '{actor} added {booking} to {trip} from a forwarded mail',
'notif.mail_booking_pending.title': 'Forwarded bookings to review',
'notif.mail_booking_pending.text': 'Forwarded bookings waiting for you: {count}',
'notif.mail_booking_pending.text': '{count} forwarded bookings are waiting for you',
'notif.mail_booking_pending.text.one': '{count} forwarded booking is waiting for you',
'notif.mail_ingest_paused.title': 'Mail import paused',
'notif.mail_ingest_paused.text':
'Forwarded booking mails are not taken in for now. Your addresses stay and work again once the import is back.',
+5 -1
View File
@@ -37,7 +37,11 @@ const reservations: TranslationStrings = {
'reservations.layover.connection': 'Connection',
'reservations.layover.layover': 'Layover',
'reservations.needsReview': 'Review',
'reservations.needsReviewHint': 'Airport could not be matched automatically — please confirm the location.',
'reservations.needsReviewHint':
'Some details were filled in automatically and could not be read for sure. Please check them.',
'reservations.reviewConfirm': 'Looks right',
'reservations.reviewConfirmHint': 'Click to mark it as checked.',
'reservations.reviewConfirmed': 'Marked as checked',
'reservations.searchLocation': 'Search station, port, address…',
'reservations.meta.trainNumber': 'Train No.',
'reservations.meta.platform': 'Platform',
+3
View File
@@ -126,6 +126,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Flight delays',
'settings.notifyFlightLanded': 'Flight landings',
'settings.notifyTripReminder': 'Trip reminders',
'settings.notifyMailBookingAdded': 'Bookings added from mails',
'settings.notifyMailBookingPending': 'Forwarded bookings to review',
'settings.notifyMailIngestPaused': 'Mail import paused',
'settings.notifyTodoDue': 'Todo due soon',
'settings.notifyVacayInvite': 'Vacay fusion invitations',
'settings.notifyVacayShare': 'Vacay calendar shares',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Importación de correos de reserva',
'mailAdmin.hint': 'Las confirmaciones de reserva reenviadas se convierten en reservas del viaje correcto.',
'mailAdmin.enabled': 'Importar correos de reserva',
'mailAdmin.enabledHint':
'Da a todos los usuarios de esta instalación una dirección de reenvío. Desactivado, ninguna dirección recibe correo.',
'mailAdmin.transport.label': 'Cómo llega el correo',
'mailAdmin.transport.off': 'Solo subida de archivos',
'mailAdmin.transport.imap': 'Buzón IMAP',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'Correos con IA por persona y día',
'mailAdmin.aiDailyHint': 'Por encima de ese número, solo se leen datos de reserva legibles por máquina.',
'mailAdmin.template': 'Plantilla de dirección',
'mailAdmin.templateHint': 'La dirección de reenvío, con {token} donde va el código de cada persona.',
'mailAdmin.imap.host': 'Servidor',
'mailAdmin.imap.port': 'Puerto',
'mailAdmin.imap.tls': 'Cifrado',
'mailAdmin.imap.user': 'Usuario',
'mailAdmin.imap.pass': 'Contraseña',
'mailAdmin.imap.mailbox': 'Carpeta',
'mailAdmin.imap.mailboxHint': 'Los correos leídos se mueven a TREK/processed.',
'mailAdmin.webhook.secret': 'Secreto de firma',
'mailAdmin.webhook.secretHint':
'Al menos 32 caracteres; el remitente firma cada correo con él. Vacío, el webhook se desactiva.',
'mailAdmin.inboundUrl': 'Dirección del webhook',
'mailAdmin.inboundUrlHint': 'Tu servicio de correo envía aquí cada correo, en bruto y firmado.',
'mailAdmin.copyUrl': 'Copiar dirección',
'mailAdmin.envHint': 'Las variables de entorno (MAIL_INGEST_*) tienen prioridad sobre estos campos.',
'mailAdmin.loadError': 'No se pudo cargar la configuración de importación de correo.',
'mailAdmin.problem.secretShort': 'El secreto necesita al menos 32 caracteres.',
'mailAdmin.problem.portInvalid': 'Introduce un puerto entre 1 y 65535.',
'mailAdmin.problem.templateToken': 'La dirección necesita {token} para el código de cada persona.',
'mailAdmin.problem.dailyInvalid': 'Introduce un número entero de 0 a 1000.',
'mailAdmin.status.title': 'Estado',
'mailAdmin.status.lastRead': 'Última lectura {time}',
'mailAdmin.status.never': 'nunca',
'mailAdmin.status.mails': 'Correos, 24 h',
'mailAdmin.status.added': 'Añadidos automáticamente',
'mailAdmin.status.waiting': 'En espera',
'mailAdmin.status.failed': 'Fallidos',
'mailAdmin.test.run': 'Probar conexión',
'mailAdmin.test.ok': 'Conectado',
'mailAdmin.test.failed': 'Sin conexión',
};
export default mailAdmin;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Reservas pendientes',
'mailInbox.pendingCount': '{count} reservas te esperan',
'mailInbox.pendingCount.one': '{count} reserva te espera',
'mailInbox.empty': 'Nada pendiente',
'mailInbox.emptyHint':
'Las reservas que TREK no pudo añadir por sí solo aparecen aquí, con lo que puedes hacer en cada caso.',
'mailInbox.untitled': 'Correo de reserva',
'mailInbox.showMail': 'Mostrar correo',
'mailInbox.backToList': 'Volver a la lista',
'mailInbox.loadMore': 'Mostrar más',
'mailInbox.pickTrip': '¿Qué viaje?',
'mailInbox.added': 'Reserva añadida',
'mailInbox.filed': 'Guardado en los archivos del viaje',
'mailInbox.discarded': 'Descartado',
'mailInbox.actionError': 'No ha funcionado. Inténtalo de nuevo en un momento.',
'mailInbox.previewError': 'No se pudo mostrar el correo.',
'mailInbox.action.add': 'Añadir',
'mailInbox.action.choose': 'Añadir a un viaje',
'mailInbox.action.file': 'Guardar como documento',
'mailInbox.action.manual': 'Rellenar tú mismo',
'mailInbox.action.discard': 'Descartar',
'mailInbox.reason.forwardingConfirmation':
'Tu proveedor de correo pide confirmar el reenvío. Introduce este código allí.',
'mailInbox.reason.newsletter': 'Un boletín, no una reserva',
'mailInbox.reason.autoReply': 'Una respuesta automática',
'mailInbox.reason.emptyMail': 'El correo no tiene texto ni adjunto que leer',
'mailInbox.reason.mailUnreadable': 'No se pudo leer el correo',
'mailInbox.reason.notABooking': 'No se encontró ninguna reserva en este correo',
'mailInbox.reason.tooLarge': 'El correo es demasiado grande para leerlo',
'mailInbox.reason.addressInvalid': 'Llegó a una dirección que ya no funciona',
'mailInbox.reason.addressDailyLimit': 'Tu dirección ya ha recibido todos los correos de hoy',
'mailInbox.reason.processingFailed': 'La lectura falló varias veces',
'mailInbox.reason.aiQuotaExhausted': 'Se ha agotado la cuota de IA de este periodo',
'mailInbox.reason.aiDisabled': 'Leído sin IA, que no encontró ninguna reserva',
'mailInbox.reason.aiUserCap': 'Has alcanzado tu límite diario de correos leídos con IA',
'mailInbox.reason.aiProviderError': 'La IA no pudo leerlo ahora',
'mailInbox.reason.noTripInRange': 'Ningún viaje cubre estas fechas',
'mailInbox.reason.tripAmbiguous': 'Podría tratarse de más de un viaje',
'mailInbox.reason.tripWithoutDates': 'El viaje no tiene fechas con las que comparar',
'mailInbox.reason.noEditPermission': 'No puedes añadir reservas a ese viaje',
'mailInbox.reason.demoMode': 'La demo no añade reservas',
'mailInbox.reason.outsideTripDates': 'Una fecha queda fuera del viaje',
'mailInbox.reason.sourceMismatch': 'Los datos del correo no coinciden entre sí',
'mailInbox.reason.implausible': 'Un valor no puede ser correcto',
'mailInbox.reason.notInText': 'Un valor leído no aparece en el correo',
'mailInbox.reason.imageOnly': 'Leído solo a partir de una imagen, así que necesita tu revisión',
'mailInbox.reason.missingCriticalField': 'Falta el tipo, la fecha o el trayecto',
'mailInbox.reason.missingConfirmation': 'No se encontró ningún localizador de reserva',
'mailInbox.reason.tooManyBookings': 'Hay más reservas en un solo correo de las que se añaden automáticamente',
'mailInbox.reason.changeOrCancellation': 'Un cambio o una cancelación de una reserva anterior',
'mailInbox.reason.cancelled': 'La reserva está cancelada',
'mailInbox.reason.possibleDuplicate': 'Puede que el viaje ya tenga esta reserva',
'mailInbox.reason.changeAvailable': 'El viaje ya tiene esta reserva, y el correo la modifica',
'mailInbox.reason.conflict': 'El viaje tiene esta reserva con otros datos',
'mailInbox.reason.askFirst': 'Has pedido confirmar cada reserva',
'mailInbox.settings.title': 'Correos de reserva',
'mailInbox.settings.on': 'Activado',
'mailInbox.settings.off': 'Desactivado',
'mailInbox.settings.yourAddress': 'Tu dirección',
'mailInbox.settings.copy': 'Copiar dirección',
'mailInbox.settings.copyError': 'No se pudo copiar',
'mailInbox.settings.renew': 'Nueva dirección',
'mailInbox.settings.renewConfirm': 'Toca de nuevo: la dirección anterior dejará de funcionar',
'mailInbox.settings.renewed': 'Tu nueva dirección está lista',
'mailInbox.settings.saveError': 'No se pudo guardar',
'mailInbox.settings.addressHint':
'Reenvía las confirmaciones de reserva a esta dirección y TREK las añade al viaje correcto. Mantenla en privado, como una contraseña.',
'mailInbox.settings.howTo': 'Reenviar automáticamente',
'mailInbox.settings.howToGmail':
'Gmail: Configuración, Reenvío y correo POP/IMAP, Añadir una dirección de reenvío. Gmail envía un código, que aparece en Reservas pendientes. Después añade un filtro para los remitentes de reservas que reenvíe a TREK.',
'mailInbox.settings.howToOutlook':
'Outlook: Configuración, Correo, Reglas, añade una regla que reenvíe los correos de reserva. Algunas cuentas de trabajo bloquean el reenvío automático; allí reenvía a mano.',
'mailInbox.settings.howToManual': 'A mano: reenvía cualquier correo de reserva, con sus adjuntos.',
'mailInbox.settings.enabled': 'Importar correos de reserva',
'mailInbox.settings.enabledHint': 'Desactivado, los correos enviados a tu dirección no se leen.',
'mailInbox.settings.ai': 'Leer con IA',
'mailInbox.settings.aiHint':
'Lee cualquier correo de reserva y encuentra su viaje. El correo y las fechas y destinos de tus viajes se envían al proveedor de IA.',
'mailInbox.settings.aiUnavailable':
'No disponible aquí: solo se leen correos con datos de reserva legibles por máquina.',
'mailInbox.settings.autoAdd': 'Añadir automáticamente',
'mailInbox.settings.autoAddHint':
'Una reserva de la que TREK está seguro va directa a su viaje, y puedes deshacerlo durante 7 días. Desactivado, cada una espera tu visto bueno.',
'mailInbox.settings.upload': 'Subir un archivo de correo',
'mailInbox.settings.uploaded': 'Correo recibido, se está leyendo',
'mailInbox.settings.uploadDuplicate': 'Este correo ya está importado',
'mailInbox.settings.uploadError': 'No se pudo leer el archivo como correo',
'mailInbox.settings.privacy':
'Los correos reenviados se guardan cifrados durante un máximo de 30 días y luego se eliminan. Por favor, no reenvíes documentos de identidad ni de salud.',
'mailInbox.trip.title': 'O reenvía por correo',
'mailInbox.trip.hint': 'Un correo de reserva enviado a la dirección de este viaje llega aquí sin adivinar nada.',
'mailInbox.trip.create': 'Crear una dirección para este viaje',
'mailInbox.fromMail': 'Desde correo',
'mailInbox.fromMailHint': 'Añadido desde un correo de reserva reenviado',
};
export default mailInbox;
+2 -1
View File
@@ -59,7 +59,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} se añadió a {trip}',
'notif.mail_booking_added.shared': '{actor} añadió {booking} a {trip} desde un correo reenviado',
'notif.mail_booking_pending.title': 'Reservas reenviadas por revisar',
'notif.mail_booking_pending.text': 'Reservas reenviadas pendientes: {count}',
'notif.mail_booking_pending.text': '{count} reservas reenviadas te esperan',
'notif.mail_booking_pending.text.one': '{count} reserva reenviada te espera',
'notif.mail_ingest_paused.title': 'Importación de correos en pausa',
'notif.mail_ingest_paused.text':
'Por ahora no se aceptan los correos de reserva reenviados. Tus direcciones se mantienen y volverán a funcionar cuando vuelva la importación.',
+4 -1
View File
@@ -119,7 +119,10 @@ const reservations: TranslationStrings = {
'reservations.layover.layover': 'Escala',
'reservations.needsReview': 'Revisar',
'reservations.needsReviewHint':
'No se pudo identificar el aeropuerto automáticamente — por favor confirma la ubicación.',
'Algunos datos se rellenaron automáticamente y no se pudieron leer con seguridad. Por favor, revísalos.',
'reservations.reviewConfirm': 'Es correcto',
'reservations.reviewConfirmHint': 'Haz clic para marcarlo como revisado.',
'reservations.reviewConfirmed': 'Marcado como revisado',
'reservations.searchLocation': 'Buscar estación, puerto, dirección...',
'reservations.meta.trainNumber': 'N° de tren',
'reservations.meta.platform': 'Andén',
+3
View File
@@ -116,6 +116,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Retrasos de vuelos',
'settings.notifyFlightLanded': 'Aterrizajes',
'settings.notifyTripReminder': 'Recordatorios de viaje',
'settings.notifyMailBookingAdded': 'Reservas añadidas desde correos',
'settings.notifyMailBookingPending': 'Reservas reenviadas por revisar',
'settings.notifyMailIngestPaused': 'Importación de correo en pausa',
'settings.notifyTodoDue': 'Tarea próxima',
'settings.notifyVacayInvite': 'Invitaciones de fusión Vacay',
'settings.notifyVacayShare': 'Calendarios Vacay compartidos',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Broneeringukirjade import',
'mailAdmin.hint': 'Edastatud broneeringute kinnitustest saavad broneeringud õigel reisil.',
'mailAdmin.enabled': 'Impordi broneeringukirjad',
'mailAdmin.enabledHint':
'Annab igaühele selles paigalduses edastusaadressi. Väljalülitatuna ei võta ükski aadress kirju vastu.',
'mailAdmin.transport.label': 'Kuidas kiri saabub',
'mailAdmin.transport.off': 'Ainult üleslaadimine',
'mailAdmin.transport.imap': 'IMAP-postkast',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'Tehisintellekti kirju inimese kohta päevas',
'mailAdmin.aiDailyHint': 'Sellest üle loetakse ainult masinloetavaid broneeringuandmeid.',
'mailAdmin.template': 'Aadressi mall',
'mailAdmin.templateHint': 'Edastusaadress, kus {token} asub iga inimese koodi kohal.',
'mailAdmin.imap.host': 'Server', // same-as-en
'mailAdmin.imap.port': 'Port', // same-as-en
'mailAdmin.imap.tls': 'Krüpteerimine',
'mailAdmin.imap.user': 'Kasutaja',
'mailAdmin.imap.pass': 'Parool',
'mailAdmin.imap.mailbox': 'Kaust',
'mailAdmin.imap.mailboxHint': 'Loetud kirjad liiguvad kausta TREK/processed.',
'mailAdmin.webhook.secret': 'Allkirjastamise saladus',
'mailAdmin.webhook.secretHint':
'Vähemalt 32 märki; saatja allkirjastab sellega iga kirja. Tühi väli lülitab webhooki välja.',
'mailAdmin.inboundUrl': 'Webhooki aadress',
'mailAdmin.inboundUrlHint': 'Sinu e-posti teenus postitab iga kirja siia, toorelt ja allkirjastatult.',
'mailAdmin.copyUrl': 'Kopeeri aadress',
'mailAdmin.envHint': 'Keskkonnamuutujad (MAIL_INGEST_*) on nendest väljadest tähtsamad.',
'mailAdmin.loadError': 'Kirjaimpordi seadeid ei saanud laadida.',
'mailAdmin.problem.secretShort': 'Saladus peab olema vähemalt 32 märki.',
'mailAdmin.problem.portInvalid': 'Sisesta port vahemikus 1 kuni 65535.',
'mailAdmin.problem.templateToken': 'Aadressis peab iga inimese koodi jaoks olema {token}.',
'mailAdmin.problem.dailyInvalid': 'Sisesta täisarv vahemikus 0 kuni 1000.',
'mailAdmin.status.title': 'Olek',
'mailAdmin.status.lastRead': 'Viimati loetud {time}',
'mailAdmin.status.never': 'mitte kunagi',
'mailAdmin.status.mails': 'Kirjad, 24 t',
'mailAdmin.status.added': 'Ise lisatud',
'mailAdmin.status.waiting': 'Ootel',
'mailAdmin.status.failed': 'Ebaõnnestunud',
'mailAdmin.test.run': 'Testi ühendust',
'mailAdmin.test.ok': 'Ühendatud',
'mailAdmin.test.failed': 'Ühendus puudub',
};
export default mailAdmin;
+99
View File
@@ -0,0 +1,99 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Ootel broneeringud',
'mailInbox.pendingCount': '{count} broneeringut ootab sind',
'mailInbox.pendingCount.one': '{count} broneering ootab sind',
'mailInbox.empty': 'Midagi ei oota',
'mailInbox.emptyHint':
'Broneeringud, mida TREK ise lisada ei saanud, ilmuvad siia koos võimalustega, mida igaühega teha.',
'mailInbox.untitled': 'Broneeringukiri',
'mailInbox.showMail': 'Näita kirja',
'mailInbox.backToList': 'Tagasi nimekirja',
'mailInbox.loadMore': 'Näita rohkem',
'mailInbox.pickTrip': 'Milline reis?',
'mailInbox.added': 'Broneering lisatud',
'mailInbox.filed': 'Salvestatud reisi failidesse',
'mailInbox.discarded': 'Kustutatud',
'mailInbox.actionError': 'See ei õnnestunud. Proovi hetke pärast uuesti.',
'mailInbox.previewError': 'Kirja ei saanud kuvada.',
'mailInbox.action.add': 'Lisa',
'mailInbox.action.choose': 'Lisa reisile',
'mailInbox.action.file': 'Salvesta dokumendina',
'mailInbox.action.manual': 'Täida ise',
'mailInbox.action.discard': 'Kustuta',
'mailInbox.reason.forwardingConfirmation':
'Sinu e-posti teenusepakkuja küsib edastamise kinnitust. Sisesta see kood seal.',
'mailInbox.reason.newsletter': 'Uudiskiri, mitte broneering',
'mailInbox.reason.autoReply': 'Automaatvastus',
'mailInbox.reason.emptyMail': 'Kirjas pole loetavat teksti ega manust',
'mailInbox.reason.mailUnreadable': 'Kirja ei saanud lugeda',
'mailInbox.reason.notABooking': 'Sellest kirjast ei leitud broneeringut',
'mailInbox.reason.tooLarge': 'Kiri on lugemiseks liiga suur',
'mailInbox.reason.addressInvalid': 'See saabus aadressile, mis enam ei tööta',
'mailInbox.reason.addressDailyLimit': 'Sinu aadress on tänase kirjade piiri täis saanud',
'mailInbox.reason.processingFailed': 'Lugemine ebaõnnestus mitu korda',
'mailInbox.reason.aiQuotaExhausted': 'Selle perioodi tehisintellekti kvoot on otsas',
'mailInbox.reason.aiDisabled': 'Loetud ilma tehisintellektita, broneeringut ei leitud',
'mailInbox.reason.aiUserCap': 'Oled jõudnud tehisintellektiga loetavate kirjade päevapiirini',
'mailInbox.reason.aiProviderError': 'Tehisintellekt ei suutnud seda praegu lugeda',
'mailInbox.reason.noTripInRange': 'Ükski reis ei hõlma neid kuupäevi',
'mailInbox.reason.tripAmbiguous': 'Silmas võib olla peetud mitut reisi',
'mailInbox.reason.tripWithoutDates': 'Reisil pole kuupäevi, millega võrrelda',
'mailInbox.reason.noEditPermission': 'Sul pole õigust sellele reisile broneeringuid lisada',
'mailInbox.reason.demoMode': 'Demo ei lisa broneeringuid',
'mailInbox.reason.outsideTripDates': 'Üks kuupäev jääb reisist välja',
'mailInbox.reason.sourceMismatch': 'Kirja andmed on omavahel vastuolus',
'mailInbox.reason.implausible': 'Üks väärtus ei saa õige olla',
'mailInbox.reason.notInText': 'Loetud väärtust kirjas pole',
'mailInbox.reason.imageOnly': 'Loetud ainult pildilt, seega vaata see üle',
'mailInbox.reason.missingCriticalField': 'Tüüp, kuupäev või marsruut puudub',
'mailInbox.reason.missingConfirmation': 'Broneeringu numbrit ei leitud',
'mailInbox.reason.tooManyBookings': 'Ühes kirjas on rohkem broneeringuid, kui ise lisatakse',
'mailInbox.reason.changeOrCancellation': 'Varasema broneeringu muudatus või tühistamine',
'mailInbox.reason.cancelled': 'Broneering on tühistatud',
'mailInbox.reason.possibleDuplicate': 'Reisil võib see broneering juba olemas olla',
'mailInbox.reason.changeAvailable': 'Reisil on see broneering ja kiri muudab seda',
'mailInbox.reason.conflict': 'Reisil on see broneering teiste andmetega',
'mailInbox.reason.askFirst': 'Palusid iga broneering kinnitada',
'mailInbox.settings.title': 'Broneeringukirjad',
'mailInbox.settings.on': 'Sees',
'mailInbox.settings.off': 'Väljas',
'mailInbox.settings.yourAddress': 'Sinu aadress',
'mailInbox.settings.copy': 'Kopeeri aadress',
'mailInbox.settings.copyError': 'Kopeerimine ebaõnnestus',
'mailInbox.settings.renew': 'Uus aadress',
'mailInbox.settings.renewConfirm': 'Puuduta uuesti: vana aadress lakkab töötamast',
'mailInbox.settings.renewed': 'Sinu uus aadress on valmis',
'mailInbox.settings.saveError': 'Salvestamine ebaõnnestus',
'mailInbox.settings.addressHint':
'Edasta broneeringute kinnitused sellele aadressile ja TREK lisab need õigele reisile. Hoia seda enda teada nagu parooli.',
'mailInbox.settings.howTo': 'Edasta automaatselt',
'mailInbox.settings.howToGmail':
'Gmail: Seaded, Edastamine ja POP/IMAP, Lisa edastusaadress. Gmail saadab koodi, mis ilmub jaotisse Ootel broneeringud. Seejärel lisa broneeringute saatjatele filter, mis edastab TREKi.',
'mailInbox.settings.howToOutlook':
'Outlook: Seaded, Meil, Reeglid, lisa reegel, mis edastab broneeringukirjad. Mõned töökontod blokeerivad automaatse edastamise; edasta seal käsitsi.',
'mailInbox.settings.howToManual': 'Käsitsi: edasta mis tahes broneeringukiri koos manustega.',
'mailInbox.settings.enabled': 'Impordi broneeringukirjad',
'mailInbox.settings.enabledHint': 'Väljalülitatuna sinu aadressile saadetud kirju ei loeta.',
'mailInbox.settings.ai': 'Loe tehisintellektiga',
'mailInbox.settings.aiHint':
'Loeb mis tahes broneeringukirja ja leiab selle reisi. Kiri ning sinu reiside kuupäevad ja sihtkohad lähevad tehisintellekti pakkujale.',
'mailInbox.settings.aiUnavailable':
'Siin pole saadaval: loetakse ainult kirju, mis sisaldavad masinloetavaid broneeringuandmeid.',
'mailInbox.settings.autoAdd': 'Lisa ise',
'mailInbox.settings.autoAddHint':
'Broneering, milles TREK on kindel, läheb otse oma reisile ja seda saab 7 päeva jooksul tagasi võtta. Väljalülitatuna ootab iga broneering sinu kinnitust.',
'mailInbox.settings.upload': 'Laadi üles kirjafail',
'mailInbox.settings.uploaded': 'Kiri vastu võetud, seda loetakse',
'mailInbox.settings.uploadDuplicate': 'See kiri on juba olemas',
'mailInbox.settings.uploadError': 'Faili ei saanud kirjana lugeda',
'mailInbox.settings.privacy':
'Edastatud kirju hoitakse krüpteeritult kuni 30 päeva ja seejärel kustutatakse. Palun ära edasta isikut tõendavaid ega terviseandmeid sisaldavaid dokumente.',
'mailInbox.trip.title': 'Või edasta e-kirjaga',
'mailInbox.trip.hint': 'Selle reisi aadressile saadetud broneeringukiri jõuab siia ilma aimamiseta.',
'mailInbox.trip.create': 'Loo sellele reisile aadress',
'mailInbox.fromMail': 'Kirjast',
'mailInbox.fromMailHint': 'Lisatud edastatud broneeringukirjast',
};
export default mailInbox;
+2 -1
View File
@@ -59,7 +59,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} lisati reisile {trip}',
'notif.mail_booking_added.shared': '{actor} lisas edastatud kirjast {booking} reisile {trip}',
'notif.mail_booking_pending.title': 'Ülevaatamist ootavad edastatud broneeringud',
'notif.mail_booking_pending.text': 'Sind ootavad edastatud broneeringud: {count}',
'notif.mail_booking_pending.text': '{count} edastatud broneeringut ootab sind',
'notif.mail_booking_pending.text.one': '{count} edastatud broneering ootab sind',
'notif.mail_ingest_paused.title': 'Kirjade import on peatatud',
'notif.mail_ingest_paused.text':
'Edastatud broneeringukirju praegu vastu ei võeta. Sinu aadressid jäävad alles ja töötavad uuesti, kui import tagasi on.',
+5 -1
View File
@@ -37,7 +37,11 @@ const reservations: TranslationStrings = {
'reservations.layover.connection': 'Ühendus',
'reservations.layover.layover': 'Vahepeatus',
'reservations.needsReview': 'Vaata üle',
'reservations.needsReviewHint': 'Lennujaama ei õnnestunud automaatselt tuvastada — kinnita asukoht.',
'reservations.needsReviewHint':
'Osa andmeid täideti automaatselt ja neid ei saanud kindlalt lugeda. Palun kontrolli need üle.',
'reservations.reviewConfirm': 'Tundub õige',
'reservations.reviewConfirmHint': 'Klõpsa, et märkida kontrollituks.',
'reservations.reviewConfirmed': 'Märgitud kontrollituks',
'reservations.searchLocation': 'Otsi jaama, sadamat, aadressi…',
'reservations.meta.trainNumber': 'Rongi nr',
'reservations.meta.platform': 'Platvorm',
+3
View File
@@ -131,6 +131,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Lendude hilinemised',
'settings.notifyFlightLanded': 'Maandumised',
'settings.notifyTripReminder': 'Reisimeeldetuletused',
'settings.notifyMailBookingAdded': 'Kirjadest lisatud broneeringud',
'settings.notifyMailBookingPending': 'Ülevaatamist ootavad edastatud broneeringud',
'settings.notifyMailIngestPaused': 'Kirjaimport peatatud',
'settings.notifyTodoDue': 'Ülesande tähtaeg läheneb',
'settings.notifyVacayInvite': 'Vacay ühendamiskutsed',
'settings.notifyVacayShare': 'Vacay kalendri jagamised',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Import des e-mails de réservation',
'mailAdmin.hint': 'Les confirmations de réservation transférées deviennent des réservations dans le bon voyage.',
'mailAdmin.enabled': 'Importer les e-mails de réservation',
'mailAdmin.enabledHint':
'Donne à chaque utilisateur de cette installation une adresse de transfert. Désactivé, aucune adresse ne reçoit d’e-mails.',
'mailAdmin.transport.label': 'Mode de réception des e-mails',
'mailAdmin.transport.off': 'Import de fichiers uniquement',
'mailAdmin.transport.imap': 'Boîte aux lettres IMAP',
'mailAdmin.transport.webhook': 'Webhook', // same-as-en
'mailAdmin.aiDaily': 'E-mails traités par IA par personne et par jour',
'mailAdmin.aiDailyHint': 'Au-delà, seules les données de réservation lisibles par machine sont lues.',
'mailAdmin.template': 'Modèle d’adresse',
'mailAdmin.templateHint': 'L’adresse de transfert, avec {token} à l’endroit où va le code de chaque personne.',
'mailAdmin.imap.host': 'Serveur',
'mailAdmin.imap.port': 'Port', // same-as-en
'mailAdmin.imap.tls': 'Chiffrement',
'mailAdmin.imap.user': 'Utilisateur',
'mailAdmin.imap.pass': 'Mot de passe',
'mailAdmin.imap.mailbox': 'Dossier',
'mailAdmin.imap.mailboxHint': 'Les e-mails lus sont déplacés vers TREK/processed.',
'mailAdmin.webhook.secret': 'Secret de signature',
'mailAdmin.webhook.secretHint':
'Au moins 32 caractères ; l’expéditeur signe chaque e-mail avec. Vide, le webhook est désactivé.',
'mailAdmin.inboundUrl': 'Adresse du webhook',
'mailAdmin.inboundUrlHint': 'Votre service de messagerie y envoie chaque e-mail, brut et signé.',
'mailAdmin.copyUrl': 'Copier l’adresse',
'mailAdmin.envHint': 'Les variables d’environnement (MAIL_INGEST_*) l’emportent sur ces champs.',
'mailAdmin.loadError': 'Les paramètres d’import des e-mails n’ont pas pu être chargés.',
'mailAdmin.problem.secretShort': 'Le secret doit comporter au moins 32 caractères.',
'mailAdmin.problem.portInvalid': 'Saisissez un port entre 1 et 65535.',
'mailAdmin.problem.templateToken': 'L’adresse doit contenir {token} pour le code de chaque personne.',
'mailAdmin.problem.dailyInvalid': 'Saisissez un nombre entier de 0 à 1000.',
'mailAdmin.status.title': 'État',
'mailAdmin.status.lastRead': 'Dernière lecture {time}',
'mailAdmin.status.never': 'jamais',
'mailAdmin.status.mails': 'E-mails, 24 h',
'mailAdmin.status.added': 'Ajoutés automatiquement',
'mailAdmin.status.waiting': 'En attente',
'mailAdmin.status.failed': 'Échoués',
'mailAdmin.test.run': 'Tester la connexion',
'mailAdmin.test.ok': 'Connecté',
'mailAdmin.test.failed': 'Pas de connexion',
};
export default mailAdmin;
+100
View File
@@ -0,0 +1,100 @@
import type { TranslationStrings } from '../types';
const mailInbox: TranslationStrings = {
'mailInbox.title': 'Réservations en attente',
'mailInbox.pendingCount': '{count} réservations vous attendent',
'mailInbox.pendingCount.one': '{count} réservation vous attend',
'mailInbox.empty': 'Rien en attente',
'mailInbox.emptyHint':
'Les réservations que TREK n’a pas pu ajouter seul apparaissent ici, avec ce que vous pouvez faire pour chacune.',
'mailInbox.untitled': 'E-mail de réservation',
'mailInbox.showMail': 'Afficher l’e-mail',
'mailInbox.backToList': 'Retour à la liste',
'mailInbox.loadMore': 'Afficher plus',
'mailInbox.pickTrip': 'Quel voyage ?',
'mailInbox.added': 'Réservation ajoutée',
'mailInbox.filed': 'Enregistré dans les fichiers du voyage',
'mailInbox.discarded': 'Ignoré',
'mailInbox.actionError': 'Ça n’a pas fonctionné. Réessayez dans un instant.',
'mailInbox.previewError': 'Impossible d’afficher l’e-mail.',
'mailInbox.action.add': 'Ajouter',
'mailInbox.action.choose': 'Ajouter à un voyage',
'mailInbox.action.file': 'Enregistrer comme document',
'mailInbox.action.manual': 'Remplir soi-même',
'mailInbox.action.discard': 'Ignorer',
'mailInbox.reason.forwardingConfirmation':
'Votre fournisseur de messagerie demande de confirmer la redirection. Saisissez ce code chez lui.',
'mailInbox.reason.newsletter': 'Une newsletter, pas une réservation',
'mailInbox.reason.autoReply': 'Une réponse automatique',
'mailInbox.reason.emptyMail': 'L’e-mail ne contient ni texte ni pièce jointe à lire',
'mailInbox.reason.mailUnreadable': 'L’e-mail n’a pas pu être lu',
'mailInbox.reason.notABooking': 'Aucune réservation trouvée dans cet e-mail',
'mailInbox.reason.tooLarge': 'L’e-mail est trop volumineux pour être lu',
'mailInbox.reason.addressInvalid': 'Reçu sur une adresse qui ne fonctionne plus',
'mailInbox.reason.addressDailyLimit': 'Votre adresse a déjà reçu tous ses e-mails du jour',
'mailInbox.reason.processingFailed': 'La lecture a échoué plusieurs fois',
'mailInbox.reason.aiQuotaExhausted': 'Le quota d’IA de cette période est épuisé',
'mailInbox.reason.aiDisabled': 'Lu sans IA, qui n’a trouvé aucune réservation',
'mailInbox.reason.aiUserCap': 'Votre limite quotidienne d’e-mails lus par IA est atteinte',
'mailInbox.reason.aiProviderError': 'L’IA n’a pas pu le lire pour l’instant',
'mailInbox.reason.noTripInRange': 'Aucun voyage ne couvre ces dates',
'mailInbox.reason.tripAmbiguous': 'Plus d’un voyage peut correspondre',
'mailInbox.reason.tripWithoutDates': 'Le voyage n’a pas de dates à comparer',
'mailInbox.reason.noEditPermission': 'Vous ne pouvez pas ajouter de réservations à ce voyage',
'mailInbox.reason.demoMode': 'La démo n’ajoute aucune réservation',
'mailInbox.reason.outsideTripDates': 'Une date tombe en dehors du voyage',
'mailInbox.reason.sourceMismatch': 'Les informations de l’e-mail se contredisent',
'mailInbox.reason.implausible': 'Une valeur ne peut pas être exacte',
'mailInbox.reason.notInText': 'Une valeur lue ne figure pas dans l’e-mail',
'mailInbox.reason.imageOnly': 'Lu à partir d’une image seulement, il faut donc le vérifier',
'mailInbox.reason.missingCriticalField': 'Le type, la date ou le trajet manque',
'mailInbox.reason.missingConfirmation': 'Aucune référence de réservation trouvée',
'mailInbox.reason.tooManyBookings': 'Plus de réservations dans un seul e-mail que la limite d’ajout automatique',
'mailInbox.reason.changeOrCancellation': 'Une modification ou une annulation d’une réservation précédente',
'mailInbox.reason.cancelled': 'La réservation est annulée',
'mailInbox.reason.possibleDuplicate': 'Le voyage contient peut-être déjà cette réservation',
'mailInbox.reason.changeAvailable': 'Le voyage contient cette réservation, et l’e-mail la modifie',
'mailInbox.reason.conflict': 'Le voyage contient cette réservation avec d’autres informations',
'mailInbox.reason.askFirst': 'Vous avez demandé à confirmer chaque réservation',
'mailInbox.settings.title': 'E-mails de réservation',
'mailInbox.settings.on': 'Activé',
'mailInbox.settings.off': 'Désactivé',
'mailInbox.settings.yourAddress': 'Votre adresse',
'mailInbox.settings.copy': 'Copier l’adresse',
'mailInbox.settings.copyError': 'Copie impossible',
'mailInbox.settings.renew': 'Nouvelle adresse',
'mailInbox.settings.renewConfirm': 'Touchez à nouveau : l’ancienne adresse cessera de fonctionner',
'mailInbox.settings.renewed': 'Votre nouvelle adresse est prête',
'mailInbox.settings.saveError': 'Enregistrement impossible',
'mailInbox.settings.addressHint':
'Transférez les confirmations de réservation à cette adresse et TREK les ajoute au bon voyage. Gardez-la pour vous, comme un mot de passe.',
'mailInbox.settings.howTo': 'Transférer automatiquement',
'mailInbox.settings.howToGmail':
'Gmail : Paramètres, Transfert et POP/IMAP, Ajouter une adresse de transfert. Gmail envoie un code, qui apparaît sous Réservations en attente. Ajoutez ensuite un filtre pour les expéditeurs de réservations qui transfère à TREK.',
'mailInbox.settings.howToOutlook':
'Outlook : Paramètres, Courrier, Règles, ajoutez une règle qui transfère les e-mails de réservation. Certains comptes professionnels bloquent le transfert automatique ; transférez alors à la main.',
'mailInbox.settings.howToManual':
'À la main : transférez n’importe quel e-mail de réservation, pièces jointes comprises.',
'mailInbox.settings.enabled': 'Importer les e-mails de réservation',
'mailInbox.settings.enabledHint': 'Désactivé, les e-mails envoyés à votre adresse ne sont pas lus.',
'mailInbox.settings.ai': 'Lire avec l’IA',
'mailInbox.settings.aiHint':
'Lit n’importe quel e-mail de réservation et trouve son voyage. L’e-mail ainsi que les dates et destinations de vos voyages sont transmis au fournisseur d’IA.',
'mailInbox.settings.aiUnavailable':
'Indisponible ici : seuls les e-mails contenant des données de réservation lisibles par machine sont lus.',
'mailInbox.settings.autoAdd': 'Ajouter automatiquement',
'mailInbox.settings.autoAddHint':
'Une réservation dont TREK est sûr va directement dans son voyage, et vous pouvez l’annuler pendant 7 jours. Désactivé, chacune attend votre accord.',
'mailInbox.settings.upload': 'Importer un fichier d’e-mail',
'mailInbox.settings.uploaded': 'E-mail reçu, lecture en cours',
'mailInbox.settings.uploadDuplicate': 'Cet e-mail a déjà été importé',
'mailInbox.settings.uploadError': 'Le fichier n’a pas pu être lu comme un e-mail',
'mailInbox.settings.privacy':
'Les e-mails transférés sont conservés chiffrés 30 jours au maximum, puis supprimés. Merci de ne pas transférer de pièces d’identité ni de documents de santé.',
'mailInbox.trip.title': 'Ou transférez par e-mail',
'mailInbox.trip.hint': 'Un e-mail de réservation envoyé à l’adresse de ce voyage arrive ici sans aucune devinette.',
'mailInbox.trip.create': 'Créer une adresse pour ce voyage',
'mailInbox.fromMail': 'Depuis un e-mail',
'mailInbox.fromMailHint': 'Ajouté depuis un e-mail de réservation transféré',
};
export default mailInbox;
+2 -1
View File
@@ -59,7 +59,8 @@ const notif: TranslationStrings = {
'notif.mail_booking_added.text': '{booking} a été ajouté à {trip}',
'notif.mail_booking_added.shared': '{actor} a ajouté {booking} à {trip} depuis un e-mail transféré',
'notif.mail_booking_pending.title': 'Réservations transférées à vérifier',
'notif.mail_booking_pending.text': 'Réservations transférées en attente : {count}',
'notif.mail_booking_pending.text': '{count} réservations transférées vous attendent',
'notif.mail_booking_pending.text.one': '{count} réservation transférée vous attend',
'notif.mail_ingest_paused.title': 'Import des e-mails en pause',
'notif.mail_ingest_paused.text':
'Les e-mails de réservation transférés ne sont pas acceptés pour le moment. Vos adresses restent et fonctionneront de nouveau au retour de l’import.',
+4 -1
View File
@@ -37,7 +37,10 @@ const reservations: TranslationStrings = {
'reservations.layover.layover': 'Escale',
'reservations.needsReview': 'Vérifier',
'reservations.needsReviewHint':
"L'aéroport n'a pas pu être identifié automatiquement — veuillez confirmer l'emplacement.",
'Certaines informations ont été remplies automatiquement et n’ont pas pu être lues avec certitude. Merci de les vérifier.',
'reservations.reviewConfirm': 'C’est correct',
'reservations.reviewConfirmHint': 'Cliquez pour marquer comme vérifié.',
'reservations.reviewConfirmed': 'Marqué comme vérifié',
'reservations.searchLocation': 'Rechercher une gare, un port, une adresse…',
'reservations.meta.trainNumber': 'N° de train',
'reservations.meta.platform': 'Quai',
+3
View File
@@ -118,6 +118,9 @@ const settings: TranslationStrings = {
'settings.notifyFlightDelayed': 'Retards de vol',
'settings.notifyFlightLanded': 'Atterrissages',
'settings.notifyTripReminder': 'Rappels de voyage',
'settings.notifyMailBookingAdded': 'Réservations ajoutées depuis des e-mails',
'settings.notifyMailBookingPending': 'Réservations transférées à vérifier',
'settings.notifyMailIngestPaused': 'Import des e-mails en pause',
'settings.notifyTodoDue': 'Tâche à échéance',
'settings.notifyVacayInvite': 'Invitations de fusion Vacay',
'settings.notifyVacayShare': 'Partages de calendrier Vacay',
+4
View File
@@ -36,6 +36,8 @@ import help from './help';
import inspector from './inspector';
import journey from './journey';
import login from './login';
import mailAdmin from './mailAdmin';
import mailInbox from './mailInbox';
import map from './map';
import members from './members';
import memories from './memories';
@@ -154,6 +156,8 @@ const locale = {
...docsync,
...ferratas,
...flightLive,
...mailInbox,
...mailAdmin,
...assistant,
...assistantCard,
...assistantTour,
+47
View File
@@ -0,0 +1,47 @@
import type { TranslationStrings } from '../types';
const mailAdmin: TranslationStrings = {
'mailAdmin.title': 'Εισαγωγή email κρατήσεων',
'mailAdmin.hint': 'Οι προωθημένες επιβεβαιώσεις κράτησης γίνονται κρατήσεις στο σωστό ταξίδι.',
'mailAdmin.enabled': 'Εισαγωγή email κρατήσεων',
'mailAdmin.enabledHint':
'Δίνει σε όλους σε αυτή την εγκατάσταση μια διεύθυνση προώθησης. Όταν είναι ανενεργό, καμία διεύθυνση δεν δέχεται email.',
'mailAdmin.transport.label': 'Πώς φτάνουν τα email',
'mailAdmin.transport.off': 'Μόνο μεταφόρτωση',
'mailAdmin.transport.imap': 'Γραμματοκιβώτιο IMAP',
'mailAdmin.transport.webhook': 'Webhook',
'mailAdmin.aiDaily': 'Email AI ανά άτομο και ημέρα',
'mailAdmin.aiDailyHint': 'Πάνω από αυτό διαβάζονται μόνο δεδομένα κράτησης αναγνώσιμα από μηχανή.',
'mailAdmin.template': 'Πρότυπο διεύθυνσης',
'mailAdmin.templateHint': 'Η διεύθυνση προώθησης, με {token} εκεί που μπαίνει ο κωδικός κάθε ατόμου.',
'mailAdmin.imap.host': 'Διακομιστής',
'mailAdmin.imap.port': 'Θύρα',
'mailAdmin.imap.tls': 'Κρυπτογράφηση',
'mailAdmin.imap.user': 'Χρήστης',
'mailAdmin.imap.pass': 'Κωδικός πρόσβασης',
'mailAdmin.imap.mailbox': 'Φάκελος',
'mailAdmin.imap.mailboxHint': 'Τα email που διαβάστηκαν μεταφέρονται στο TREK/processed.',
'mailAdmin.webhook.secret': 'Μυστικό υπογραφής',
'mailAdmin.webhook.secretHint':
'Τουλάχιστον 32 χαρακτήρες· ο αποστολέας υπογράφει κάθε email με αυτό. Αν μείνει κενό, το webhook απενεργοποιείται.',
'mailAdmin.inboundUrl': 'Διεύθυνση webhook',
'mailAdmin.inboundUrlHint': 'Η υπηρεσία email σου στέλνει εδώ κάθε email, ανεπεξέργαστο και υπογεγραμμένο.',
'mailAdmin.copyUrl': 'Αντιγραφή διεύθυνσης',
'mailAdmin.envHint': 'Οι μεταβλητές περιβάλλοντος (MAIL_INGEST_*) υπερισχύουν αυτών των πεδίων.',
'mailAdmin.loadError': 'Οι ρυθμίσεις εισαγωγής email δεν μπόρεσαν να φορτωθούν.',
'mailAdmin.problem.secretShort': 'Το μυστικό χρειάζεται τουλάχιστον 32 χαρακτήρες.',
'mailAdmin.problem.portInvalid': 'Εισήγαγε θύρα από 1 έως 65535.',
'mailAdmin.problem.templateToken': 'Η διεύθυνση χρειάζεται {token} για τον κωδικό κάθε ατόμου.',
'mailAdmin.problem.dailyInvalid': 'Εισήγαγε έναν ακέραιο αριθμό από 0 έως 1000.',
'mailAdmin.status.title': 'Κατάσταση',
'mailAdmin.status.lastRead': 'Τελευταία ανάγνωση {time}',
'mailAdmin.status.never': 'ποτέ',
'mailAdmin.status.mails': 'Email, 24 ώ',
'mailAdmin.status.added': 'Προστέθηκαν αυτόματα',
'mailAdmin.status.waiting': 'Σε αναμονή',
'mailAdmin.status.failed': 'Απέτυχαν',
'mailAdmin.test.run': 'Δοκιμή σύνδεσης',
'mailAdmin.test.ok': 'Συνδέθηκε',
'mailAdmin.test.failed': 'Χωρίς σύνδεση',
};
export default mailAdmin;

Some files were not shown because too many files have changed in this diff Show More