mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -11,6 +11,9 @@ import MVacaySettingsSheet from './MVacaySettingsSheet'
|
||||
import MVacayShareSheet from './MVacayShareSheet'
|
||||
import { FALLBACK_PERSON_COLOR } from './vacayDayModel'
|
||||
|
||||
/** Half days (#552) make the balance fractional; one decimal is exact and never drifts. */
|
||||
const fmtDays = (n: number) => (Number.isInteger(n) ? String(n) : n.toFixed(1))
|
||||
|
||||
const WEEKDAY_KEYS_MONDAY = ['vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat', 'vacay.sun'] as const
|
||||
const WEEKDAY_KEYS_SUNDAY = ['vacay.sun', 'vacay.mon', 'vacay.tue', 'vacay.wed', 'vacay.thu', 'vacay.fri', 'vacay.sat'] as const
|
||||
|
||||
@@ -78,23 +81,16 @@ export default function MVacay() {
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-[0.875rem] font-extrabold">{v.selectedUser.username}</div>
|
||||
<MProgress value={pct} color={v.selectedColor} className="mt-[6px]" />
|
||||
<div className="mt-[5px] flex items-center justify-between">
|
||||
<span className="font-geist text-[0.5625rem] font-bold text-m-muted">
|
||||
{t('mobileVacay.usedCount', { count: stat.used })}
|
||||
</span>
|
||||
{stat.carried_over > 0 && (
|
||||
<span className="font-geist text-[0.5625rem] font-bold text-[color:var(--m-st-pending)]">
|
||||
+{stat.carried_over} {v.isShiftedYear ? t('vacay.carriedOverPrevPeriod') : t('vacay.carriedOver', { year: v.selectedYear - 1 })}
|
||||
</span>
|
||||
)}
|
||||
{/* Comp / flex days (#1074) sit outside used/left — they cost nothing. */}
|
||||
{(stat.comp_used ?? 0) > 0 && (
|
||||
<span className="font-geist text-[0.5625rem] font-bold text-m-muted">
|
||||
{t('vacay.compUsedCount', { count: stat.comp_used })}
|
||||
</span>
|
||||
)}
|
||||
<span className="font-geist text-[0.5625rem] font-extrabold" style={{ color: leftColor }}>
|
||||
{t('mobileVacay.leftCount', { count: stat.remaining })}
|
||||
{/* Used, carried over and comp days all fit on a desktop sidebar; on a
|
||||
phone they turn into four scraps of 9px text. Only the number you
|
||||
act on stays — the rest is a tap away in the entitlement view. */}
|
||||
<div className="mt-[6px] flex items-center">
|
||||
<span
|
||||
className="inline-flex items-center gap-[3px] rounded-full px-[8px] py-[2px] font-geist text-[0.625rem] font-bold"
|
||||
style={{ background: `${leftColor}1f`, border: `1px solid ${leftColor}47`, color: leftColor }}
|
||||
>
|
||||
<span className="font-extrabold tabular-nums">{fmtDays(stat.remaining)}</span>
|
||||
{t('mobileVacay.left')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,7 +106,7 @@ export default function MVacay() {
|
||||
<div className="min-w-[40px] text-center">
|
||||
<div className="text-[1.1875rem] font-extrabold leading-none tabular-nums">{stat.vacation_days}</div>
|
||||
<div className="font-geist text-[0.5rem] font-bold uppercase tracking-[.06em] text-m-faint">
|
||||
{t('mobileVacay.daysPerYear')}
|
||||
{t('vacay.entitlementDays')}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
@@ -266,7 +262,7 @@ export default function MVacay() {
|
||||
}`}
|
||||
>
|
||||
<Building2 size={13} strokeWidth={2.2} />
|
||||
{t('vacay.modeCompany')}
|
||||
{t('mobileVacay.modeCompany')}
|
||||
</button>
|
||||
)}
|
||||
{/* Divider — comp/flex and half-day modify the selected person's logging, they aren't modes. */}
|
||||
|
||||
+192
-412
@@ -1,28 +1,41 @@
|
||||
import {
|
||||
BookMarked,
|
||||
CheckSquare,
|
||||
ChevronDown,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Fingerprint,
|
||||
FolderOpen,
|
||||
Globe,
|
||||
KeyRound,
|
||||
Lock,
|
||||
Mail,
|
||||
Map,
|
||||
Plane,
|
||||
Route,
|
||||
Shield,
|
||||
User,
|
||||
Users,
|
||||
Wallet,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import React from 'react';
|
||||
import ToggleSwitch from '../components/Settings/ToggleSwitch';
|
||||
import { SUPPORTED_LANGUAGES, useTranslation } from '../i18n';
|
||||
import { useLogin } from './login/useLogin';
|
||||
import LoginWorld from './login/LoginWorld';
|
||||
|
||||
/** Fixed so the sky does not reshuffle on every render. */
|
||||
const STARFIELD = [
|
||||
{ top: 6, left: 12, size: 1, opacity: 0.3, delay: 0 },
|
||||
{ top: 11, left: 68, size: 2, opacity: 0.22, delay: 1.4 },
|
||||
{ top: 17, left: 34, size: 1, opacity: 0.35, delay: 2.6 },
|
||||
{ top: 22, left: 84, size: 1, opacity: 0.25, delay: 0.7 },
|
||||
{ top: 28, left: 19, size: 2, opacity: 0.2, delay: 3.1 },
|
||||
{ top: 33, left: 57, size: 1, opacity: 0.32, delay: 1.9 },
|
||||
{ top: 39, left: 91, size: 1, opacity: 0.24, delay: 2.2 },
|
||||
{ top: 46, left: 8, size: 1, opacity: 0.28, delay: 0.4 },
|
||||
{ top: 52, left: 73, size: 2, opacity: 0.18, delay: 3.6 },
|
||||
{ top: 58, left: 42, size: 1, opacity: 0.3, delay: 1.1 },
|
||||
{ top: 64, left: 88, size: 1, opacity: 0.26, delay: 2.9 },
|
||||
{ top: 71, left: 26, size: 1, opacity: 0.22, delay: 0.9 },
|
||||
{ top: 77, left: 63, size: 1, opacity: 0.3, delay: 3.3 },
|
||||
{ top: 83, left: 15, size: 2, opacity: 0.2, delay: 1.7 },
|
||||
{ top: 88, left: 79, size: 1, opacity: 0.27, delay: 2.4 },
|
||||
{ top: 93, left: 48, size: 1, opacity: 0.23, delay: 0.2 },
|
||||
];
|
||||
|
||||
export default function LoginPage(): React.ReactElement {
|
||||
const { t, language } = useTranslation();
|
||||
@@ -96,190 +109,110 @@ export default function LoginPage(): React.ReactElement {
|
||||
|
||||
if (showTakeoff) {
|
||||
return (
|
||||
<div className="takeoff-overlay" style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden' }}>
|
||||
{/* Sky gradient */}
|
||||
<div className="takeoff-sky" style={{ position: 'absolute', inset: 0 }} />
|
||||
|
||||
{/* Stars */}
|
||||
{Array.from({ length: 60 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="takeoff-star"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: Math.random() > 0.7 ? 3 : 1.5,
|
||||
height: Math.random() > 0.7 ? 3 : 1.5,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
top: `${Math.random() * 100}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDelay: `${0.3 + Math.random() * 0.5}s, ${Math.random() * 1}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Clouds rushing past */}
|
||||
{[0, 1, 2, 3, 4].map((i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="takeoff-cloud"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 120 + i * 40,
|
||||
height: 40 + i * 10,
|
||||
borderRadius: '50%',
|
||||
background: 'rgba(255,255,255,0.15)',
|
||||
filter: 'blur(8px)',
|
||||
right: -200,
|
||||
top: `${25 + i * 12}%`,
|
||||
animationDelay: `${0.3 + i * 0.25}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Speed lines */}
|
||||
{Array.from({ length: 12 }, (_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="takeoff-speedline"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 80 + Math.random() * 120,
|
||||
height: 1.5,
|
||||
background: 'linear-gradient(90deg, transparent, rgba(255,255,255,0.3), transparent)',
|
||||
top: `${10 + Math.random() * 80}%`,
|
||||
right: -200,
|
||||
animationDelay: `${0.5 + i * 0.12}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Plane */}
|
||||
<div
|
||||
className="takeoff-plane"
|
||||
style={{ position: 'absolute', left: '50%', bottom: '10%', transform: 'translate(-50%, 0)' }}
|
||||
>
|
||||
<svg viewBox="0 0 480 120" style={{ width: 200, filter: 'drop-shadow(0 0 20px rgba(255,255,255,0.3))' }}>
|
||||
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
<div
|
||||
className="takeoff-overlay"
|
||||
style={{ position: 'fixed', inset: 0, zIndex: 99999, overflow: 'hidden', background: '#070c1a' }}
|
||||
>
|
||||
{/* Signing in picks up exactly where the login panel left off: the same dot
|
||||
map, except now every route departs at once. The network finishing is
|
||||
the moment — no separate imagery, no plane flying off alone. */}
|
||||
<div className="takeoff-world">
|
||||
<LoginWorld variant="takeoff" />
|
||||
</div>
|
||||
|
||||
{/* Contrail */}
|
||||
<div
|
||||
className="takeoff-trail"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
left: '50%',
|
||||
bottom: '8%',
|
||||
width: 3,
|
||||
height: 0,
|
||||
background: 'linear-gradient(to top, transparent, rgba(255,255,255,0.5))',
|
||||
transformOrigin: 'bottom center',
|
||||
}}
|
||||
/>
|
||||
{/* The colour rises with the departures rather than sitting there from the start. */}
|
||||
<div className="takeoff-aurora takeoff-aurora-a" />
|
||||
<div className="takeoff-aurora takeoff-aurora-b" />
|
||||
|
||||
{/* Logo fade in + burst */}
|
||||
<div
|
||||
className="takeoff-logo"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<img src="/logo-light.svg" alt="TREK" style={{ height: 72 }} />
|
||||
<div className="takeoff-mark">
|
||||
<img src="/logo-light.svg" alt="TREK" style={{ height: 'clamp(58px, 5.2vw, 84px)' }} />
|
||||
<p
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'calc(20px * var(--fs-scale-title, 1))',
|
||||
color: 'rgba(255,255,255,0.6)',
|
||||
margin: '12px 0 0',
|
||||
fontSize: 'calc(clamp(15px, 1.35vw, 21px) * var(--fs-scale-title, 1))',
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
fontFamily: "'MuseoModerno', sans-serif",
|
||||
textTransform: 'lowercase',
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: '0 2px 10px rgba(4,8,20,0.6)',
|
||||
}}
|
||||
>
|
||||
{t('login.tagline')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Hands over to the app instead of cutting to it. */}
|
||||
<div className="takeoff-veil" />
|
||||
|
||||
<style>{`
|
||||
.takeoff-sky {
|
||||
background: linear-gradient(to top, #1a1a2e 0%, #16213e 30%, #0f3460 60%, #0a0a23 100%);
|
||||
animation: skyShift 2.6s ease-in-out forwards;
|
||||
}
|
||||
@keyframes skyShift {
|
||||
0% { background: linear-gradient(to top, #0a0a23 0%, #0f172a 40%, #111827 100%); }
|
||||
100% { background: linear-gradient(to top, #000011 0%, #000016 50%, #000011 100%); }
|
||||
}
|
||||
|
||||
.takeoff-star {
|
||||
.takeoff-world {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
opacity: 0;
|
||||
animation: starAppear 0.5s ease-out forwards, starTwinkle 2s ease-in-out infinite alternate;
|
||||
animation: takeoffWorld 2600ms cubic-bezier(0.22,1,0.36,1) forwards;
|
||||
}
|
||||
@keyframes starAppear {
|
||||
0% { opacity: 0; transform: scale(0); }
|
||||
100% { opacity: 0.7; transform: scale(1); }
|
||||
}
|
||||
@keyframes starTwinkle {
|
||||
0% { opacity: 0.3; }
|
||||
100% { opacity: 0.9; }
|
||||
@keyframes takeoffWorld {
|
||||
0% { opacity: 0; transform: scale(1.14); }
|
||||
18% { opacity: 1; }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
.takeoff-cloud {
|
||||
animation: cloudRush 0.6s ease-in forwards;
|
||||
}
|
||||
@keyframes cloudRush {
|
||||
0% { right: -200px; opacity: 0; }
|
||||
20% { opacity: 0.4; }
|
||||
100% { right: 120%; opacity: 0; }
|
||||
}
|
||||
|
||||
.takeoff-speedline {
|
||||
animation: speedRush 0.4s ease-in forwards;
|
||||
}
|
||||
@keyframes speedRush {
|
||||
0% { right: -200px; opacity: 0; }
|
||||
30% { opacity: 0.6; }
|
||||
100% { right: 120%; opacity: 0; }
|
||||
}
|
||||
|
||||
.takeoff-plane {
|
||||
animation: planeUp 1s ease-in forwards;
|
||||
}
|
||||
@keyframes planeUp {
|
||||
0% { transform: translate(-50%, 0) rotate(0deg) scale(1); bottom: 8%; left: 50%; opacity: 1; }
|
||||
100% { transform: translate(-50%, 0) rotate(-22deg) scale(0.15); bottom: 120%; left: 58%; opacity: 0; }
|
||||
}
|
||||
|
||||
.takeoff-trail {
|
||||
animation: trailGrow 0.9s ease-out 0.15s forwards;
|
||||
}
|
||||
@keyframes trailGrow {
|
||||
0% { height: 0; opacity: 0; transform: translateX(-50%) rotate(-5deg); }
|
||||
30% { height: 150px; opacity: 0.6; }
|
||||
60% { height: 350px; opacity: 0.4; }
|
||||
100% { height: 600px; opacity: 0; transform: translateX(-50%) rotate(-8deg); }
|
||||
}
|
||||
|
||||
.takeoff-logo {
|
||||
.takeoff-aurora {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(110px);
|
||||
opacity: 0;
|
||||
animation: logoReveal 0.5s ease-out 0.9s forwards;
|
||||
}
|
||||
@keyframes logoReveal {
|
||||
0% { opacity: 0; transform: translate(-50%, -40%) scale(0.9); }
|
||||
100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
.takeoff-aurora-a {
|
||||
width: 46vw; height: 46vw; top: -8%; left: 4%;
|
||||
background: radial-gradient(circle, rgba(79,70,229,0.55) 0%, rgba(79,70,229,0) 70%);
|
||||
animation: takeoffGlow 2600ms ease-out forwards;
|
||||
}
|
||||
.takeoff-aurora-b {
|
||||
width: 38vw; height: 38vw; bottom: -10%; right: 6%;
|
||||
background: radial-gradient(circle, rgba(6,182,212,0.4) 0%, rgba(6,182,212,0) 72%);
|
||||
animation: takeoffGlow 2600ms ease-out 220ms forwards;
|
||||
}
|
||||
@keyframes takeoffGlow {
|
||||
0% { opacity: 0; }
|
||||
55% { opacity: 1; }
|
||||
100% { opacity: 0.75; }
|
||||
}
|
||||
|
||||
.takeoff-mark {
|
||||
position: absolute;
|
||||
top: 50%; left: 50%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transform: translate(-50%, -50%);
|
||||
opacity: 0;
|
||||
filter: drop-shadow(0 2px 12px rgba(4,8,20,0.6)) drop-shadow(0 0 40px rgba(4,8,20,0.5));
|
||||
animation: takeoffMark 2600ms cubic-bezier(0.22,1,0.36,1) forwards;
|
||||
}
|
||||
@keyframes takeoffMark {
|
||||
0%, 22% { opacity: 0; transform: translate(-50%, -50%) scale(0.94); }
|
||||
48% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
88% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
|
||||
100% { opacity: 1; transform: translate(-50%, -50%) scale(1.03); }
|
||||
}
|
||||
|
||||
.takeoff-veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: #f9fafb;
|
||||
opacity: 0;
|
||||
animation: takeoffVeil 2600ms ease-in forwards;
|
||||
}
|
||||
@keyframes takeoffVeil {
|
||||
0%, 82% { opacity: 0; }
|
||||
100% { opacity: 1; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.takeoff-world, .takeoff-aurora, .takeoff-mark { animation: none; opacity: 1; transform: none; }
|
||||
.takeoff-mark { transform: translate(-50%, -50%); }
|
||||
.takeoff-veil { animation: takeoffVeil 2600ms linear forwards; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
@@ -389,7 +322,7 @@ export default function LoginPage(): React.ReactElement {
|
||||
style={{
|
||||
display: 'none',
|
||||
width: '55%',
|
||||
background: 'linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #0f172a 100%)',
|
||||
background: '#070c1a',
|
||||
flexDirection: 'column',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
@@ -401,240 +334,87 @@ export default function LoginPage(): React.ReactElement {
|
||||
>
|
||||
<style>{`@media(min-width:1024px){.lg-panel{display:flex!important}}`}</style>
|
||||
|
||||
{/* Stars */}
|
||||
{/* Aurora — three drifting colour fields. They are the whole background:
|
||||
deep indigo, a cold cyan and a warm violet, blurred past recognition so
|
||||
what is left is the gradient, not the shapes. */}
|
||||
<div className="login-aurora login-aurora-a" />
|
||||
<div className="login-aurora login-aurora-b" />
|
||||
<div className="login-aurora login-aurora-c" />
|
||||
|
||||
{/* Depth: a handful of pinpricks, far enough back to read as distance. */}
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden' }}>
|
||||
{Array.from({ length: 40 }, (_, i) => (
|
||||
{STARFIELD.map((s, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="login-star"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: Math.random() > 0.7 ? 2 : 1,
|
||||
height: Math.random() > 0.7 ? 2 : 1,
|
||||
width: s.size,
|
||||
height: s.size,
|
||||
borderRadius: '50%',
|
||||
background: 'white',
|
||||
opacity: 0.15 + Math.random() * 0.25,
|
||||
top: `${Math.random() * 70}%`,
|
||||
left: `${Math.random() * 100}%`,
|
||||
animationDelay: `${Math.random() * 4}s`,
|
||||
opacity: s.opacity,
|
||||
top: `${s.top}%`,
|
||||
left: `${s.left}%`,
|
||||
animationDelay: `${s.delay}s`,
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Animated glow orbs */}
|
||||
<div
|
||||
className="login-orb1"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 500,
|
||||
height: 500,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(99,102,241,0.1) 0%, transparent 70%)',
|
||||
filter: 'blur(80px)',
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="login-orb2"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
width: 350,
|
||||
height: 350,
|
||||
borderRadius: '50%',
|
||||
background: 'radial-gradient(circle, rgba(14,165,233,0.08) 0%, transparent 70%)',
|
||||
filter: 'blur(60px)',
|
||||
}}
|
||||
/>
|
||||
{/* Coastlines as a dot map, with routes lighting up between cities across
|
||||
it. The geometry is TREK's own Atlas bundle, baked in at build time
|
||||
because this screen is unauthenticated. */}
|
||||
<LoginWorld />
|
||||
|
||||
{/* Animated planes — realistic silhouettes at different sizes/speeds */}
|
||||
<div style={{ position: 'absolute', inset: 0, overflow: 'hidden', pointerEvents: 'none' }}>
|
||||
{/* Plane 1 — large, slow, foreground */}
|
||||
<svg
|
||||
className="login-plane1"
|
||||
viewBox="0 0 480 120"
|
||||
style={{ position: 'absolute', width: 48, opacity: 0.12 }}
|
||||
>
|
||||
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 2 — small, faster, higher */}
|
||||
<svg
|
||||
className="login-plane2"
|
||||
viewBox="0 0 480 120"
|
||||
style={{ position: 'absolute', width: 24, opacity: 0.08 }}
|
||||
>
|
||||
<g fill="white" transform="translate(240,60) rotate(-12)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 3 — medium, mid-speed */}
|
||||
<svg
|
||||
className="login-plane3"
|
||||
viewBox="0 0 480 120"
|
||||
style={{ position: 'absolute', width: 32, opacity: 0.06 }}
|
||||
>
|
||||
<g fill="white" transform="translate(240,60) rotate(-5)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 4 — tiny, fast, high */}
|
||||
<svg
|
||||
className="login-plane4"
|
||||
viewBox="0 0 480 120"
|
||||
style={{ position: 'absolute', width: 16, opacity: 0.07 }}
|
||||
>
|
||||
<g fill="white" transform="translate(240,60) rotate(-10)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 5 — medium, right to left, lower */}
|
||||
<svg
|
||||
className="login-plane5"
|
||||
viewBox="0 0 480 120"
|
||||
style={{ position: 'absolute', width: 28, opacity: 0.05 }}
|
||||
>
|
||||
<g fill="white" transform="translate(240,60) rotate(8) scale(-1,1)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<path d="M-100,-5 L-120,-30 L-108,-30 L-90,-8 Z" />
|
||||
<path d="M-100,5 L-120,30 L-108,30 L-90,8 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
{/* Plane 6 — tiny distant */}
|
||||
<svg
|
||||
className="login-plane6"
|
||||
viewBox="0 0 480 120"
|
||||
style={{ position: 'absolute', width: 12, opacity: 0.04 }}
|
||||
>
|
||||
<g fill="white" transform="translate(240,60) rotate(-15)">
|
||||
<ellipse cx="0" cy="0" rx="120" ry="12" />
|
||||
<path d="M-20,-10 L-60,-55 L-40,-55 L0,-15 Z" />
|
||||
<path d="M-20,10 L-60,55 L-40,55 L0,15 Z" />
|
||||
<ellipse cx="60" cy="0" rx="18" ry="8" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: 560, textAlign: 'center' }}>
|
||||
{/* Logo */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 48 }}>
|
||||
<img src="/logo-light.svg" alt="TREK" style={{ height: 64 }} />
|
||||
{/* No max-width: the tagline stays on one line, so the block is allowed to
|
||||
use the whole panel rather than wrapping inside it. */}
|
||||
<div style={{ position: 'relative', zIndex: 1, maxWidth: '100%', textAlign: 'center' }}>
|
||||
{/* Logo. The soft dark halo is what separates it from the dot map behind —
|
||||
wide and low-opacity rather than a hard drop shadow, so it reads as the
|
||||
map dimming around the mark instead of an outline. */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', marginBottom: 12 }}>
|
||||
<img
|
||||
src="/logo-light.svg"
|
||||
alt="TREK"
|
||||
style={{
|
||||
height: 'clamp(58px, 5.2vw, 84px)',
|
||||
filter: 'drop-shadow(0 2px 10px rgba(4,8,20,0.55)) drop-shadow(0 0 34px rgba(4,8,20,0.45))',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h2
|
||||
style={{
|
||||
margin: '0 0 12px',
|
||||
fontSize: 'calc(36px * var(--fs-scale-title, 1))',
|
||||
margin: 0,
|
||||
// Scales with the panel so it stays on one line in every language.
|
||||
fontSize: 'calc(clamp(32px, 2.95vw, 46px) * var(--fs-scale-title, 1))',
|
||||
fontWeight: 700,
|
||||
color: 'white',
|
||||
lineHeight: 1.15,
|
||||
letterSpacing: '-0.02em',
|
||||
fontFamily: "'MuseoModerno', sans-serif",
|
||||
textTransform: 'lowercase',
|
||||
whiteSpace: 'nowrap',
|
||||
textShadow: '0 2px 10px rgba(4,8,20,0.55), 0 0 34px rgba(4,8,20,0.5)',
|
||||
}}
|
||||
>
|
||||
{t('login.tagline')}
|
||||
</h2>
|
||||
<p
|
||||
style={{
|
||||
margin: '0 0 44px',
|
||||
fontSize: 'calc(15px * var(--fs-scale-subtitle, 1))',
|
||||
color: 'rgba(255,255,255,0.5)',
|
||||
margin: '10px 0 0',
|
||||
// Must never wrap: the panel is a fixed share of the viewport, so the
|
||||
// size follows it and a long translation shrinks instead of breaking.
|
||||
fontSize: 'calc(clamp(11px, 1.05vw, 16px) * var(--fs-scale-subtitle, 1))',
|
||||
whiteSpace: 'nowrap',
|
||||
color: 'rgba(255,255,255,0.62)',
|
||||
lineHeight: 1.7,
|
||||
textShadow: '0 1px 8px rgba(4,8,20,0.6), 0 0 24px rgba(4,8,20,0.45)',
|
||||
}}
|
||||
>
|
||||
{t('login.description')}
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 8 }}>
|
||||
{[
|
||||
{ Icon: Map, label: t('login.features.maps'), desc: t('login.features.mapsDesc') },
|
||||
{ Icon: Zap, label: t('login.features.realtime'), desc: t('login.features.realtimeDesc') },
|
||||
{ Icon: Wallet, label: t('login.features.budget'), desc: t('login.features.budgetDesc') },
|
||||
{ Icon: Users, label: t('login.features.collab'), desc: t('login.features.collabDesc') },
|
||||
{ Icon: CheckSquare, label: t('login.features.packing'), desc: t('login.features.packingDesc') },
|
||||
{ Icon: BookMarked, label: t('login.features.bookings'), desc: t('login.features.bookingsDesc') },
|
||||
{ Icon: FolderOpen, label: t('login.features.files'), desc: t('login.features.filesDesc') },
|
||||
{ Icon: Route, label: t('login.features.routes'), desc: t('login.features.routesDesc') },
|
||||
].map(({ Icon, label, desc }) => (
|
||||
<div
|
||||
key={label}
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.04)',
|
||||
borderRadius: 14,
|
||||
padding: '14px 12px',
|
||||
border: '1px solid rgba(255,255,255,0.06)',
|
||||
textAlign: 'left',
|
||||
transition:
|
||||
'background 200ms cubic-bezier(0.23,1,0.32,1), border-color 200ms cubic-bezier(0.23,1,0.32,1)',
|
||||
}}
|
||||
onMouseEnter={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.08)';
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.12)';
|
||||
}}
|
||||
onMouseLeave={(e: React.MouseEvent<HTMLDivElement>) => {
|
||||
e.currentTarget.style.background = 'rgba(255,255,255,0.04)';
|
||||
e.currentTarget.style.borderColor = 'rgba(255,255,255,0.06)';
|
||||
}}
|
||||
>
|
||||
<Icon size={17} style={{ color: 'rgba(255,255,255,0.7)', marginBottom: 7 }} />
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'calc(12.5px * var(--fs-scale-body, 1))',
|
||||
color: 'white',
|
||||
fontWeight: 600,
|
||||
marginBottom: 2,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: 'rgba(255,255,255,0.35)',
|
||||
lineHeight: 1.4,
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
marginTop: 36,
|
||||
fontSize: 'calc(11.5px * var(--fs-scale-caption, 1))',
|
||||
color: 'rgba(255,255,255,0.25)',
|
||||
letterSpacing: '0.03em',
|
||||
}}
|
||||
>
|
||||
{t('login.selfHosted')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1441,20 +1221,45 @@ export default function LoginPage(): React.ReactElement {
|
||||
|
||||
<style>{`
|
||||
@keyframes spin { to { transform: rotate(360deg) } }
|
||||
@keyframes orbFloat1 {
|
||||
0%, 100% { top: 15%; left: 30%; }
|
||||
25% { top: 25%; left: 55%; }
|
||||
50% { top: 45%; left: 40%; }
|
||||
75% { top: 20%; left: 20%; }
|
||||
|
||||
/* ── Branding panel ──────────────────────────────────────────────────
|
||||
Aurora: three colour fields, blurred far past their own shape, drifting
|
||||
on different periods so the gradient never repeats a frame exactly. */
|
||||
.login-aurora {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
will-change: transform;
|
||||
}
|
||||
@keyframes orbFloat2 {
|
||||
0%, 100% { bottom: 20%; right: 15%; }
|
||||
25% { bottom: 35%; right: 35%; }
|
||||
50% { bottom: 15%; right: 45%; }
|
||||
75% { bottom: 40%; right: 20%; }
|
||||
.login-aurora-a {
|
||||
width: 620px; height: 620px; top: -12%; left: -14%;
|
||||
background: radial-gradient(circle, rgba(79,70,229,0.55) 0%, rgba(79,70,229,0) 68%);
|
||||
animation: auroraA 34s ease-in-out infinite;
|
||||
}
|
||||
.login-aurora-b {
|
||||
width: 520px; height: 520px; bottom: -16%; right: -10%;
|
||||
background: radial-gradient(circle, rgba(6,182,212,0.4) 0%, rgba(6,182,212,0) 70%);
|
||||
animation: auroraB 44s ease-in-out infinite;
|
||||
}
|
||||
.login-aurora-c {
|
||||
width: 460px; height: 460px; top: 42%; left: 34%;
|
||||
background: radial-gradient(circle, rgba(168,85,247,0.32) 0%, rgba(168,85,247,0) 72%);
|
||||
animation: auroraC 52s ease-in-out infinite;
|
||||
}
|
||||
@keyframes auroraA {
|
||||
0%, 100% { transform: translate3d(0,0,0) scale(1); }
|
||||
33% { transform: translate3d(14%, 18%, 0) scale(1.14); }
|
||||
66% { transform: translate3d(26%, 4%, 0) scale(0.94); }
|
||||
}
|
||||
@keyframes auroraB {
|
||||
0%, 100% { transform: translate3d(0,0,0) scale(1.05); }
|
||||
40% { transform: translate3d(-22%, -14%, 0) scale(0.9); }
|
||||
70% { transform: translate3d(-8%, -28%, 0) scale(1.2); }
|
||||
}
|
||||
@keyframes auroraC {
|
||||
0%, 100% { transform: translate3d(0,0,0) scale(0.95); }
|
||||
50% { transform: translate3d(-18%, 12%, 0) scale(1.25); }
|
||||
}
|
||||
.login-orb1 { animation: orbFloat1 20s ease-in-out infinite; }
|
||||
.login-orb2 { animation: orbFloat2 25s ease-in-out infinite; }
|
||||
|
||||
@keyframes twinkle {
|
||||
0%, 100% { opacity: 0.15; }
|
||||
@@ -1462,36 +1267,11 @@ export default function LoginPage(): React.ReactElement {
|
||||
}
|
||||
.login-star { animation: twinkle 3s ease-in-out infinite; }
|
||||
|
||||
@keyframes plane1Move {
|
||||
0% { left: -8%; top: 30%; transform: rotate(-8deg); }
|
||||
100% { left: 108%; top: 10%; transform: rotate(-12deg); }
|
||||
|
||||
/* Ambient only — the world map holds still. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.login-aurora, .login-star { animation: none; }
|
||||
}
|
||||
@keyframes plane2Move {
|
||||
0% { right: -5%; top: 18%; transform: rotate(5deg); }
|
||||
100% { right: 110%; top: 8%; transform: rotate(3deg); }
|
||||
}
|
||||
@keyframes plane3Move {
|
||||
0% { left: -6%; top: 55%; transform: rotate(-10deg); }
|
||||
100% { left: 110%; top: 35%; transform: rotate(-6deg); }
|
||||
}
|
||||
@keyframes plane4Move {
|
||||
0% { left: -4%; top: 8%; transform: rotate(-3deg); }
|
||||
100% { left: 110%; top: 5%; transform: rotate(-5deg); }
|
||||
}
|
||||
@keyframes plane5Move {
|
||||
0% { right: -6%; top: 65%; transform: rotate(3deg); }
|
||||
100% { right: 110%; top: 50%; transform: rotate(-2deg); }
|
||||
}
|
||||
@keyframes plane6Move {
|
||||
0% { left: -3%; top: 75%; transform: rotate(-7deg); }
|
||||
100% { left: 110%; top: 58%; transform: rotate(-5deg); }
|
||||
}
|
||||
.login-plane1 { animation: plane1Move 24s ease-in-out infinite; }
|
||||
.login-plane2 { animation: plane2Move 18s ease-in-out infinite; animation-delay: 6s; }
|
||||
.login-plane3 { animation: plane3Move 30s ease-in-out infinite; animation-delay: 12s; }
|
||||
.login-plane4 { animation: plane4Move 14s ease-in-out infinite; animation-delay: 3s; }
|
||||
.login-plane5 { animation: plane5Move 22s ease-in-out infinite; animation-delay: 9s; }
|
||||
.login-plane6 { animation: plane6Move 32s ease-in-out infinite; animation-delay: 16s; }
|
||||
|
||||
`}</style>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
import { WORLD_GRID_H, WORLD_GRID_W, WORLD_LAT_BOT, WORLD_LAT_TOP, worldDots } from './worldDots';
|
||||
|
||||
/**
|
||||
* The login backdrop: TREK's own coastlines as a dot map, with routes lighting up
|
||||
* between cities across it.
|
||||
*
|
||||
* The dots come from the Atlas admin-0 bundle, baked into worldDots.ts — the login
|
||||
* screen is unauthenticated, so it cannot fetch Atlas at runtime.
|
||||
*
|
||||
* Perf: the map itself is drawn once into an offscreen canvas and blitted each
|
||||
* frame; only the handful of live arcs and their city glows are drawn per frame.
|
||||
* No shadowBlur in the frame path.
|
||||
*/
|
||||
|
||||
/** Landmark cities, the anchors every route runs between. */
|
||||
const CITIES: [number, number][] = [
|
||||
[52.52, 13.4], [48.85, 2.35], [51.5, -0.12], [40.4, -3.7], [41.9, 12.5], [59.33, 18.07],
|
||||
[41.0, 28.98], [55.75, 37.62], [52.37, 4.9], [64.15, -21.9], [38.72, -9.14], [50.08, 14.44],
|
||||
[30.04, 31.24], [6.52, 3.38], [-1.29, 36.82], [-33.92, 18.42], [33.57, -7.59],
|
||||
[25.2, 55.27], [19.08, 72.88], [28.61, 77.21], [13.76, 100.5], [1.35, 103.82],
|
||||
[-6.21, 106.85], [35.68, 139.69], [37.57, 126.98], [39.9, 116.4], [31.23, 121.47],
|
||||
[22.32, 114.17], [-33.87, 151.21], [-36.85, 174.76], [-27.47, 153.03],
|
||||
[49.28, -123.12], [37.77, -122.42], [40.71, -74.01], [41.88, -87.63], [43.65, -79.38],
|
||||
[19.43, -99.13], [25.76, -80.19], [4.71, -74.07], [-12.05, -77.04], [-23.55, -46.63],
|
||||
[-34.6, -58.38], [-33.45, -70.67],
|
||||
];
|
||||
|
||||
interface Arc {
|
||||
from: number;
|
||||
to: number;
|
||||
/** 0 → 1 progress of the light travelling the arc. */
|
||||
t: number;
|
||||
speed: number;
|
||||
/** Stays lit briefly after arrival, then fades. */
|
||||
hold: number;
|
||||
lift: number;
|
||||
/** Milliseconds still to wait before this one starts (takeoff cascade). */
|
||||
delay: number;
|
||||
}
|
||||
|
||||
const MAX_ARCS = 7;
|
||||
const DOT_COLOUR = 'rgba(148, 163, 214, 0.55)';
|
||||
|
||||
interface LoginWorldProps {
|
||||
/**
|
||||
* `ambient` is the login backdrop: a few routes at a time, forever.
|
||||
* `takeoff` is the sign-in moment: the whole network fires at once and stays lit.
|
||||
*/
|
||||
variant?: 'ambient' | 'takeoff';
|
||||
}
|
||||
|
||||
export default function LoginWorld({ variant = 'ambient' }: LoginWorldProps): React.ReactElement {
|
||||
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
|
||||
const takeoff = variant === 'takeoff';
|
||||
|
||||
React.useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
const reduced = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
||||
const dots = worldDots();
|
||||
|
||||
let raf = 0;
|
||||
let width = 0;
|
||||
let height = 0;
|
||||
let dpr = 1;
|
||||
let mapLayer: HTMLCanvasElement | null = null;
|
||||
/** Map placement in CSS pixels, shared by the layer and the city projection. */
|
||||
let originX = 0;
|
||||
let originY = 0;
|
||||
let cell = 1;
|
||||
|
||||
const project = (lat: number, lon: number) => ({
|
||||
x: originX + ((lon + 180) / 360) * (WORLD_GRID_W - 1) * cell,
|
||||
y: originY + ((WORLD_LAT_TOP - lat) / (WORLD_LAT_TOP - WORLD_LAT_BOT)) * (WORLD_GRID_H - 1) * cell,
|
||||
});
|
||||
|
||||
/** Redraw the static dot map. Called on mount and on resize only. */
|
||||
function buildMap() {
|
||||
// Cover the panel, biased up a little so the busier northern half sits behind
|
||||
// the copy rather than under it.
|
||||
cell = Math.max(width / (WORLD_GRID_W - 1), height / (WORLD_GRID_H - 1)) * 1.08;
|
||||
originX = (width - (WORLD_GRID_W - 1) * cell) / 2;
|
||||
originY = (height - (WORLD_GRID_H - 1) * cell) / 2;
|
||||
|
||||
const layer = document.createElement('canvas');
|
||||
layer.width = Math.max(1, Math.floor(width * dpr));
|
||||
layer.height = Math.max(1, Math.floor(height * dpr));
|
||||
const lc = layer.getContext('2d');
|
||||
if (!lc) return;
|
||||
lc.scale(dpr, dpr);
|
||||
lc.fillStyle = DOT_COLOUR;
|
||||
const r = Math.max(0.6, cell * 0.19);
|
||||
for (const d of dots) {
|
||||
const x = originX + d.x * cell;
|
||||
const y = originY + d.y * cell;
|
||||
if (x < -4 || x > width + 4 || y < -4 || y > height + 4) continue;
|
||||
lc.beginPath();
|
||||
lc.arc(x, y, r, 0, Math.PI * 2);
|
||||
lc.fill();
|
||||
}
|
||||
mapLayer = layer;
|
||||
}
|
||||
|
||||
function resize() {
|
||||
const rect = canvas!.getBoundingClientRect();
|
||||
dpr = Math.min(window.devicePixelRatio || 1, 2);
|
||||
width = rect.width;
|
||||
height = rect.height;
|
||||
canvas!.width = Math.max(1, Math.floor(width * dpr));
|
||||
canvas!.height = Math.max(1, Math.floor(height * dpr));
|
||||
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
buildMap();
|
||||
}
|
||||
|
||||
const arcs: Arc[] = [];
|
||||
|
||||
function spawnArc(opts?: { delay?: number; speed?: number }) {
|
||||
if (!takeoff && arcs.length >= MAX_ARCS) return;
|
||||
const from = Math.floor(Math.random() * CITIES.length);
|
||||
let to = Math.floor(Math.random() * CITIES.length);
|
||||
if (to === from) to = (to + 1 + Math.floor(Math.random() * 3)) % CITIES.length;
|
||||
arcs.push({
|
||||
from,
|
||||
to,
|
||||
t: 0,
|
||||
// Per millisecond, so 1/speed is the flight time: roughly 3–5s. Long enough
|
||||
// to read as a journey, short enough that the map keeps moving.
|
||||
speed: opts?.speed ?? 0.0002 + Math.random() * 0.00013,
|
||||
hold: 0,
|
||||
// Longer hops bow higher, the way a route reads on a map.
|
||||
lift: 0.16 + Math.random() * 0.16,
|
||||
delay: opts?.delay ?? 0,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign-in: the whole network lights up at once. Routes leave in a staggered
|
||||
* cascade so it reads as departures rather than one flash, and nothing fades —
|
||||
* the finished web is the last thing on screen before the app takes over.
|
||||
*/
|
||||
function burst() {
|
||||
for (let i = 0; i < 11; i++) {
|
||||
spawnArc({ delay: i * 105 + Math.random() * 70, speed: 0.0013 + Math.random() * 0.0009 });
|
||||
}
|
||||
}
|
||||
|
||||
/** Quadratic arc between two projected cities, bowed away from the straight line. */
|
||||
function arcPoints(a: Arc) {
|
||||
const p1 = project(CITIES[a.from][0], CITIES[a.from][1]);
|
||||
const p2 = project(CITIES[a.to][0], CITIES[a.to][1]);
|
||||
const mx = (p1.x + p2.x) / 2;
|
||||
const my = (p1.y + p2.y) / 2;
|
||||
const dx = p2.x - p1.x;
|
||||
const dy = p2.y - p1.y;
|
||||
const len = Math.hypot(dx, dy) || 1;
|
||||
// Perpendicular offset, always bowing "up" so arcs never dive off-screen.
|
||||
const cx = mx - (dy / len) * len * a.lift * Math.sign(dx || 1);
|
||||
const cy = my + (dx / len) * len * a.lift * Math.sign(dx || 1) - len * 0.08;
|
||||
return { p1, p2, cx, cy };
|
||||
}
|
||||
|
||||
function pointOn(p1: { x: number; y: number }, cx: number, cy: number, p2: { x: number; y: number }, t: number) {
|
||||
const u = 1 - t;
|
||||
return {
|
||||
x: u * u * p1.x + 2 * u * t * cx + t * t * p2.x,
|
||||
y: u * u * p1.y + 2 * u * t * cy + t * t * p2.y,
|
||||
};
|
||||
}
|
||||
|
||||
function cityGlow(x: number, y: number, alpha: number, radius: number) {
|
||||
const g = ctx!.createRadialGradient(x, y, 0, x, y, radius);
|
||||
g.addColorStop(0, `rgba(199, 210, 254, ${alpha})`);
|
||||
g.addColorStop(0.35, `rgba(129, 140, 248, ${alpha * 0.5})`);
|
||||
g.addColorStop(1, 'rgba(99, 102, 241, 0)');
|
||||
ctx!.fillStyle = g;
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(x, y, radius, 0, Math.PI * 2);
|
||||
ctx!.fill();
|
||||
}
|
||||
|
||||
let last = 0;
|
||||
let spawnTimer = 0;
|
||||
|
||||
function frame(now: number) {
|
||||
const dt = last ? Math.min(now - last, 48) : 16;
|
||||
last = now;
|
||||
|
||||
ctx!.clearRect(0, 0, width, height);
|
||||
if (mapLayer) ctx!.drawImage(mapLayer, 0, 0, width, height);
|
||||
|
||||
if (!takeoff) {
|
||||
spawnTimer -= dt;
|
||||
if (spawnTimer <= 0) {
|
||||
spawnArc();
|
||||
spawnTimer = 1600 + Math.random() * 2200;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = arcs.length - 1; i >= 0; i--) {
|
||||
const a = arcs[i];
|
||||
|
||||
if (a.delay > 0) {
|
||||
a.delay -= dt;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { p1, p2, cx, cy } = arcPoints(a);
|
||||
|
||||
if (a.t < 1) a.t = Math.min(1, a.t + a.speed * dt);
|
||||
else a.hold += dt;
|
||||
|
||||
// Ambient routes retire to make room for the next; the takeoff web stays lit.
|
||||
const fade = takeoff ? 1 : a.hold > 2200 ? Math.max(0, 1 - (a.hold - 2200) / 2400) : 1;
|
||||
if (fade <= 0) {
|
||||
arcs.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// The drawn portion, with the leading edge brightest.
|
||||
ctx!.strokeStyle = `rgba(129, 140, 248, ${0.5 * fade})`;
|
||||
ctx!.lineWidth = 1.4;
|
||||
ctx!.beginPath();
|
||||
const segs = Math.max(2, Math.round(34 * a.t));
|
||||
for (let s = 0; s <= segs; s++) {
|
||||
const pt = pointOn(p1, cx, cy, p2, (a.t * s) / segs);
|
||||
if (s === 0) ctx!.moveTo(pt.x, pt.y);
|
||||
else ctx!.lineTo(pt.x, pt.y);
|
||||
}
|
||||
ctx!.stroke();
|
||||
|
||||
// Origin lights the moment the route leaves it, destination on arrival.
|
||||
cityGlow(p1.x, p1.y, 0.55 * fade, Math.max(9, cell * 2.6));
|
||||
if (a.t >= 1) cityGlow(p2.x, p2.y, 0.6 * fade, Math.max(11, cell * 3));
|
||||
|
||||
// The travelling head.
|
||||
if (a.t < 1) {
|
||||
const head = pointOn(p1, cx, cy, p2, a.t);
|
||||
cityGlow(head.x, head.y, 0.75, Math.max(7, cell * 2));
|
||||
ctx!.fillStyle = 'rgba(224, 242, 254, 0.95)';
|
||||
ctx!.beginPath();
|
||||
ctx!.arc(head.x, head.y, 1.8, 0, Math.PI * 2);
|
||||
ctx!.fill();
|
||||
}
|
||||
}
|
||||
|
||||
raf = window.requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
resize();
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
if (reduced) {
|
||||
// Still frame: the map, no routes firing.
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
if (mapLayer) ctx.drawImage(mapLayer, 0, 0, width, height);
|
||||
} else if (takeoff) {
|
||||
burst();
|
||||
raf = window.requestAnimationFrame(frame);
|
||||
} else {
|
||||
// Open with a few routes already in flight instead of an empty map.
|
||||
for (let i = 0; i < 3; i++) {
|
||||
spawnArc();
|
||||
const a = arcs[arcs.length - 1];
|
||||
if (a) a.t = Math.random() * 0.6;
|
||||
}
|
||||
raf = window.requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(raf);
|
||||
window.removeEventListener('resize', resize);
|
||||
};
|
||||
}, [takeoff]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
aria-hidden
|
||||
className={takeoff ? 'login-world-takeoff' : undefined}
|
||||
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', pointerEvents: 'none', opacity: 0.85 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'فشل تسجيل الدخول. يرجى التحقق من بياناتك.',
|
||||
'login.tagline': 'رحلاتك.\nخطتك.',
|
||||
'login.description': 'خطط لرحلاتك بشكل تعاوني مع خرائط تفاعلية وميزانيات ومزامنة لحظية.',
|
||||
'login.features.maps': 'خرائط تفاعلية',
|
||||
'login.features.mapsDesc': 'Google Places ومسارات وتجميع',
|
||||
'login.features.realtime': 'مزامنة فورية',
|
||||
'login.features.realtimeDesc': 'خططوا معًا عبر WebSocket',
|
||||
'login.features.budget': 'تتبع التكاليف',
|
||||
'login.features.budgetDesc': 'فئات ورسوم وتقسيم لكل شخص',
|
||||
'login.features.collab': 'تعاون',
|
||||
'login.features.collabDesc': 'عدة مستخدمين مع رحلات مشتركة',
|
||||
'login.features.packing': 'قوائم تجهيز',
|
||||
'login.features.packingDesc': 'فئات وتقدم واقتراحات',
|
||||
'login.features.bookings': 'الحجوزات',
|
||||
'login.features.bookingsDesc': 'رحلات وفنادق ومطاعم وغير ذلك',
|
||||
'login.features.files': 'المستندات',
|
||||
'login.features.filesDesc': 'رفع الملفات وإدارتها',
|
||||
'login.features.routes': 'مسارات ذكية',
|
||||
'login.features.routesDesc': 'تحسين تلقائي وتصدير إلى Google Maps',
|
||||
'login.selfHosted': 'استضافة ذاتية · مفتوح المصدر · بياناتك تبقى ملكك',
|
||||
'login.title': 'تسجيل الدخول',
|
||||
'login.subtitle': 'مرحبًا بعودتك',
|
||||
'login.signingIn': 'جارٍ تسجيل الدخول…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'الشهر التالي',
|
||||
'mobileVacay.usedCount': '{count} مستخدَمة',
|
||||
'mobileVacay.leftCount': '{count} متبقية',
|
||||
'mobileVacay.daysPerYear': 'يوم / سنة',
|
||||
'mobileVacay.left': 'متبقية',
|
||||
'mobileVacay.modeCompany': 'الشركة',
|
||||
'mobileVacay.decreaseAllowance': 'تقليل الرصيد',
|
||||
'mobileVacay.increaseAllowance': 'زيادة الرصيد',
|
||||
'mobileVacay.companyLegend': 'أيام الشركة',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Falha no login. Verifique suas credenciais.',
|
||||
'login.tagline': 'Suas viagens.\nSeu plano.',
|
||||
'login.description': 'Planeje viagens em equipe com mapas interativos, orçamento e sincronização em tempo real.',
|
||||
'login.features.maps': 'Mapas interativos',
|
||||
'login.features.mapsDesc': 'Google Places, rotas e agrupamento',
|
||||
'login.features.realtime': 'Sincronização em tempo real',
|
||||
'login.features.realtimeDesc': 'Planejem juntos via WebSocket',
|
||||
'login.features.budget': 'Controle de custos',
|
||||
'login.features.budgetDesc': 'Categorias, gráficos e custo por pessoa',
|
||||
'login.features.collab': 'Colaboração',
|
||||
'login.features.collabDesc': 'Vários usuários com viagens compartilhadas',
|
||||
'login.features.packing': 'Listas de malas',
|
||||
'login.features.packingDesc': 'Categorias, progresso e sugestões',
|
||||
'login.features.bookings': 'Reservas',
|
||||
'login.features.bookingsDesc': 'Voos, hotéis, restaurantes e mais',
|
||||
'login.features.files': 'Documentos',
|
||||
'login.features.filesDesc': 'Envie e gerencie documentos',
|
||||
'login.features.routes': 'Rotas inteligentes',
|
||||
'login.features.routesDesc': 'Otimize e exporte para o Google Maps',
|
||||
'login.selfHosted': 'Auto-hospedado · Código aberto · Seus dados são seus',
|
||||
'login.title': 'Entrar',
|
||||
'login.subtitle': 'Bem-vindo de volta',
|
||||
'login.signingIn': 'Entrando…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Próximo mês',
|
||||
'mobileVacay.usedCount': '{count} usados',
|
||||
'mobileVacay.leftCount': '{count} restantes',
|
||||
'mobileVacay.daysPerYear': 'dias / ano',
|
||||
'mobileVacay.left': 'restantes',
|
||||
'mobileVacay.modeCompany': 'Empresa',
|
||||
'mobileVacay.decreaseAllowance': 'Diminuir cota',
|
||||
'mobileVacay.increaseAllowance': 'Aumentar cota',
|
||||
'mobileVacay.companyLegend': 'Férias coletivas',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Els teus viatges.\nEl teu pla.',
|
||||
'login.description':
|
||||
'Planifica viatges en col·laboració amb mapes interactius, pressupostos i sincronització en temps real.',
|
||||
'login.features.maps': 'Mapes interactius',
|
||||
'login.features.mapsDesc': 'Google Places, rutes i agrupació',
|
||||
'login.features.realtime': 'Sincronització en temps real',
|
||||
'login.features.realtimeDesc': 'Planifiqueu junts mitjançant WebSocket',
|
||||
'login.features.budget': 'Control de pressupost',
|
||||
'login.features.budgetDesc': 'Categories, gràfics i costos per persona',
|
||||
'login.features.collab': 'Col·laboració',
|
||||
'login.features.collabDesc': 'Multiusuari amb viatges compartits',
|
||||
'login.features.packing': "Llistes d'equipatge",
|
||||
'login.features.packingDesc': 'Categories, progrés i suggeriments',
|
||||
'login.features.bookings': 'Reserves',
|
||||
'login.features.bookingsDesc': 'Vols, hotels, restaurants i més',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Puja i gestiona documents',
|
||||
'login.features.routes': 'Rutes intel·ligents',
|
||||
'login.features.routesDesc': 'Optimització automàtica i exportació a Google Maps',
|
||||
'login.selfHosted': 'Autoallotjat · Codi obert · Les teves dades segueixen sent teves',
|
||||
'login.title': 'Inicia la sessió',
|
||||
'login.subtitle': 'Benvingut de nou',
|
||||
'login.signingIn': 'Iniciant la sessió…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Mes següent',
|
||||
'mobileVacay.usedCount': '{count} usats',
|
||||
'mobileVacay.leftCount': '{count} restants',
|
||||
'mobileVacay.daysPerYear': 'dies / any',
|
||||
'mobileVacay.left': 'restants',
|
||||
'mobileVacay.modeCompany': 'Empresa',
|
||||
'mobileVacay.decreaseAllowance': "Redueix l'assignació",
|
||||
'mobileVacay.increaseAllowance': "Augmenta l'assignació",
|
||||
'mobileVacay.companyLegend': "Dies d'empresa",
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Přihlášení se nezdařilo. Zkontrolujte prosím své údaje.',
|
||||
'login.tagline': 'Vaše cesty.\nVáš plán.',
|
||||
'login.description': 'Plánujte cesty společně s interaktivními mapami, rozpočty a synchronizací v reálném čase.',
|
||||
'login.features.maps': 'Interaktivní mapy',
|
||||
'login.features.mapsDesc': 'Google Places, trasy a shlukování bodů',
|
||||
'login.features.realtime': 'Synchronizace v reálném čase',
|
||||
'login.features.realtimeDesc': 'Plánujte společně přes WebSocket',
|
||||
'login.features.budget': 'Sledování nákladů',
|
||||
'login.features.budgetDesc': 'Kategorie, grafy a náklady na osobu',
|
||||
'login.features.collab': 'Spolupráce',
|
||||
'login.features.collabDesc': 'Více uživatelů se sdílenými cestami',
|
||||
'login.features.packing': 'Seznamy věcí',
|
||||
'login.features.packingDesc': 'Kategorie, pokrok v balení a návrhy',
|
||||
'login.features.bookings': 'Rezervace',
|
||||
'login.features.bookingsDesc': 'Lety, hotely, restaurace a další',
|
||||
'login.features.files': 'Dokumenty',
|
||||
'login.features.filesDesc': 'Nahrávejte a spravujte dokumenty',
|
||||
'login.features.routes': 'Chytré trasy',
|
||||
'login.features.routesDesc': 'Automatická optimalizace a export do Google Maps',
|
||||
'login.selfHosted': 'Self-hosted · Open Source · Vaše data zůstávají u vás',
|
||||
'login.title': 'Přihlásit se',
|
||||
'login.subtitle': 'Vítejte zpět',
|
||||
'login.signingIn': 'Přihlašování…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Další měsíc',
|
||||
'mobileVacay.usedCount': '{count} využito',
|
||||
'mobileVacay.leftCount': 'Zbývá {count}',
|
||||
'mobileVacay.daysPerYear': 'dní / rok',
|
||||
'mobileVacay.left': 'zbývá',
|
||||
'mobileVacay.modeCompany': 'Firma',
|
||||
'mobileVacay.decreaseAllowance': 'Snížit nárok',
|
||||
'mobileVacay.increaseAllowance': 'Zvýšit nárok',
|
||||
'mobileVacay.companyLegend': 'Firemní dny',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Anmeldung fehlgeschlagen. Bitte Zugangsdaten prüfen.',
|
||||
'login.tagline': 'Deine Reisen.\nDein Plan.',
|
||||
'login.description': 'Plane Reisen gemeinsam mit interaktiven Karten, Budgets und Echtzeit-Sync.',
|
||||
'login.features.maps': 'Interaktive Karten',
|
||||
'login.features.mapsDesc': 'Google Places, Routen & Clustering',
|
||||
'login.features.realtime': 'Echtzeit-Sync',
|
||||
'login.features.realtimeDesc': 'Gemeinsam planen via WebSocket',
|
||||
'login.features.budget': 'Kosten-Tracking',
|
||||
'login.features.budgetDesc': 'Kategorien, Diagramme & Pro-Kopf',
|
||||
'login.features.collab': 'Zusammenarbeit',
|
||||
'login.features.collabDesc': 'Multi-User mit geteilten Reisen',
|
||||
'login.features.packing': 'Packlisten',
|
||||
'login.features.packingDesc': 'Kategorien & Fortschritt',
|
||||
'login.features.bookings': 'Buchungen',
|
||||
'login.features.bookingsDesc': 'Flüge, Hotels, Restaurants & mehr',
|
||||
'login.features.files': 'Dokumente',
|
||||
'login.features.filesDesc': 'Dateien hochladen & verwalten',
|
||||
'login.features.routes': 'Routenoptimierung',
|
||||
'login.features.routesDesc': 'Auto-Optimierung & Google Maps Export',
|
||||
'login.selfHosted': 'Self-hosted · Open Source · Deine Daten bleiben bei dir',
|
||||
'login.title': 'Anmelden',
|
||||
'login.subtitle': 'Willkommen zurück',
|
||||
'login.signingIn': 'Anmelden…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Nächster Monat',
|
||||
'mobileVacay.usedCount': '{count} genommen',
|
||||
'mobileVacay.leftCount': '{count} übrig',
|
||||
'mobileVacay.daysPerYear': 'Tage / Jahr',
|
||||
'mobileVacay.left': 'übrig',
|
||||
'mobileVacay.modeCompany': 'Betrieb',
|
||||
'mobileVacay.decreaseAllowance': 'Anspruch verringern',
|
||||
'mobileVacay.increaseAllowance': 'Anspruch erhöhen',
|
||||
'mobileVacay.companyLegend': 'Betriebsferien',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Login failed. Please check your credentials.',
|
||||
'login.tagline': 'Your Trips.\nYour Plan.',
|
||||
'login.description': 'Plan trips collaboratively with interactive maps, budgets, and real-time sync.',
|
||||
'login.features.maps': 'Interactive Maps',
|
||||
'login.features.mapsDesc': 'Google Places, routes & clustering',
|
||||
'login.features.realtime': 'Real-Time Sync',
|
||||
'login.features.realtimeDesc': 'Plan together via WebSocket',
|
||||
'login.features.budget': 'Cost Tracking',
|
||||
'login.features.budgetDesc': 'Categories, charts & per-person costs',
|
||||
'login.features.collab': 'Collaboration',
|
||||
'login.features.collabDesc': 'Multi-user with shared trips',
|
||||
'login.features.packing': 'Packing Lists',
|
||||
'login.features.packingDesc': 'Categories, progress & suggestions',
|
||||
'login.features.bookings': 'Reservations',
|
||||
'login.features.bookingsDesc': 'Flights, hotels, restaurants & more',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Upload & manage documents',
|
||||
'login.features.routes': 'Smart Routes',
|
||||
'login.features.routesDesc': 'Auto-optimize & Google Maps export',
|
||||
'login.selfHosted': 'Self-hosted · Open Source · Your data stays yours',
|
||||
'login.title': 'Sign In',
|
||||
'login.subtitle': 'Welcome back',
|
||||
'login.signingIn': 'Signing in…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Next month',
|
||||
'mobileVacay.usedCount': '{count} used',
|
||||
'mobileVacay.leftCount': '{count} left',
|
||||
'mobileVacay.daysPerYear': 'days / year',
|
||||
'mobileVacay.left': 'left',
|
||||
'mobileVacay.modeCompany': 'Company',
|
||||
'mobileVacay.decreaseAllowance': 'Decrease allowance',
|
||||
'mobileVacay.increaseAllowance': 'Increase allowance',
|
||||
'mobileVacay.companyLegend': 'Company days',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Tus viajes.\nTu plan.',
|
||||
'login.description':
|
||||
'Planifica viajes en colaboración con mapas interactivos, presupuestos y sincronización en tiempo real.',
|
||||
'login.features.maps': 'Mapas interactivos',
|
||||
'login.features.mapsDesc': 'Google Places, rutas y agrupación',
|
||||
'login.features.realtime': 'Sincronización en tiempo real',
|
||||
'login.features.realtimeDesc': 'Planificad juntos mediante WebSocket',
|
||||
'login.features.budget': 'Control de costes',
|
||||
'login.features.budgetDesc': 'Categorías, gráficos y costes por persona',
|
||||
'login.features.collab': 'Colaboración',
|
||||
'login.features.collabDesc': 'Multiusuario con viajes compartidos',
|
||||
'login.features.packing': 'Listas de equipaje',
|
||||
'login.features.packingDesc': 'Categorías, progreso y sugerencias',
|
||||
'login.features.bookings': 'Reservas',
|
||||
'login.features.bookingsDesc': 'Vuelos, hoteles, restaurantes y más',
|
||||
'login.features.files': 'Documentos',
|
||||
'login.features.filesDesc': 'Sube y gestiona documentos',
|
||||
'login.features.routes': 'Rutas inteligentes',
|
||||
'login.features.routesDesc': 'Optimización automática y exportación a Google Maps',
|
||||
'login.selfHosted': 'Autoalojado · Código abierto · Tus datos siguen siendo tuyos',
|
||||
'login.title': 'Iniciar sesión',
|
||||
'login.subtitle': 'Bienvenido de nuevo',
|
||||
'login.signingIn': 'Iniciando sesión…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Mes siguiente',
|
||||
'mobileVacay.usedCount': '{count} usados',
|
||||
'mobileVacay.leftCount': '{count} restantes',
|
||||
'mobileVacay.daysPerYear': 'días / año',
|
||||
'mobileVacay.left': 'restantes',
|
||||
'mobileVacay.modeCompany': 'Empresa',
|
||||
'mobileVacay.decreaseAllowance': 'Reducir asignación',
|
||||
'mobileVacay.increaseAllowance': 'Aumentar asignación',
|
||||
'mobileVacay.companyLegend': 'Días de empresa',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Vos voyages.\nVotre organisation.',
|
||||
'login.description':
|
||||
'Planifiez vos voyages en collaboration avec des cartes interactives, des budgets et la synchronisation en temps réel.',
|
||||
'login.features.maps': 'Cartes interactives',
|
||||
'login.features.mapsDesc': 'Google Places, itinéraires et regroupement',
|
||||
'login.features.realtime': 'Synchronisation en temps réel',
|
||||
'login.features.realtimeDesc': 'Planifiez ensemble via WebSocket',
|
||||
'login.features.budget': 'Suivi des coûts',
|
||||
'login.features.budgetDesc': 'Catégories, graphiques et coûts par personne',
|
||||
'login.features.collab': 'Collaboration',
|
||||
'login.features.collabDesc': 'Multi-utilisateurs avec voyages partagés',
|
||||
'login.features.packing': 'Listes de bagages',
|
||||
'login.features.packingDesc': 'Catégories, progression et suggestions',
|
||||
'login.features.bookings': 'Réservations',
|
||||
'login.features.bookingsDesc': 'Vols, hôtels, restaurants et plus',
|
||||
'login.features.files': 'Documents',
|
||||
'login.features.filesDesc': 'Importez et gérez vos documents',
|
||||
'login.features.routes': 'Itinéraires intelligents',
|
||||
'login.features.routesDesc': 'Optimisation automatique et export Google Maps',
|
||||
'login.selfHosted': 'Auto-hébergé · Open Source · Vos données restent les vôtres',
|
||||
'login.title': 'Connexion',
|
||||
'login.subtitle': 'Bon retour',
|
||||
'login.signingIn': 'Connexion en cours…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Mois suivant',
|
||||
'mobileVacay.usedCount': '{count} pris',
|
||||
'mobileVacay.leftCount': '{count} restants',
|
||||
'mobileVacay.daysPerYear': 'jours / an',
|
||||
'mobileVacay.left': 'restants',
|
||||
'mobileVacay.modeCompany': 'Entreprise',
|
||||
'mobileVacay.decreaseAllowance': 'Diminuer le quota',
|
||||
'mobileVacay.increaseAllowance': 'Augmenter le quota',
|
||||
'mobileVacay.companyLegend': 'Congés d\'entreprise',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Τα Ταξίδια σας.\nΤο Σχέδιό σας.',
|
||||
'login.description':
|
||||
'Σχεδιάστε ταξίδια συνεργατικά με διαδραστικούς χάρτες, προϋπολογισμούς και συγχρονισμό σε πραγματικό χρόνο.',
|
||||
'login.features.maps': 'Διαδραστικοί Χάρτες',
|
||||
'login.features.mapsDesc': 'Google Places, διαδρομές & ομαδοποίηση',
|
||||
'login.features.realtime': 'Συγχρονισμός Πραγματικού Χρόνου',
|
||||
'login.features.realtimeDesc': 'Σχεδιάστε μαζί μέσω WebSocket',
|
||||
'login.features.budget': 'Παρακολούθηση Κόστους',
|
||||
'login.features.budgetDesc': 'Κατηγορίες, γραφήματα & κόστος ανά άτομο',
|
||||
'login.features.collab': 'Συνεργασία',
|
||||
'login.features.collabDesc': 'Πολλαπλοί χρήστες με κοινόχρηστα ταξίδια',
|
||||
'login.features.packing': 'Λίστες Πακεταρίσματος',
|
||||
'login.features.packingDesc': 'Κατηγορίες, πρόοδος & προτάσεις',
|
||||
'login.features.bookings': 'Κρατήσεις',
|
||||
'login.features.bookingsDesc': 'Πτήσεις, ξενοδοχεία, εστιατόρια & άλλα',
|
||||
'login.features.files': 'Έγγραφα',
|
||||
'login.features.filesDesc': 'Μεταφόρτωση & διαχείριση εγγράφων',
|
||||
'login.features.routes': 'Έξυπνες Διαδρομές',
|
||||
'login.features.routesDesc': 'Αυτόματη βελτιστοποίηση & εξαγωγή στο Google Maps',
|
||||
'login.selfHosted': 'Self-hosted \u00B7 Open Source \u00B7 Τα δεδομένα σας παραμένουν δικά σας',
|
||||
'login.title': 'Σύνδεση',
|
||||
'login.subtitle': 'Καλώς ήρθατε πίσω',
|
||||
'login.signingIn': 'Σύνδεση…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Επόμενος μήνας',
|
||||
'mobileVacay.usedCount': '{count} σε χρήση',
|
||||
'mobileVacay.leftCount': '{count} απομένουν',
|
||||
'mobileVacay.daysPerYear': 'ημέρες / έτος',
|
||||
'mobileVacay.left': 'απομένουν',
|
||||
'mobileVacay.modeCompany': 'Εταιρεία',
|
||||
'mobileVacay.decreaseAllowance': 'Μείωση δικαιώματος',
|
||||
'mobileVacay.increaseAllowance': 'Αύξηση δικαιώματος',
|
||||
'mobileVacay.companyLegend': 'Άδεια εταιρείας',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Az utazásaid.\nA terved.',
|
||||
'login.description':
|
||||
'Tervezz utazásokat közösen interaktív térképekkel, költségvetéssel és valós idejű szinkronizálással.',
|
||||
'login.features.maps': 'Interaktív térképek',
|
||||
'login.features.mapsDesc': 'Google Places, útvonalak és csoportosítás',
|
||||
'login.features.realtime': 'Valós idejű szinkron',
|
||||
'login.features.realtimeDesc': 'Közös tervezés WebSocket-en keresztül',
|
||||
'login.features.budget': 'Költségkövetés',
|
||||
'login.features.budgetDesc': 'Kategóriák, diagramok és személyenkénti költségek',
|
||||
'login.features.collab': 'Együttműködés',
|
||||
'login.features.collabDesc': 'Többfelhasználós, megosztott utazásokkal',
|
||||
'login.features.packing': 'Csomagolási listák',
|
||||
'login.features.packingDesc': 'Kategóriák és haladás',
|
||||
'login.features.bookings': 'Foglalások',
|
||||
'login.features.bookingsDesc': 'Repülők, szállodák, éttermek és még több',
|
||||
'login.features.files': 'Dokumentumok',
|
||||
'login.features.filesDesc': 'Fájlok feltöltése és kezelése',
|
||||
'login.features.routes': 'Útvonal-optimalizálás',
|
||||
'login.features.routesDesc': 'Automatikus optimalizálás és Google Maps export',
|
||||
'login.selfHosted': 'Saját üzemeltetés · Nyílt forráskód · Az adataid nálad maradnak',
|
||||
'login.title': 'Bejelentkezés',
|
||||
'login.subtitle': 'Üdv újra',
|
||||
'login.signingIn': 'Bejelentkezés…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Következő hónap',
|
||||
'mobileVacay.usedCount': '{count} felhasználva',
|
||||
'mobileVacay.leftCount': '{count} maradt',
|
||||
'mobileVacay.daysPerYear': 'nap / év',
|
||||
'mobileVacay.left': 'maradt',
|
||||
'mobileVacay.modeCompany': 'Céges',
|
||||
'mobileVacay.decreaseAllowance': 'Keret csökkentése',
|
||||
'mobileVacay.increaseAllowance': 'Keret növelése',
|
||||
'mobileVacay.companyLegend': 'Céges szabadság',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Login gagal. Periksa kembali kredensial kamu.',
|
||||
'login.tagline': 'Perjalananmu.\nRencanamu.',
|
||||
'login.description': 'Rencanakan perjalanan bersama dengan peta interaktif, anggaran, dan sinkronisasi real-time.',
|
||||
'login.features.maps': 'Peta Interaktif',
|
||||
'login.features.mapsDesc': 'Google Places, rute & pengelompokan',
|
||||
'login.features.realtime': 'Sinkronisasi Real-Time',
|
||||
'login.features.realtimeDesc': 'Rencanakan bersama via WebSocket',
|
||||
'login.features.budget': 'Pelacak Biaya',
|
||||
'login.features.budgetDesc': 'Kategori, grafik & biaya per orang',
|
||||
'login.features.collab': 'Kolaborasi',
|
||||
'login.features.collabDesc': 'Multi-pengguna dengan perjalanan bersama',
|
||||
'login.features.packing': 'Daftar Packing',
|
||||
'login.features.packingDesc': 'Kategori, progres & saran',
|
||||
'login.features.bookings': 'Reservasi',
|
||||
'login.features.bookingsDesc': 'Penerbangan, hotel, restoran & lainnya',
|
||||
'login.features.files': 'Dokumen',
|
||||
'login.features.filesDesc': 'Unggah & kelola dokumen',
|
||||
'login.features.routes': 'Rute Cerdas',
|
||||
'login.features.routesDesc': 'Optimasi otomatis & ekspor ke Google Maps',
|
||||
'login.selfHosted': 'Self-hosted · Open Source · Datamu tetap milikmu',
|
||||
'login.title': 'Masuk',
|
||||
'login.subtitle': 'Selamat datang kembali',
|
||||
'login.signingIn': 'Sedang masuk…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Bulan berikutnya',
|
||||
'mobileVacay.usedCount': '{count} terpakai',
|
||||
'mobileVacay.leftCount': '{count} tersisa',
|
||||
'mobileVacay.daysPerYear': 'hari / tahun',
|
||||
'mobileVacay.left': 'tersisa',
|
||||
'mobileVacay.modeCompany': 'Perusahaan',
|
||||
'mobileVacay.decreaseAllowance': 'Kurangi jatah',
|
||||
'mobileVacay.increaseAllowance': 'Tambah jatah',
|
||||
'mobileVacay.companyLegend': 'Libur perusahaan',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'I tuoi viaggi.\nIl tuo programma.',
|
||||
'login.description':
|
||||
'Programma viaggi in collaborazione con mappe interattive, budget e sincronizzazione in tempo reale.',
|
||||
'login.features.maps': 'Mappe Interattive',
|
||||
'login.features.mapsDesc': 'Google Places, percorsi e clustering',
|
||||
'login.features.realtime': 'Sincronizzazione in tempo reale',
|
||||
'login.features.realtimeDesc': 'Programmate insieme tramite WebSocket',
|
||||
'login.features.budget': 'Tracciamento costi',
|
||||
'login.features.budgetDesc': 'Categorie, grafici e costi per persona',
|
||||
'login.features.collab': 'Collaborazione',
|
||||
'login.features.collabDesc': 'Multi-utente con viaggi condivisi',
|
||||
'login.features.packing': 'Lista Valigia',
|
||||
'login.features.packingDesc': 'Categorie, progressi e suggerimenti',
|
||||
'login.features.bookings': 'Prenotazioni',
|
||||
'login.features.bookingsDesc': 'Voli, alloggi, ristoranti e altro',
|
||||
'login.features.files': 'Documenti',
|
||||
'login.features.filesDesc': 'Carica e gestisci i documenti',
|
||||
'login.features.routes': 'Percorsi Intelligenti',
|
||||
'login.features.routesDesc': 'Ottimizzazione automatica ed esportazione su Google Maps',
|
||||
'login.selfHosted': 'Self-hosted · Open Source · Your data stays yours',
|
||||
'login.title': 'Accedi',
|
||||
'login.subtitle': 'Bentornato',
|
||||
'login.signingIn': 'Accesso in corso…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Mese successivo',
|
||||
'mobileVacay.usedCount': '{count} usati',
|
||||
'mobileVacay.leftCount': '{count} rimanenti',
|
||||
'mobileVacay.daysPerYear': 'giorni / anno',
|
||||
'mobileVacay.left': 'rimanenti',
|
||||
'mobileVacay.modeCompany': 'Azienda',
|
||||
'mobileVacay.decreaseAllowance': 'Riduci giorni disponibili',
|
||||
'mobileVacay.increaseAllowance': 'Aumenta giorni disponibili',
|
||||
'mobileVacay.companyLegend': 'Ferie aziendali',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'ログインに失敗しました。認証情報を確認してください。',
|
||||
'login.tagline': 'あなたの旅行。\nあなたの計画。',
|
||||
'login.description': 'インタラクティブなマップ、予算、リアルタイム同期で、みんなで旅行を計画。',
|
||||
'login.features.maps': 'インタラクティブマップ',
|
||||
'login.features.mapsDesc': 'Google Places、経路、クラスタリング',
|
||||
'login.features.realtime': 'リアルタイム同期',
|
||||
'login.features.realtimeDesc': 'WebSocketで共同計画',
|
||||
'login.features.budget': '費用管理',
|
||||
'login.features.budgetDesc': 'カテゴリ、グラフ、人数別費用',
|
||||
'login.features.collab': 'コラボレーション',
|
||||
'login.features.collabDesc': '複数ユーザーで旅行を共有',
|
||||
'login.features.packing': '持ち物リスト',
|
||||
'login.features.packingDesc': 'カテゴリ、進捗、提案',
|
||||
'login.features.bookings': '予約',
|
||||
'login.features.bookingsDesc': '航空券、ホテル、レストランなど',
|
||||
'login.features.files': 'ドキュメント',
|
||||
'login.features.filesDesc': '書類のアップロードと管理',
|
||||
'login.features.routes': 'スマート経路',
|
||||
'login.features.routesDesc': '自動最適化&Google Maps書き出し',
|
||||
'login.selfHosted': 'セルフホスト · オープンソース · データはあなたのもの',
|
||||
'login.title': 'サインイン',
|
||||
'login.subtitle': 'おかえりなさい',
|
||||
'login.signingIn': 'サインイン中…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': '次の月',
|
||||
'mobileVacay.usedCount': '{count} 日取得',
|
||||
'mobileVacay.leftCount': '残り {count} 日',
|
||||
'mobileVacay.daysPerYear': '日 / 年',
|
||||
'mobileVacay.left': '残り',
|
||||
'mobileVacay.modeCompany': '会社',
|
||||
'mobileVacay.decreaseAllowance': '付与日数を減らす',
|
||||
'mobileVacay.increaseAllowance': '付与日数を増やす',
|
||||
'mobileVacay.companyLegend': '会社の休業日',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': '로그인 실패. 자격 증명을 확인하세요.',
|
||||
'login.tagline': '나의 여행.\n나의 계획.',
|
||||
'login.description': '인터랙티브 지도, 예산, 실시간 동기화로 함께 여행을 계획하세요.',
|
||||
'login.features.maps': '인터랙티브 지도',
|
||||
'login.features.mapsDesc': 'Google Places, 경로 및 클러스터링',
|
||||
'login.features.realtime': '실시간 동기화',
|
||||
'login.features.realtimeDesc': 'WebSocket으로 함께 계획',
|
||||
'login.features.budget': '비용 추적',
|
||||
'login.features.budgetDesc': '카테고리, 차트 및 1인당 비용',
|
||||
'login.features.collab': '협업',
|
||||
'login.features.collabDesc': '공유 여행으로 다중 사용자 지원',
|
||||
'login.features.packing': '짐 목록',
|
||||
'login.features.packingDesc': '카테고리, 진행 상황 및 제안',
|
||||
'login.features.bookings': '예약',
|
||||
'login.features.bookingsDesc': '항공, 호텔, 레스토랑 등',
|
||||
'login.features.files': '문서',
|
||||
'login.features.filesDesc': '문서 업로드 및 관리',
|
||||
'login.features.routes': '스마트 경로',
|
||||
'login.features.routesDesc': '자동 최적화 및 Google Maps 내보내기',
|
||||
'login.selfHosted': '자체 호스팅 · 오픈 소스 · 내 데이터는 내 것',
|
||||
'login.title': '로그인',
|
||||
'login.subtitle': '다시 오신 것을 환영합니다',
|
||||
'login.signingIn': '로그인 중…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': '다음 달',
|
||||
'mobileVacay.usedCount': '{count}일 사용',
|
||||
'mobileVacay.leftCount': '{count}일 남음',
|
||||
'mobileVacay.daysPerYear': '일 / 년',
|
||||
'mobileVacay.left': '남음',
|
||||
'mobileVacay.modeCompany': '회사',
|
||||
'mobileVacay.decreaseAllowance': '할당 줄이기',
|
||||
'mobileVacay.increaseAllowance': '할당 늘리기',
|
||||
'mobileVacay.companyLegend': '회사 지정 휴무',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Inloggen mislukt. Controleer je inloggegevens.',
|
||||
'login.tagline': 'Jouw reizen.\nJouw plan.',
|
||||
'login.description': 'Plan reizen samen met interactieve kaarten, budgetten en realtime synchronisatie.',
|
||||
'login.features.maps': 'Interactieve kaarten',
|
||||
'login.features.mapsDesc': 'Google Places, routes en clustering',
|
||||
'login.features.realtime': 'Realtime synchronisatie',
|
||||
'login.features.realtimeDesc': 'Plan samen via WebSocket',
|
||||
'login.features.budget': 'Onkostenbeheer',
|
||||
'login.features.budgetDesc': 'Categorieën, grafieken en kosten per persoon',
|
||||
'login.features.collab': 'Samenwerking',
|
||||
'login.features.collabDesc': 'Meerdere gebruikers met gedeelde reizen',
|
||||
'login.features.packing': 'Paklijsten',
|
||||
'login.features.packingDesc': 'Categorieën, voortgang en suggesties',
|
||||
'login.features.bookings': 'Reserveringen',
|
||||
'login.features.bookingsDesc': 'Vluchten, hotels, restaurants en meer',
|
||||
'login.features.files': 'Documenten',
|
||||
'login.features.filesDesc': 'Upload en beheer documenten',
|
||||
'login.features.routes': 'Slimme routes',
|
||||
'login.features.routesDesc': 'Automatisch optimaliseren en exporteren naar Google Maps',
|
||||
'login.selfHosted': 'Zelf gehost · Open Source · Jouw gegevens blijven van jou',
|
||||
'login.title': 'Inloggen',
|
||||
'login.subtitle': 'Welkom terug',
|
||||
'login.signingIn': 'Inloggen…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Volgende maand',
|
||||
'mobileVacay.usedCount': '{count} gebruikt',
|
||||
'mobileVacay.leftCount': '{count} over',
|
||||
'mobileVacay.daysPerYear': 'dagen / jaar',
|
||||
'mobileVacay.left': 'over',
|
||||
'mobileVacay.modeCompany': 'Bedrijf',
|
||||
'mobileVacay.decreaseAllowance': 'Tegoed verlagen',
|
||||
'mobileVacay.increaseAllowance': 'Tegoed verhogen',
|
||||
'mobileVacay.companyLegend': 'Bedrijfsverlof',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Twoje podróże.\nTwój plan.',
|
||||
'login.description':
|
||||
'Planuj wspólnie podróże z interaktywnymi mapami, budżetami i synchronizacją w czasie rzeczywistym.',
|
||||
'login.features.maps': 'Interaktywne mapy',
|
||||
'login.features.mapsDesc': 'Google Places, trasy i grupowanie',
|
||||
'login.features.realtime': 'Synchronizacja w czasie rzeczywistym',
|
||||
'login.features.realtimeDesc': 'Planuj wspólnie przez WebSocket',
|
||||
'login.features.budget': 'Śledzenie kosztów',
|
||||
'login.features.budgetDesc': 'Kategorie, wykresy i koszty w przeliczeniu na osobę',
|
||||
'login.features.collab': 'Współpraca',
|
||||
'login.features.collabDesc': 'Wielu użytkowników i wspólne podróże',
|
||||
'login.features.packing': 'Listy pakowania',
|
||||
'login.features.packingDesc': 'Kategorie, postęp i sugestie',
|
||||
'login.features.bookings': 'Rezerwacje',
|
||||
'login.features.bookingsDesc': 'Loty, hotele, restauracje i więcej',
|
||||
'login.features.files': 'Dokumenty',
|
||||
'login.features.filesDesc': 'Przesyłaj i zarządzaj dokumentami',
|
||||
'login.features.routes': 'Inteligentne trasy',
|
||||
'login.features.routesDesc': 'Automatyczna optymalizacja i eksport do Google Maps',
|
||||
'login.selfHosted': 'Własny hosting · Otwarty kod źródłowy · Twoje dane pozostają Twoje',
|
||||
'login.title': 'Zaloguj się',
|
||||
'login.subtitle': 'Witaj ponownie',
|
||||
'login.signingIn': 'Logowanie...',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Następny miesiąc',
|
||||
'mobileVacay.usedCount': 'Wykorzystano: {count}',
|
||||
'mobileVacay.leftCount': 'Pozostało: {count}',
|
||||
'mobileVacay.daysPerYear': 'dni / rok',
|
||||
'mobileVacay.left': 'pozostało',
|
||||
'mobileVacay.modeCompany': 'Firma',
|
||||
'mobileVacay.decreaseAllowance': 'Zmniejsz limit',
|
||||
'mobileVacay.increaseAllowance': 'Zwiększ limit',
|
||||
'mobileVacay.companyLegend': 'Dni firmowe',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Ваши поездки.\nВаш план.',
|
||||
'login.description':
|
||||
'Планируйте поездки совместно с интерактивными картами, бюджетами и синхронизацией в реальном времени.',
|
||||
'login.features.maps': 'Интерактивные карты',
|
||||
'login.features.mapsDesc': 'Google Places, маршруты и кластеризация',
|
||||
'login.features.realtime': 'Синхронизация в реальном времени',
|
||||
'login.features.realtimeDesc': 'Планируйте вместе через WebSocket',
|
||||
'login.features.budget': 'Контроль расходов',
|
||||
'login.features.budgetDesc': 'Категории, графики и расходы на человека',
|
||||
'login.features.collab': 'Совместная работа',
|
||||
'login.features.collabDesc': 'Многопользовательский режим с общими поездками',
|
||||
'login.features.packing': 'Списки вещей',
|
||||
'login.features.packingDesc': 'Категории, прогресс и подсказки',
|
||||
'login.features.bookings': 'Бронирования',
|
||||
'login.features.bookingsDesc': 'Авиабилеты, отели, рестораны и многое другое',
|
||||
'login.features.files': 'Документы',
|
||||
'login.features.filesDesc': 'Загружайте и управляйте документами',
|
||||
'login.features.routes': 'Умные маршруты',
|
||||
'login.features.routesDesc': 'Автооптимизация и экспорт в Google Maps',
|
||||
'login.selfHosted': 'Самостоятельный хостинг · Открытый код · Ваши данные остаются у вас',
|
||||
'login.title': 'Вход',
|
||||
'login.subtitle': 'С возвращением',
|
||||
'login.signingIn': 'Вход…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Следующий месяц',
|
||||
'mobileVacay.usedCount': 'Использовано: {count}',
|
||||
'mobileVacay.leftCount': 'Осталось: {count}',
|
||||
'mobileVacay.daysPerYear': 'дней / год',
|
||||
'mobileVacay.left': 'осталось',
|
||||
'mobileVacay.modeCompany': 'Компания',
|
||||
'mobileVacay.decreaseAllowance': 'Уменьшить лимит',
|
||||
'mobileVacay.increaseAllowance': 'Увеличить лимит',
|
||||
'mobileVacay.companyLegend': 'Корпоративные дни',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Dina resor.\nDin plan.',
|
||||
'login.description':
|
||||
'Planera resor tillsammans med hjälp av interaktiva kartor, budgetar och synkronisering i realtid.',
|
||||
'login.features.maps': 'Interaktiva kartor',
|
||||
'login.features.mapsDesc': 'Google Places, rutter och klusterbildning',
|
||||
'login.features.realtime': 'Synkronisering i realtid',
|
||||
'login.features.realtimeDesc': 'Planera tillsammans via WebSocket',
|
||||
'login.features.budget': 'Kostnadsuppföljning',
|
||||
'login.features.budgetDesc': 'Kategorier, diagram och kostnader per person',
|
||||
'login.features.collab': 'Samarbete',
|
||||
'login.features.collabDesc': 'Flera användare med delade resor',
|
||||
'login.features.packing': 'Packlistor',
|
||||
'login.features.packingDesc': 'Kategorier, framsteg och förslag',
|
||||
'login.features.bookings': 'Bokningar',
|
||||
'login.features.bookingsDesc': 'Flyg, hotell, restauranger och mycket mer',
|
||||
'login.features.files': 'Dokument',
|
||||
'login.features.filesDesc': 'Ladda upp och hantera dokument',
|
||||
'login.features.routes': 'Smarta rutter',
|
||||
'login.features.routesDesc': 'Automatisk optimering och export till Google Maps',
|
||||
'login.selfHosted': 'Egenhostad · Öppen källkod · Dina data förblir dina',
|
||||
'login.title': 'Logga in',
|
||||
'login.subtitle': 'Välkommen tillbaka',
|
||||
'login.signingIn': 'Loggar in…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Nästa månad',
|
||||
'mobileVacay.usedCount': '{count} använda',
|
||||
'mobileVacay.leftCount': '{count} kvar',
|
||||
'mobileVacay.daysPerYear': 'dagar / år',
|
||||
'mobileVacay.left': 'kvar',
|
||||
'mobileVacay.modeCompany': 'Företag',
|
||||
'mobileVacay.decreaseAllowance': 'Minska kvot',
|
||||
'mobileVacay.increaseAllowance': 'Öka kvot',
|
||||
'mobileVacay.companyLegend': 'Företagsledighet',
|
||||
|
||||
@@ -5,23 +5,6 @@ const login: TranslationStrings = {
|
||||
'login.tagline': 'Gezileriniz.\\nPlanınız.',
|
||||
'login.description':
|
||||
'Etkileşimli haritalar, bütçeler ve gerçek zamanlı senkronizasyonla gezileri işbirliği içinde planlayın.',
|
||||
'login.features.maps': 'İnteraktif Haritalar',
|
||||
'login.features.mapsDesc': 'Google Rehber, rotalar ve kümeleme',
|
||||
'login.features.realtime': 'Gerçek Zamanlı Senkronizasyon',
|
||||
'login.features.realtimeDesc': 'WebSocket aracılığıyla birlikte plan yapın',
|
||||
'login.features.budget': 'Maliyet Takibi',
|
||||
'login.features.budgetDesc': 'Kategoriler, grafikler ve kişi başı maliyetler',
|
||||
'login.features.collab': 'İşbirliği',
|
||||
'login.features.collabDesc': 'Paylaşılan gezilerle çok kullanıcılı',
|
||||
'login.features.packing': 'Paketleme Listeleri',
|
||||
'login.features.packingDesc': 'Kategoriler, ilerleme ve öneriler',
|
||||
'login.features.bookings': 'Rezervasyonlar',
|
||||
'login.features.bookingsDesc': 'Uçuşlar, oteller, restoranlar ve daha fazlası',
|
||||
'login.features.files': 'Belgeler',
|
||||
'login.features.filesDesc': 'Belgeleri yükleyin ve yönetin',
|
||||
'login.features.routes': 'Akıllı Rotalar',
|
||||
'login.features.routesDesc': "Otomatik optimize etme ve Google Haritalar'ı dışa aktarma",
|
||||
'login.selfHosted': 'Kendi kendine barındırılan \\u00B7 Açık Kaynak \\u00B7 Verileriniz size ait kalır',
|
||||
'login.title': 'Oturum Aç',
|
||||
'login.subtitle': 'Tekrar hoş geldiniz',
|
||||
'login.signingIn': 'Oturum açılıyor…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Sonraki ay',
|
||||
'mobileVacay.usedCount': '{count} kullanıldı',
|
||||
'mobileVacay.leftCount': '{count} kaldı',
|
||||
'mobileVacay.daysPerYear': 'gün / yıl',
|
||||
'mobileVacay.left': 'kaldı',
|
||||
'mobileVacay.modeCompany': 'Şirket',
|
||||
'mobileVacay.decreaseAllowance': 'Hakkı azalt',
|
||||
'mobileVacay.increaseAllowance': 'Hakkı artır',
|
||||
'mobileVacay.companyLegend': 'Şirket tatili',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Помилка входу. Перевірте свої облікові дані.',
|
||||
'login.tagline': 'Ваші поїздки.\nВаш план.',
|
||||
'login.description': 'Плануйте поїздки разом з інтерактивними картами, бюджетами та синхронізацією в реальному часі.',
|
||||
'login.features.maps': 'Інтерактивні карти',
|
||||
'login.features.mapsDesc': 'Google Places, маршрути та кластеризація',
|
||||
'login.features.realtime': 'Синхронізація в реальному часі',
|
||||
'login.features.realtimeDesc': 'Плануйте разом через WebSocket',
|
||||
'login.features.budget': 'Контроль витрат',
|
||||
'login.features.budgetDesc': 'Категорії, графіки та витрати на особу',
|
||||
'login.features.collab': 'Спільна робота',
|
||||
'login.features.collabDesc': 'Багатокористувацький режим зі спільними поїздками',
|
||||
'login.features.packing': 'Списки речей',
|
||||
'login.features.packingDesc': 'Категорії, прогрес та підказки',
|
||||
'login.features.bookings': 'Бронювання',
|
||||
'login.features.bookingsDesc': 'Авіаквитки, готелі, ресторани та багато іншого',
|
||||
'login.features.files': 'Документи',
|
||||
'login.features.filesDesc': 'Завантажуйте та керуйте документами',
|
||||
'login.features.routes': 'Розумні маршрути',
|
||||
'login.features.routesDesc': 'Автооптимізація та експорт з Google Maps',
|
||||
'login.selfHosted': 'Self-hosted · Відкритий код · Ваші дані залишаються у вас',
|
||||
'login.title': 'Вхід',
|
||||
'login.subtitle': 'З поверненням',
|
||||
'login.signingIn': 'Вхід…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Наступний місяць',
|
||||
'mobileVacay.usedCount': 'Використано: {count}',
|
||||
'mobileVacay.leftCount': 'Залишилось: {count}',
|
||||
'mobileVacay.daysPerYear': 'днів / рік',
|
||||
'mobileVacay.left': 'залишилось',
|
||||
'mobileVacay.modeCompany': 'Компанія',
|
||||
'mobileVacay.decreaseAllowance': 'Зменшити ліміт',
|
||||
'mobileVacay.increaseAllowance': 'Збільшити ліміт',
|
||||
'mobileVacay.companyLegend': 'Корпоративні дні',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': 'Đăng nhập không thành công. Vui lòng kiểm tra thông tin đăng nhập của bạn.',
|
||||
'login.tagline': 'Chuyến đi của bạn.\nKế hoạch của bạn.',
|
||||
'login.description': 'Lập kế hoạch chuyến đi cộng tác với bản đồ tương tác, ngân sách và đồng bộ hóa thời gian thực.',
|
||||
'login.features.maps': 'Bản đồ tương tác',
|
||||
'login.features.mapsDesc': 'Google Địa điểm, tuyến đường và phân cụm',
|
||||
'login.features.realtime': 'Đồng bộ hóa thời gian thực',
|
||||
'login.features.realtimeDesc': 'Lập kế hoạch cùng nhau thông qua WebSocket',
|
||||
'login.features.budget': 'Theo dõi chi phí',
|
||||
'login.features.budgetDesc': 'Danh mục, biểu đồ và chi phí mỗi người',
|
||||
'login.features.collab': 'Sự hợp tác',
|
||||
'login.features.collabDesc': 'Nhiều người dùng với các chuyến đi chung',
|
||||
'login.features.packing': 'Danh sách đóng gói',
|
||||
'login.features.packingDesc': 'Danh mục, tiến độ và đề xuất',
|
||||
'login.features.bookings': 'Đặt chỗ',
|
||||
'login.features.bookingsDesc': 'Chuyến bay, khách sạn, nhà hàng và hơn thế nữa',
|
||||
'login.features.files': 'Tài liệu',
|
||||
'login.features.filesDesc': 'Tải lên và quản lý tài liệu',
|
||||
'login.features.routes': 'Tuyến đường thông minh',
|
||||
'login.features.routesDesc': 'Tự động tối ưu hóa & xuất Google Maps',
|
||||
'login.selfHosted': 'Tự lưu trữ · Mã nguồn mở · Dữ liệu của bạn vẫn là của bạn',
|
||||
'login.title': 'Đăng nhập',
|
||||
'login.subtitle': 'Chào mừng trở lại',
|
||||
'login.signingIn': 'Đang đăng nhập…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': 'Tháng sau',
|
||||
'mobileVacay.usedCount': 'Đã dùng {count}',
|
||||
'mobileVacay.leftCount': 'Còn {count}',
|
||||
'mobileVacay.daysPerYear': 'ngày / năm',
|
||||
'mobileVacay.left': 'còn',
|
||||
'mobileVacay.modeCompany': 'Công ty',
|
||||
'mobileVacay.decreaseAllowance': 'Giảm hạn mức',
|
||||
'mobileVacay.increaseAllowance': 'Tăng hạn mức',
|
||||
'mobileVacay.companyLegend': 'Ngày nghỉ công ty',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': '登入失敗,請檢查你的憑據。',
|
||||
'login.tagline': '你的旅行。\n你的計劃。',
|
||||
'login.description': '透過互動地圖、預算管理和即時同步,協同規劃旅行。',
|
||||
'login.features.maps': '互動地圖',
|
||||
'login.features.mapsDesc': 'Google Places、路線和聚類',
|
||||
'login.features.realtime': '即時同步',
|
||||
'login.features.realtimeDesc': '透過 WebSocket 協同規劃',
|
||||
'login.features.budget': '費用追蹤',
|
||||
'login.features.budgetDesc': '分類、圖表和人均費用',
|
||||
'login.features.collab': '協作',
|
||||
'login.features.collabDesc': '多使用者共享旅行',
|
||||
'login.features.packing': '行李清單',
|
||||
'login.features.packingDesc': '分類、進度和建議',
|
||||
'login.features.bookings': '預訂',
|
||||
'login.features.bookingsDesc': '航班、酒店、餐廳等',
|
||||
'login.features.files': '文件',
|
||||
'login.features.filesDesc': '上傳和管理文件',
|
||||
'login.features.routes': '智慧路線',
|
||||
'login.features.routesDesc': '自動最佳化和匯出到 Google Maps',
|
||||
'login.selfHosted': '自託管 · 開源 · 資料由你掌控',
|
||||
'login.title': '登入',
|
||||
'login.subtitle': '歡迎回來',
|
||||
'login.signingIn': '登入中…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': '下個月',
|
||||
'mobileVacay.usedCount': '已用 {count}',
|
||||
'mobileVacay.leftCount': '剩餘 {count}',
|
||||
'mobileVacay.daysPerYear': '天 / 年',
|
||||
'mobileVacay.left': '剩餘',
|
||||
'mobileVacay.modeCompany': '公司',
|
||||
'mobileVacay.decreaseAllowance': '減少額度',
|
||||
'mobileVacay.increaseAllowance': '增加額度',
|
||||
'mobileVacay.companyLegend': '公司假期',
|
||||
|
||||
@@ -4,23 +4,6 @@ const login: TranslationStrings = {
|
||||
'login.error': '登录失败,请检查你的凭据。',
|
||||
'login.tagline': '你的旅行。\n你的计划。',
|
||||
'login.description': '通过互动地图、预算管理和实时同步,协同规划旅行。',
|
||||
'login.features.maps': '互动地图',
|
||||
'login.features.mapsDesc': 'Google Places、路线和聚类',
|
||||
'login.features.realtime': '实时同步',
|
||||
'login.features.realtimeDesc': '通过 WebSocket 协同规划',
|
||||
'login.features.budget': '费用跟踪',
|
||||
'login.features.budgetDesc': '分类、图表和人均费用',
|
||||
'login.features.collab': '协作',
|
||||
'login.features.collabDesc': '多用户共享旅行',
|
||||
'login.features.packing': '行李清单',
|
||||
'login.features.packingDesc': '分类、进度和建议',
|
||||
'login.features.bookings': '预订',
|
||||
'login.features.bookingsDesc': '航班、酒店、餐厅等',
|
||||
'login.features.files': '文档',
|
||||
'login.features.filesDesc': '上传和管理文档',
|
||||
'login.features.routes': '智能路线',
|
||||
'login.features.routesDesc': '自动优化和导出到 Google Maps',
|
||||
'login.selfHosted': '自托管 · 开源 · 数据由你掌控',
|
||||
'login.title': '登录',
|
||||
'login.subtitle': '欢迎回来',
|
||||
'login.signingIn': '登录中…',
|
||||
|
||||
@@ -7,7 +7,8 @@ const mobileVacay: TranslationStrings = {
|
||||
'mobileVacay.nextMonth': '下个月',
|
||||
'mobileVacay.usedCount': '已用 {count}',
|
||||
'mobileVacay.leftCount': '剩余 {count}',
|
||||
'mobileVacay.daysPerYear': '天 / 年',
|
||||
'mobileVacay.left': '剩余',
|
||||
'mobileVacay.modeCompany': '公司',
|
||||
'mobileVacay.decreaseAllowance': '减少额度',
|
||||
'mobileVacay.increaseAllowance': '增加额度',
|
||||
'mobileVacay.companyLegend': '公司假期',
|
||||
|
||||
Reference in New Issue
Block a user