mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-11-14 00:01:03 +00:00
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:
@@ -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,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);
|
||||
|
||||
@@ -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>;
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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':
|
||||
'لا تُقبل رسائل الحجز المُعاد توجيهها في الوقت الحالي. تبقى عناوينك وستعمل مجددًا عند عودة الاستيراد.',
|
||||
|
||||
@@ -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': 'المنصة',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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ı',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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ó.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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í.',
|
||||
|
||||
@@ -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ě',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user