mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-25 00:13:00 +00:00
Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a9fb0fc475 | |||
| 5596355141 | |||
| 9c83386655 | |||
| 98961dadd5 | |||
| 7ada99acfc | |||
| 2ac333f842 | |||
| 270a13ff60 | |||
| d800e64f93 | |||
| 390135f3bc | |||
| cad7d54e83 | |||
| 2df677d065 | |||
| 03493d4f79 | |||
| eae74c5605 | |||
| ec486214f1 | |||
| d09a09ad52 | |||
| 8b41561db9 | |||
| 73744203fb | |||
| bc385f27dd | |||
| 866b9e94af | |||
| ed7c79bcb7 | |||
| 1e4b618878 | |||
| 982861264e | |||
| 6434fe7a3c | |||
| 70e07f1965 | |||
| 87041c8842 | |||
| edb113f97f | |||
| fcd5f5dbb6 | |||
| 6849742d67 | |||
| 022e088da4 | |||
| 2471fc5aa7 | |||
| fad301ef5e | |||
| 1c83bf78fa | |||
| a6cd4ffdff | |||
| 988d27c39d | |||
| 62b954cffc | |||
| a2fed6afb8 | |||
| 97510e007e | |||
| 36f81934aa | |||
| 303bce431d | |||
| 73811e0bb7 | |||
| 0ca9f7241a | |||
| bab4c7edcd | |||
| 166b0d8ab3 | |||
| a5dbf6934c | |||
| d2bd6e7556 | |||
| 676d086ff2 | |||
| d8793f3d0b | |||
| fcf4c6e736 | |||
| fa40dfba2c | |||
| 2a17227337 | |||
| a5757dd59f | |||
| 2e10263155 | |||
| 7a7c66e801 | |||
| a55728fe46 | |||
| 8bd5e428b4 | |||
| 504b58ecf6 | |||
| 0f05b0342c | |||
| 7eea0e7669 | |||
| 0f88a74976 | |||
| 9ffd5e5ffb | |||
| 00a326029b | |||
| a47b35b965 | |||
| fcfcf2ff17 | |||
| 016567dd9a | |||
| b64dd700a8 | |||
| b0b6f5872f | |||
| e119a2c936 | |||
| 45ae8b5af2 | |||
| e28bd2fc99 | |||
| 1696162d22 | |||
| f375a2f3cc |
@@ -22,7 +22,6 @@ WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY client/package.json ./client/
|
||||
COPY client/scripts/patch-maplibre.mjs ./client/scripts/
|
||||
RUN npm ci --workspace=client
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY client/ ./client/
|
||||
|
||||
+3
-5
@@ -8,7 +8,7 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"prebuild": "node scripts/patch-maplibre.mjs && node scripts/generate-icons.mjs",
|
||||
"prebuild": "node scripts/generate-icons.mjs",
|
||||
"build": "vite build",
|
||||
"build:analyze": "vite build --mode analyze",
|
||||
"preview": "vite preview",
|
||||
@@ -41,9 +41,7 @@
|
||||
"shots:promote": "node e2e/screenshots/promote.mjs",
|
||||
"e2e:report": "playwright show-report",
|
||||
"format": "prettier --write \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\"",
|
||||
"postinstall": "node scripts/patch-maplibre.mjs",
|
||||
"predev": "node scripts/patch-maplibre.mjs"
|
||||
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
@@ -94,7 +92,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"trek/store-without-selector": 67,
|
||||
"react-hooks/refs": 555,
|
||||
"react-hooks/set-state-in-effect": 194,
|
||||
"@typescript-eslint/no-floating-promises": 165,
|
||||
"@typescript-eslint/no-floating-promises": 164,
|
||||
"react-hooks/exhaustive-deps": 116,
|
||||
"@typescript-eslint/no-unused-vars": 124,
|
||||
"react-hooks/immutability": 28,
|
||||
@@ -35,7 +35,7 @@
|
||||
},
|
||||
"tests": {
|
||||
"@typescript-eslint/no-explicit-any": 490,
|
||||
"@typescript-eslint/no-non-null-assertion": 1929,
|
||||
"@typescript-eslint/no-non-null-assertion": 1927,
|
||||
"@typescript-eslint/no-misused-promises": 22,
|
||||
"@typescript-eslint/no-unused-vars": 51,
|
||||
"@typescript-eslint/no-floating-promises": 33,
|
||||
|
||||
@@ -661,9 +661,24 @@
|
||||
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
|
||||
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Demo account locks",
|
||||
"hooks": ["src/components/Demo/useDemoLock.ts"],
|
||||
"desktop": [
|
||||
"src/components/Settings/AccountDemoLock.tsx",
|
||||
"src/components/Settings/DisplaySettingsTab.tsx",
|
||||
"src/components/Settings/IntegrationsTab.tsx",
|
||||
"src/components/Settings/ai/AiConnectionSection.tsx"
|
||||
],
|
||||
"mobile": [
|
||||
"src/mobile/screens/settings/MAiConnectionSection.tsx",
|
||||
"src/mobile/screens/settings/MSettingsGeneral.tsx",
|
||||
"src/mobile/screens/settings/MSettingsMcp.tsx"
|
||||
]
|
||||
},
|
||||
{
|
||||
"feature": "Settings / Account",
|
||||
"hooks": ["src/components/Settings/useAccountSettings.ts"],
|
||||
"hooks": ["src/components/Settings/AccountDemoLock.tsx", "src/components/Settings/useAccountSettings.ts"],
|
||||
"desktop": ["src/components/Settings/AccountTab.tsx"],
|
||||
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
|
||||
},
|
||||
|
||||
@@ -55,7 +55,6 @@
|
||||
"scripts/check-gl-split.mjs",
|
||||
"scripts/check-page-pattern.mjs",
|
||||
"scripts/generate-icons.mjs",
|
||||
"scripts/patch-maplibre.mjs",
|
||||
"src/App.test.tsx",
|
||||
"src/App.tsx",
|
||||
"src/App.versionHandover.test.tsx",
|
||||
@@ -409,7 +408,6 @@
|
||||
"src/components/Journey/usePhotoZoom.ts",
|
||||
"src/components/Layout/BottomNav.test.tsx",
|
||||
"src/components/Layout/BottomNav.tsx",
|
||||
"src/components/Layout/DemoBanner.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.test.tsx",
|
||||
"src/components/Layout/InAppNotificationBell.tsx",
|
||||
"src/components/Layout/MobileTopBar.test.tsx",
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const packagePath = require.resolve('maplibre-gl/package.json')
|
||||
const { version } = JSON.parse(readFileSync(packagePath, 'utf8'))
|
||||
|
||||
if (version !== '5.24.0') throw new Error('Recheck the MapLibre sanitizer backport before changing its version')
|
||||
|
||||
// Backport maplibre-gl-js#8189 without removing WebGL1 support in version 6.
|
||||
const loops = [
|
||||
['of elem.attributes)', 'of Array.from(elem.attributes))'],
|
||||
['of e.attributes)', 'of Array.from(e.attributes))'],
|
||||
]
|
||||
|
||||
for (const filename of [
|
||||
'src/util/dom.ts',
|
||||
'dist/maplibre-gl.js',
|
||||
'dist/maplibre-gl-dev.js',
|
||||
'dist/maplibre-gl-csp.js',
|
||||
'dist/maplibre-gl-csp-dev.js',
|
||||
]) {
|
||||
const target = join(dirname(packagePath), filename)
|
||||
const code = readFileSync(target, 'utf8')
|
||||
const loop = loops.find(([before, after]) => code.includes(before) || code.includes(after))
|
||||
if (!loop) throw new Error(`MapLibre sanitizer loop not found in ${filename}`)
|
||||
const [before, after] = loop
|
||||
if (code.split(before).length === 1 && code.split(after).length === 2) continue
|
||||
if (code.split(before).length !== 2 || code.includes(after)) {
|
||||
throw new Error(`Unexpected MapLibre sanitizer in ${filename}`)
|
||||
}
|
||||
writeFileSync(target, code.replace(before, after))
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
"src/components/Budget/CostsPanel.test.tsx": 2736,
|
||||
"src/components/Budget/CostsPanel.tsx": 1798,
|
||||
"src/components/Map/MapView.tsx": 1399,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2920,
|
||||
"src/components/Map/MapViewGL.test.tsx": 2919,
|
||||
"src/components/Map/MapViewGL.tsx": 2290,
|
||||
"src/components/Map/RouteCalculator.ts": 1076,
|
||||
"src/components/PDF/TripPDF.tsx": 1082,
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"components/Journey/journeyMapPopup.ts": 17,
|
||||
"components/Journey/moodConfig.ts": 5,
|
||||
"components/Layout/BottomNav.tsx": 3,
|
||||
"components/Layout/DemoBanner.tsx": 25,
|
||||
"components/Layout/InAppNotificationBell.tsx": 3,
|
||||
"components/Layout/MobileTopBar.tsx": 51,
|
||||
"components/Layout/Navbar.tsx": 21,
|
||||
@@ -207,7 +206,7 @@
|
||||
"mobile/screens/settings/MSetPickerSheet.tsx": 2,
|
||||
"mobile/screens/settings/MSettings.tsx": 2,
|
||||
"mobile/screens/settings/MSettingsAbout.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 21,
|
||||
"mobile/screens/settings/MSettingsAccount.tsx": 20,
|
||||
"mobile/screens/settings/MSettingsAppearance.tsx": 12,
|
||||
"mobile/screens/settings/MSettingsMap.tsx": 5,
|
||||
"mobile/screens/settings/MSettingsMcp.tsx": 30,
|
||||
|
||||
@@ -140,7 +140,17 @@ export function ChatBubble({
|
||||
>
|
||||
{reply.name}
|
||||
</div>
|
||||
<div style={{ opacity: 0.8, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{/* Wrapped and cut after two lines: a quote kept on one line set the bubble's width and pushed it out of the chat. */}
|
||||
<div
|
||||
style={{
|
||||
opacity: 0.8,
|
||||
overflow: 'hidden',
|
||||
whiteSpace: 'normal',
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
}}
|
||||
>
|
||||
{reply.text}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,8 @@ describe('the Collab pieces', () => {
|
||||
expect(quoted.parentElement?.getAttribute('style')).toContain('background: var(--bg-card)');
|
||||
expect(screen.getByText('Anna')).toBeInTheDocument();
|
||||
expect(screen.getByText('Train?')).toBeInTheDocument();
|
||||
// A long quote wraps instead of setting the bubble's width (it pushed the bubble out of the chat).
|
||||
expect(screen.getByText('Train?').getAttribute('style')).toContain('white-space: normal');
|
||||
});
|
||||
|
||||
it('FE-COLLAB-PARTS-002: a face is the picture, else the first letter; day, reactions and time draw their children', () => {
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
import {
|
||||
PHOTO_CREDITS,
|
||||
PUBLIC_DOMAIN,
|
||||
commonsPage,
|
||||
creditTitle,
|
||||
licenseDeed,
|
||||
type PhotoCredit,
|
||||
} from './demoCreditsModel';
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
const LINK = 'underline decoration-edge underline-offset-2 hover:text-content';
|
||||
|
||||
/** One photo: its title linking to its page on Commons, who took it, and its license linking to the deed. */
|
||||
function CreditRow({ credit, t }: { credit: PhotoCredit; t: Translate }) {
|
||||
const deed = licenseDeed(credit.license);
|
||||
const license = credit.license === PUBLIC_DOMAIN ? t('demo.credits.publicDomain') : credit.license;
|
||||
return (
|
||||
<li className="flex flex-col gap-0.5 py-2.5">
|
||||
<a
|
||||
href={commonsPage(credit.file)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`${LINK} self-start break-words font-medium text-content`}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
{creditTitle(credit.file)}
|
||||
</a>
|
||||
<span className="flex flex-wrap gap-x-3 gap-y-0.5 text-content-muted" style={fs(12, 'body')}>
|
||||
<span>{t('demo.credits.by', { author: credit.author })}</span>
|
||||
{deed ? (
|
||||
<a href={deed} target="_blank" rel="noopener noreferrer" className={LINK}>
|
||||
{license}
|
||||
</a>
|
||||
) : (
|
||||
<span>{license}</span>
|
||||
)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credits for the demo's photos, the welcome dialog's second view. Its own
|
||||
* chunk, loaded when a visitor opens it: only a demo instance shows the
|
||||
* welcome, and few visitors open its credits.
|
||||
*/
|
||||
export default function DemoCredits() {
|
||||
const { t } = useTranslation();
|
||||
const intro = useRef<HTMLParagraphElement>(null);
|
||||
|
||||
// The dialog's body keeps the welcome's scroll position when the credits
|
||||
// take its place; they start at their top.
|
||||
useEffect(() => {
|
||||
intro.current?.scrollIntoView({ block: 'nearest' });
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<p ref={intro} className="m-0 scroll-mt-5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t('demo.credits.intro')}
|
||||
</p>
|
||||
<ul
|
||||
aria-label={t('demo.credits.title')}
|
||||
className="m-0 list-none divide-y divide-edge-faint rounded-[16px] border border-edge-faint bg-surface-card px-3.5"
|
||||
>
|
||||
{PHOTO_CREDITS.map((credit) => (
|
||||
<CreditRow key={credit.file} credit={credit} t={t} />
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoLock from './DemoLock';
|
||||
|
||||
describe('DemoLock', () => {
|
||||
it('FE-COMP-DEMOLOCK-001: unlocked, the section is the section and nothing more', () => {
|
||||
const { container } = render(
|
||||
<DemoLock locked={false} reason="Not here.">
|
||||
<button type="button">Create key</button>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create key' })).toBeEnabled();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-demo-lock]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOLOCK-002: locked, the section stays visible but out of reach, under the badge and the reason', () => {
|
||||
const { container } = render(
|
||||
<DemoLock locked reason="API keys cannot be created in the demo.">
|
||||
<button type="button">Create key</button>
|
||||
</DemoLock>
|
||||
);
|
||||
const note = screen.getByRole('note');
|
||||
expect(note).toHaveTextContent('Demo mode');
|
||||
expect(note).toHaveTextContent('API keys cannot be created in the demo.');
|
||||
expect(screen.queryByRole('button', { name: 'Create key' })).not.toBeInTheDocument();
|
||||
const veiled = container.querySelector('[data-demo-lock] > [inert]');
|
||||
expect(veiled).toHaveAttribute('aria-hidden', 'true');
|
||||
expect(veiled).toContainElement(screen.getByRole('button', { hidden: true, name: 'Create key' }));
|
||||
expect(note.getAttribute('style')).toContain('repeating-linear-gradient');
|
||||
expect(note.getAttribute('style')).toContain('var(--text-primary)');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOLOCK-003: a single row puts the note in its middle, a section at its top', () => {
|
||||
const { rerender } = render(
|
||||
<DemoLock locked reason="r" size="row">
|
||||
<span>row</span>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('note').className).toContain('items-center');
|
||||
rerender(
|
||||
<DemoLock locked reason="r">
|
||||
<span>section</span>
|
||||
</DemoLock>
|
||||
);
|
||||
expect(screen.getByRole('note').className).toContain('items-start');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Lock } from 'lucide-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import { fs } from '../shared/DialogShell';
|
||||
|
||||
interface DemoLockProps {
|
||||
/** From useDemoAccountLock: false renders the children untouched. */
|
||||
locked: boolean;
|
||||
/** One line saying what the demo does not allow here. */
|
||||
reason: string;
|
||||
children: ReactNode;
|
||||
/** `row` for a single settings row: the note sits in the middle instead of at the top. */
|
||||
size?: 'section' | 'row';
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** Thin diagonal stripes in the theme's own ink, so they read in light and dark alike. */
|
||||
const STRIPES =
|
||||
'repeating-linear-gradient(135deg, color-mix(in srgb, var(--text-primary) 8%, transparent) 0 9px, transparent 9px 18px)';
|
||||
|
||||
/**
|
||||
* A section the shared demo account may look at but not change: dimmed,
|
||||
* out of reach for pointer and keyboard, under a striped veil that carries a
|
||||
* small "Demo mode" badge and the reason. Used on the desktop settings and on
|
||||
* the phone screens alike.
|
||||
*/
|
||||
export default function DemoLock({ locked, reason, children, size = 'section', className = '' }: DemoLockProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!locked) return <>{children}</>;
|
||||
return (
|
||||
<div className={`relative ${className}`} data-demo-lock="">
|
||||
<div inert aria-hidden="true" className="pointer-events-none select-none opacity-40 saturate-50">
|
||||
{children}
|
||||
</div>
|
||||
<div
|
||||
role="note"
|
||||
className={`absolute inset-0 flex justify-center rounded-2xl border border-dashed border-edge-secondary ${
|
||||
size === 'row' ? 'items-center p-1' : 'items-start p-3'
|
||||
}`}
|
||||
style={{ background: STRIPES }}
|
||||
>
|
||||
<div className="sticky top-3 flex max-w-full flex-wrap items-center justify-center gap-x-2.5 gap-y-1 rounded-2xl bg-surface-card px-3 py-2 text-center shadow-md ring-1 ring-edge-faint">
|
||||
<span
|
||||
className="inline-flex flex-none items-center gap-1 rounded-full bg-accent px-2 py-0.5 font-semibold uppercase tracking-[.06em] text-accent-text"
|
||||
style={fs(10)}
|
||||
>
|
||||
<Lock size={11} strokeWidth={2.4} aria-hidden />
|
||||
{t('demo.lock.badge')}
|
||||
</span>
|
||||
<span className="min-w-0 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{reason}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { act, fireEvent, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import DemoWelcome from './DemoWelcome';
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
seedStore(useAuthStore, { serverTimezone: 'UTC' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe('DemoWelcome', () => {
|
||||
it('FE-COMP-DEMOWELCOME-001: opens as a dialog saying what TREK is', () => {
|
||||
render(<DemoWelcome />);
|
||||
const dialog = screen.getByRole('dialog', { name: 'Live demo' });
|
||||
expect(dialog).toHaveTextContent('Welcome to TREK');
|
||||
expect(dialog).toHaveTextContent('The travel planner you share');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-002: says the demo resets every hour and what it cannot do', () => {
|
||||
render(<DemoWelcome />);
|
||||
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
|
||||
for (const line of [
|
||||
'Uploading photos, documents and covers',
|
||||
'Changing the account, its password or 2FA',
|
||||
'Creating API keys',
|
||||
'Connecting MCP clients',
|
||||
'Connecting your own AI model',
|
||||
])
|
||||
expect(screen.getByText(line)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-003: shows the highlights to try and what the full version adds', () => {
|
||||
render(<DemoWelcome />);
|
||||
for (const title of [
|
||||
'Trip planner',
|
||||
'Tours and hikes',
|
||||
'Photo books in TREK Studio',
|
||||
'Plan together live',
|
||||
'Journey travel journal',
|
||||
'Atlas world map',
|
||||
'Vacay holiday planner',
|
||||
'Collections',
|
||||
'Works offline',
|
||||
])
|
||||
expect(screen.getByText(title)).toBeInTheDocument();
|
||||
for (const line of ['Sign in with OIDC and SSO', 'Automatic backups', 'Plugins for new features'])
|
||||
expect(screen.getByText(line)).toBeInTheDocument();
|
||||
expect(screen.getByText('The AI assistant on your own model')).toBeInTheDocument();
|
||||
expect(screen.getByText('An admin panel for users, addons and permissions')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-004: links to the repository in a new tab', () => {
|
||||
render(<DemoWelcome />);
|
||||
const link = screen.getByRole('link', { name: 'Source code on GitHub' });
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-005: the countdown runs live in the server zone', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||
// On a whole second, so every tick of the interval lands on one too.
|
||||
vi.setSystemTime(new Date(Date.UTC(2026, 9, 24, 12, 17, 53)));
|
||||
render(<DemoWelcome />);
|
||||
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:07');
|
||||
|
||||
// Advancing the fake timers moves the fake clock with them: the fifth tick reads 12:17:58.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
expect(screen.getByTestId('demo-countdown')).toHaveTextContent('42:02');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-006: the button closes it', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Start exploring' }));
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-007: Escape closes it too', () => {
|
||||
render(<DemoWelcome />);
|
||||
fireEvent.keyDown(document, { key: 'Escape' });
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-008: the photo credits open in its place and the back button returns', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
|
||||
|
||||
const list = await screen.findByRole('list', { name: 'Photo credits' }, { timeout: 5000 });
|
||||
expect(within(list).getAllByRole('listitem')).toHaveLength(228);
|
||||
expect(screen.getByText(/come from Wikimedia Commons/)).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Photo credits' })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Everything resets every hour')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog', { name: 'Live demo' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Back' })).toHaveFocus();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Back' }));
|
||||
expect(screen.getByText('Everything resets every hour')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('list', { name: 'Photo credits' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Photo credits' })).toHaveFocus();
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-009: a photo names its title, author and license, each linking to its source', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
|
||||
|
||||
const title = await screen.findByRole('link', { name: 'Lagoa Rasa (Sete Cidades) 2' }, { timeout: 5000 });
|
||||
expect(title).toHaveAttribute('href', 'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg');
|
||||
expect(title).toHaveAttribute('target', '_blank');
|
||||
expect(title).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
const row = within(title.closest('li') as HTMLElement);
|
||||
expect(row.getByText('by Walmaul')).toBeInTheDocument();
|
||||
const license = row.getByRole('link', { name: 'CC BY-SA 4.0' });
|
||||
expect(license).toHaveAttribute('href', 'https://creativecommons.org/licenses/by-sa/4.0/');
|
||||
expect(license).toHaveAttribute('target', '_blank');
|
||||
expect(license).toHaveAttribute('rel', 'noopener noreferrer');
|
||||
});
|
||||
|
||||
it('FE-COMP-DEMOWELCOME-010: a public domain photo names it without a deed, and a cleaned author reads as a name', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoWelcome />);
|
||||
await user.click(screen.getByRole('button', { name: 'Photo credits' }));
|
||||
|
||||
const abiskoTitle = await screen.findByRole('link', { name: 'AbiskoNationalpark01' }, { timeout: 5000 });
|
||||
const abisko = within(abiskoTitle.closest('li') as HTMLElement);
|
||||
expect(abisko.getByText('Public domain')).toBeInTheDocument();
|
||||
expect(abisko.getAllByRole('link')).toHaveLength(1);
|
||||
|
||||
const zinnen = within(
|
||||
screen.getByRole('link', { name: 'Drei Zinnen Tre Cime di Lavaredo Dolomites' }).closest('li') as HTMLElement
|
||||
);
|
||||
expect(zinnen.getByText('by Wolfgang Moroder')).toBeInTheDocument();
|
||||
expect(zinnen.getByRole('link', { name: 'CC BY-SA 3.0' })).toHaveAttribute(
|
||||
'href',
|
||||
'https://creativecommons.org/licenses/by-sa/3.0/'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { ArrowLeft, ArrowRight, Clock, ExternalLink, Images, RotateCcw } from 'lucide-react';
|
||||
import { lazy, Suspense, useId } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import {
|
||||
DialogButton,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogSection,
|
||||
DialogShell,
|
||||
DialogTile,
|
||||
FooterSpacer,
|
||||
fs,
|
||||
NEUTRAL_TINT,
|
||||
PILL,
|
||||
} from '../shared/DialogShell';
|
||||
import ErrorBoundary from '../shared/ErrorBoundary';
|
||||
import TrekIcon from '../shared/TrekIcon';
|
||||
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, TREK_REPO_URL, type DemoLine } from './demoWelcomeModel';
|
||||
import { useDemoWelcome } from './useDemoWelcome';
|
||||
|
||||
// The credits and their photo list load when a visitor opens them, not with the dashboard.
|
||||
const DemoCredits = lazy(() => import('./DemoCredits'));
|
||||
|
||||
type Translate = ReturnType<typeof useTranslation>['t'];
|
||||
|
||||
const FOOTER_LINK = 'inline-flex items-center gap-1.5 font-medium text-content-muted no-underline hover:text-content';
|
||||
|
||||
/** The tiles: what a visitor can open right now, one card each. */
|
||||
function FeatureGrid({ t }: { t: Translate }) {
|
||||
return (
|
||||
<div className="grid gap-2.5 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{DEMO_FEATURES.map(({ id, icon: Icon, titleKey, bodyKey }) => (
|
||||
<div key={id} className="flex gap-3 rounded-[16px] border border-edge-faint bg-surface-secondary p-3.5">
|
||||
<span className="grid h-9 w-9 flex-none place-items-center rounded-[12px] bg-surface-card text-content shadow-sm">
|
||||
<Icon size={17} strokeWidth={2} aria-hidden />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{t(titleKey)}
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-muted" style={fs(12, 'body')}>
|
||||
{t(bodyKey)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** A short list with an icon per line, for what the demo leaves out and what the full version adds. */
|
||||
function LineList({ lines, tone, t }: { lines: readonly DemoLine[]; tone: 'muted' | 'accent'; t: Translate }) {
|
||||
const iconClass = tone === 'accent' ? 'text-success' : 'text-content-faint';
|
||||
return (
|
||||
<ul className="m-0 flex list-none flex-col gap-2 rounded-[16px] border border-edge-faint bg-surface-card p-3.5">
|
||||
{lines.map(({ icon: Icon, labelKey }) => (
|
||||
<li key={labelKey} className="flex items-center gap-2.5 text-content-secondary" style={fs(12.5, 'body')}>
|
||||
<Icon size={15} strokeWidth={2} className={`flex-none ${iconClass}`} aria-hidden />
|
||||
<span>{t(labelKey)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
/** The welcome itself: the hourly reset, the tiles, and the two lists under them. */
|
||||
function WelcomeBody({ t }: { t: Translate }) {
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-start gap-3 rounded-[16px] bg-warning-soft px-4 py-3">
|
||||
<RotateCcw size={17} strokeWidth={2.2} className="mt-0.5 flex-none text-warning" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<div className="font-semibold text-content" style={fs(13.5, 'body')}>
|
||||
{t('demo.welcome.resetTitle')}
|
||||
</div>
|
||||
<p className="m-0 mt-0.5 leading-snug text-content-secondary" style={fs(12.5, 'body')}>
|
||||
{t('demo.welcome.resetBody')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogSection label={t('demo.welcome.tryTitle')}>
|
||||
<FeatureGrid t={t} />
|
||||
</DialogSection>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<DialogSection label={t('demo.welcome.limitsTitle')}>
|
||||
<LineList lines={DEMO_LIMITS} tone="muted" t={t} />
|
||||
</DialogSection>
|
||||
<DialogSection label={t('demo.welcome.fullTitle')}>
|
||||
<LineList lines={DEMO_FULL_VERSION} tone="accent" t={t} />
|
||||
</DialogSection>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's links: the source code, and the credits for the demo's photos.
|
||||
* One above the other on a phone, where the footer has no room for both
|
||||
* beside the button.
|
||||
*/
|
||||
function FooterLinks({ t, onCredits, focusCredits }: { t: Translate; onCredits: () => void; focusCredits: boolean }) {
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col items-start gap-1.5 sm:flex-row sm:items-center sm:gap-4">
|
||||
<a
|
||||
href={TREK_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={FOOTER_LINK}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<ExternalLink size={15} strokeWidth={2} aria-hidden />
|
||||
{t('demo.welcome.repo')}
|
||||
</a>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCredits}
|
||||
autoFocus={focusCredits}
|
||||
className={FOOTER_LINK}
|
||||
style={fs(12.5, 'body')}
|
||||
>
|
||||
<Images size={15} strokeWidth={2} aria-hidden />
|
||||
{t('demo.welcome.credits')}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The welcome a demo instance opens with: what TREK is, that the demo resets
|
||||
* on the hour (with the live countdown), what to try right away, what the
|
||||
* demo leaves out and what the full version adds. The photo credits open in
|
||||
* its place and back. Closed by the button, Escape or a click on the
|
||||
* backdrop, and shown again on the next visit.
|
||||
*/
|
||||
export default function DemoWelcome() {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const { open, close, countdown, creditsOpen, backFromCredits, openCredits, closeCredits } = useDemoWelcome();
|
||||
|
||||
return (
|
||||
<DialogShell
|
||||
open={open}
|
||||
onClose={close}
|
||||
labelledBy={labelId}
|
||||
width="wide"
|
||||
header={
|
||||
<DialogHeader
|
||||
tile={
|
||||
<DialogTile>
|
||||
<TrekIcon className="h-7 w-7 text-content" />
|
||||
</DialogTile>
|
||||
}
|
||||
tint={NEUTRAL_TINT}
|
||||
labelId={labelId}
|
||||
onClose={close}
|
||||
eyebrow={t('demo.welcome.eyebrow')}
|
||||
title={t(creditsOpen ? 'demo.credits.title' : 'demo.welcome.title')}
|
||||
sub={creditsOpen ? undefined : t('demo.welcome.tagline')}
|
||||
subWraps
|
||||
pills={
|
||||
<span className={PILL}>
|
||||
<Clock size={13} strokeWidth={2.2} aria-hidden />
|
||||
{t('demo.welcome.resetIn')}
|
||||
<span className="font-geist tabular-nums" data-testid="demo-countdown">
|
||||
{countdown}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
}
|
||||
footer={
|
||||
<DialogFooter>
|
||||
{/* The start button keeps its place in both views, so switching does not move the focus onto it again. */}
|
||||
{creditsOpen ? (
|
||||
<DialogButton
|
||||
onClick={closeCredits}
|
||||
autoFocus
|
||||
icon={<ArrowLeft size={15} strokeWidth={2.2} className="rtl:-scale-x-100" />}
|
||||
>
|
||||
{t('demo.credits.back')}
|
||||
</DialogButton>
|
||||
) : (
|
||||
<FooterLinks t={t} onCredits={openCredits} focusCredits={backFromCredits} />
|
||||
)}
|
||||
<FooterSpacer />
|
||||
<DialogButton variant="primary" onClick={close} autoFocus icon={<ArrowRight size={15} strokeWidth={2.2} />}>
|
||||
{t('demo.welcome.start')}
|
||||
</DialogButton>
|
||||
</DialogFooter>
|
||||
}
|
||||
>
|
||||
{creditsOpen ? (
|
||||
<ErrorBoundary boundaryId="demo-credits">
|
||||
<Suspense fallback={null}>
|
||||
<DemoCredits />
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
) : (
|
||||
<WelcomeBody t={t} />
|
||||
)}
|
||||
</DialogShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { PHOTO_CREDITS, PUBLIC_DOMAIN, commonsPage, creditTitle, licenseDeed } from './demoCreditsModel';
|
||||
|
||||
describe('demo photo credits', () => {
|
||||
it('FE-DEMO-CREDITS-001: credits every photo of the demo content, each file once', () => {
|
||||
expect(PHOTO_CREDITS).toHaveLength(228);
|
||||
expect(new Set(PHOTO_CREDITS.map((c) => c.file)).size).toBe(228);
|
||||
for (const { file } of PHOTO_CREDITS) expect(file, file).toMatch(/\.jpe?g$/i);
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-002: every author is a plain name, with no Commons notice or link label left', () => {
|
||||
for (const { file, author } of PHOTO_CREDITS) {
|
||||
expect(author.trim(), file).not.toBe('');
|
||||
expect(author, file).toBe(author.trim());
|
||||
expect(author.length, `${file}: ${author}`).toBeLessThanOrEqual(60);
|
||||
expect(author, file).not.toMatch(/\s{2,}|\n/);
|
||||
expect(author, file).not.toMatch(
|
||||
/Photo was taken|machine-readable|assumed|Feel free|copyright|https?:|User:|\((?:Website|Flickr)\)/i
|
||||
);
|
||||
}
|
||||
const authorOf = (file: string) => PHOTO_CREDITS.find((c) => c.file === file)?.author;
|
||||
expect(authorOf('Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg')).toBe('Wolfgang Moroder');
|
||||
expect(authorOf('Iceland Godafoss 1972.jpg')).toBe('Roger McLassus');
|
||||
expect(authorOf('Arashiyama Bamboo Grove (Unsplash).jpg')).toBe('Erol Ahmed');
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-003: every license has its deed, except the public domain', () => {
|
||||
for (const { file, license } of PHOTO_CREDITS) {
|
||||
if (license === PUBLIC_DOMAIN) expect(licenseDeed(license), file).toBeUndefined();
|
||||
else expect(licenseDeed(license), `${file}: ${license}`).toMatch(/^https:\/\/creativecommons\.org\/.+\/$/);
|
||||
}
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-004: derives the deed from the license name', () => {
|
||||
expect(licenseDeed('CC BY-SA 4.0')).toBe('https://creativecommons.org/licenses/by-sa/4.0/');
|
||||
expect(licenseDeed('CC BY 2.5')).toBe('https://creativecommons.org/licenses/by/2.5/');
|
||||
expect(licenseDeed('CC BY-SA 2.0 de')).toBe('https://creativecommons.org/licenses/by-sa/2.0/de/');
|
||||
expect(licenseDeed('CC0')).toBe('https://creativecommons.org/publicdomain/zero/1.0/');
|
||||
expect(licenseDeed(PUBLIC_DOMAIN)).toBeUndefined();
|
||||
expect(licenseDeed('GFDL')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('FE-DEMO-CREDITS-005: shows the title without its extension and links the page on Commons', () => {
|
||||
expect(creditTitle('Café A Brasileira. Sculpture.jpg')).toBe('Café A Brasileira. Sculpture');
|
||||
expect(creditTitle('Svínafellsjökull.JPG')).toBe('Svínafellsjökull');
|
||||
expect(creditTitle('No extension')).toBe('No extension');
|
||||
expect(commonsPage('Lagoa Rasa (Sete Cidades) 2.jpg')).toBe(
|
||||
'https://commons.wikimedia.org/wiki/File:Lagoa_Rasa_(Sete_Cidades)_2.jpg'
|
||||
);
|
||||
expect(commonsPage('Glacier du Géant & Mont Blanc.jpg')).toBe(
|
||||
'https://commons.wikimedia.org/wiki/File:Glacier_du_G%C3%A9ant_%26_Mont_Blanc.jpg'
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import credits from './photoCredits.json';
|
||||
|
||||
/**
|
||||
* One photo of the demo content as Wikimedia Commons credits it: the file's
|
||||
* name there, its author and the short name of its license. The page on
|
||||
* Commons, the title to show and the license's deed all follow from these, so
|
||||
* photoCredits.json holds nothing else.
|
||||
*/
|
||||
export interface PhotoCredit {
|
||||
file: string;
|
||||
author: string;
|
||||
license: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every photo in the demo content, sorted by file name. The authors are the
|
||||
* plain names: where Commons fills the field with a notice or a link label
|
||||
* ("This Photo was taken by ...", "(Flickr)"), the list holds the name it
|
||||
* gives, and where the author asks to be credited in a set form, that form.
|
||||
*/
|
||||
export const PHOTO_CREDITS: readonly PhotoCredit[] = credits;
|
||||
|
||||
/** The license name Commons gives a photo nobody holds a copyright in; it has no deed to link. */
|
||||
export const PUBLIC_DOMAIN = 'Public domain';
|
||||
|
||||
const COMMONS_FILE_PAGE = 'https://commons.wikimedia.org/wiki/File:';
|
||||
|
||||
/** The file's name without its extension, the title Commons shows above the photo. */
|
||||
export function creditTitle(file: string): string {
|
||||
const dot = file.lastIndexOf('.');
|
||||
return dot > 0 ? file.slice(0, dot) : file;
|
||||
}
|
||||
|
||||
/** The file's page on Commons, with its full description and license. */
|
||||
export function commonsPage(file: string): string {
|
||||
return COMMONS_FILE_PAGE + encodeURIComponent(file.replace(/ /g, '_'));
|
||||
}
|
||||
|
||||
/**
|
||||
* The deed of a Creative Commons license by the short name Commons gives it:
|
||||
* "CC BY-SA 4.0", a ported one such as "CC BY-SA 2.0 de", or "CC0". Nothing
|
||||
* for the public domain, which is no license.
|
||||
*/
|
||||
export function licenseDeed(license: string): string | undefined {
|
||||
if (license === 'CC0') return 'https://creativecommons.org/publicdomain/zero/1.0/';
|
||||
const match = /^CC (BY(?:-SA)?) (\d\.\d)(?: ([a-z]{2}))?$/.exec(license);
|
||||
if (!match) return undefined;
|
||||
const [, terms, version, port] = match;
|
||||
return `https://creativecommons.org/licenses/${terms.toLowerCase()}/${version}/${port ? `${port}/` : ''}`;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import en from '@trek/shared/i18n/en';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DEMO_FEATURES, DEMO_FULL_VERSION, DEMO_LIMITS, formatCountdown, secondsUntilReset } from './demoWelcomeModel';
|
||||
|
||||
describe('demo welcome model', () => {
|
||||
it('FE-DEMO-MODEL-001: every tile and line names a key the English catalogue holds', () => {
|
||||
const keys = [
|
||||
...DEMO_FEATURES.flatMap((f) => [f.titleKey, f.bodyKey]),
|
||||
...DEMO_LIMITS.map((l) => l.labelKey),
|
||||
...DEMO_FULL_VERSION.map((l) => l.labelKey),
|
||||
];
|
||||
for (const key of keys) expect(Object.keys(en), key).toContain(key);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-002: shows the highlights a visitor can open, each once', () => {
|
||||
expect(DEMO_FEATURES.map((f) => f.id)).toEqual([
|
||||
'planner',
|
||||
'tours',
|
||||
'studio',
|
||||
'collab',
|
||||
'journey',
|
||||
'atlas',
|
||||
'vacay',
|
||||
'collections',
|
||||
'offline',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-003: names everything the demo account cannot do', () => {
|
||||
expect(DEMO_LIMITS.map((l) => l.labelKey)).toEqual([
|
||||
'demo.limit.uploads',
|
||||
'demo.limit.account',
|
||||
'demo.limit.apiKeys',
|
||||
'demo.limit.mcp',
|
||||
'demo.limit.ai',
|
||||
]);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-004: counts down to the next full hour in the server zone', () => {
|
||||
const at = new Date(Date.UTC(2026, 9, 24, 12, 17, 53));
|
||||
expect(secondsUntilReset(at, 'UTC')).toBe(42 * 60 + 7);
|
||||
// A zone half an hour off UTC has its hour mark at half past, UTC time.
|
||||
expect(secondsUntilReset(at, 'Asia/Kolkata')).toBe(12 * 60 + 7);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-005: falls back to the browser clock for a zone it does not know, or none', () => {
|
||||
const at = new Date(2026, 9, 24, 12, 59, 30);
|
||||
expect(secondsUntilReset(at, 'Not/AZone')).toBe(30);
|
||||
expect(secondsUntilReset(at)).toBe(30);
|
||||
expect(secondsUntilReset(new Date(2026, 9, 24, 13, 0, 0))).toBe(3600);
|
||||
});
|
||||
|
||||
it('FE-DEMO-MODEL-006: formats the countdown as minutes and seconds', () => {
|
||||
expect(formatCountdown(42 * 60 + 7)).toBe('42:07');
|
||||
expect(formatCountdown(5)).toBe('00:05');
|
||||
expect(formatCountdown(3600)).toBe('60:00');
|
||||
expect(formatCountdown(-3)).toBe('00:00');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import {
|
||||
BookImage,
|
||||
Bookmark,
|
||||
Bot,
|
||||
CalendarDays,
|
||||
DatabaseBackup,
|
||||
Globe,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
Map as MapIcon,
|
||||
Mountain,
|
||||
NotebookPen,
|
||||
Puzzle,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
Upload,
|
||||
UserLock,
|
||||
Users,
|
||||
WifiOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
/** One thing a visitor can try in the demo right away: a tile of the welcome dialog. */
|
||||
interface DemoFeature {
|
||||
id: string;
|
||||
icon: LucideIcon;
|
||||
titleKey: string;
|
||||
bodyKey: string;
|
||||
}
|
||||
|
||||
/** A line of the two lists under the tiles: what the demo leaves out, and what the full version adds. */
|
||||
export interface DemoLine {
|
||||
icon: LucideIcon;
|
||||
labelKey: string;
|
||||
}
|
||||
|
||||
export const DEMO_FEATURES: readonly DemoFeature[] = [
|
||||
{ id: 'planner', icon: MapIcon, titleKey: 'demo.feature.planner.title', bodyKey: 'demo.feature.planner.body' },
|
||||
{ id: 'tours', icon: Mountain, titleKey: 'demo.feature.tours.title', bodyKey: 'demo.feature.tours.body' },
|
||||
{ id: 'studio', icon: BookImage, titleKey: 'demo.feature.studio.title', bodyKey: 'demo.feature.studio.body' },
|
||||
{ id: 'collab', icon: Users, titleKey: 'demo.feature.collab.title', bodyKey: 'demo.feature.collab.body' },
|
||||
{ id: 'journey', icon: NotebookPen, titleKey: 'demo.feature.journey.title', bodyKey: 'demo.feature.journey.body' },
|
||||
{ id: 'atlas', icon: Globe, titleKey: 'demo.feature.atlas.title', bodyKey: 'demo.feature.atlas.body' },
|
||||
{ id: 'vacay', icon: CalendarDays, titleKey: 'demo.feature.vacay.title', bodyKey: 'demo.feature.vacay.body' },
|
||||
{
|
||||
id: 'collections',
|
||||
icon: Bookmark,
|
||||
titleKey: 'demo.feature.collections.title',
|
||||
bodyKey: 'demo.feature.collections.body',
|
||||
},
|
||||
{ id: 'offline', icon: WifiOff, titleKey: 'demo.feature.offline.title', bodyKey: 'demo.feature.offline.body' },
|
||||
];
|
||||
|
||||
/** What the shared demo account cannot do; the server refuses each of them too. */
|
||||
export const DEMO_LIMITS: readonly DemoLine[] = [
|
||||
{ icon: Upload, labelKey: 'demo.limit.uploads' },
|
||||
{ icon: UserLock, labelKey: 'demo.limit.account' },
|
||||
{ icon: KeyRound, labelKey: 'demo.limit.apiKeys' },
|
||||
{ icon: Terminal, labelKey: 'demo.limit.mcp' },
|
||||
{ icon: Sparkles, labelKey: 'demo.limit.ai' },
|
||||
];
|
||||
|
||||
export const DEMO_FULL_VERSION: readonly DemoLine[] = [
|
||||
{ icon: ShieldCheck, labelKey: 'demo.full.sso' },
|
||||
{ icon: DatabaseBackup, labelKey: 'demo.full.backups' },
|
||||
{ icon: Puzzle, labelKey: 'demo.full.plugins' },
|
||||
{ icon: Bot, labelKey: 'demo.full.assistant' },
|
||||
{ icon: LayoutDashboard, labelKey: 'demo.full.admin' },
|
||||
];
|
||||
|
||||
export const TREK_REPO_URL = 'https://github.com/liketrek/TREK';
|
||||
|
||||
/** The demo resets on the hour (the server's `0 * * * *` cron). */
|
||||
const HOUR_SECONDS = 3600;
|
||||
|
||||
/**
|
||||
* Minute and second of the hour at `now` in the server's time zone, where the
|
||||
* reset cron runs. A zone whose offset is not whole hours (India, Nepal) moves
|
||||
* the hour mark; an unknown zone falls back to the browser's clock.
|
||||
*/
|
||||
function minuteAndSecond(now: Date, timeZone: string | undefined): [number, number] {
|
||||
if (timeZone) {
|
||||
try {
|
||||
const parts = new Intl.DateTimeFormat('en-US', { timeZone, minute: 'numeric', second: 'numeric' }).formatToParts(
|
||||
now
|
||||
);
|
||||
const read = (type: 'minute' | 'second') => Number(parts.find((p) => p.type === type)?.value);
|
||||
const minute = read('minute');
|
||||
const second = read('second');
|
||||
if (Number.isFinite(minute) && Number.isFinite(second)) return [minute, second];
|
||||
} catch {
|
||||
// A zone this browser does not know: the local clock below.
|
||||
}
|
||||
}
|
||||
return [now.getMinutes(), now.getSeconds()];
|
||||
}
|
||||
|
||||
/** Seconds until the next hourly reset, from 1 up to 3600. */
|
||||
export function secondsUntilReset(now: Date, timeZone?: string): number {
|
||||
const [minute, second] = minuteAndSecond(now, timeZone);
|
||||
return HOUR_SECONDS - (minute * 60 + second);
|
||||
}
|
||||
|
||||
/** The countdown as minutes and seconds, `42:07`. */
|
||||
export function formatCountdown(seconds: number): string {
|
||||
const safe = Math.max(0, Math.floor(seconds));
|
||||
const minutes = Math.floor(safe / 60);
|
||||
const rest = safe % 60;
|
||||
return `${String(minutes).padStart(2, '0')}:${String(rest).padStart(2, '0')}`;
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
[
|
||||
{
|
||||
"file": "(Venice) - Fondamente zattere - Casa Scarpa.jpg",
|
||||
"author": "Didier Descouens",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 03.jpg",
|
||||
"author": "Holger Uwe Schmitt",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "+Die Hallgrimskirkja ist das markanteste Bauwerk Reykjaviks. 05.jpg",
|
||||
"author": "Holger Uwe Schmitt",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "021 Wild smiling harbor seal at Jökulsárlón (Iceland) Photo by Giles Laurent.jpg",
|
||||
"author": "Giles Laurent",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "03613 ISL Akureyri cruise vessel vapors V P.jpg", "author": "Virtual-Pano", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "20181110 Fushimi Inari shrine 3.jpg", "author": "Balon Greyjoy", "license": "CC0" },
|
||||
{ "file": "20181110 Fushimi Inari shrine 4.jpg", "author": "Balon Greyjoy", "license": "CC0" },
|
||||
{ "file": "20181110 Fushimi Inari shrine 6.jpg", "author": "Balon Greyjoy", "license": "CC0" },
|
||||
{ "file": "2019-08-01 Zugspitze 7.jpg", "author": "Maksym Kozlenko", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "2022-05-28 13-21-39 Heinabergslón and Heinabergsjökull.jpg",
|
||||
"author": "Hansueli Krapf",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "2022-05-28 13-26-26 Island - Hoffell 118.0.jpg", "author": "Hansueli Krapf", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "2022-05-28 13-50-35 Panorama of Heinabergsjökull.jpg",
|
||||
"author": "Hansueli Krapf",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "2023-11-04 Lycian Way near Kaş.jpg", "author": "Alexkom000", "license": "CC BY 4.0" },
|
||||
{ "file": "AbiskoNationalpark01.jpg", "author": "Unterrichter at English Wikipedia", "license": "Public domain" },
|
||||
{ "file": "Almannagjá gorge at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Almannagjá rift at Þingvellir National Park.jpg", "author": "Olga Ernst", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Arashiyama Bamboo Grove (Unsplash).jpg", "author": "Erol Ahmed", "license": "CC0" },
|
||||
{ "file": "Arashiyama Bamboo Grove.jpg", "author": "Mitchwandrew", "license": "CC BY 4.0" },
|
||||
{ "file": "Arashiyama-Bamboo-Grove-Sunset.jpg", "author": "Bjørn Christian Tørrissen", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "ArashiyamaTogetsukyo Kyoto.jpg", "author": "Kentagon", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Arnarstapi (port).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{ "file": "Arnarstapi 2.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
|
||||
{ "file": "Arnarstapi 4.jpg", "author": "Christian Bickel", "license": "CC BY-SA 2.0 de" },
|
||||
{
|
||||
"file": "Averau from Fedare at Passo Giau, Belluno, Veneto, Italy, 2025 October.jpg",
|
||||
"author": "Simo Räsänen",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Bamboo Forest, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Bamboo Grove, Arashiyama, Kyoto, Japan.jpg", "author": "Basile Morin", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Basalt Arch near Arnarstapi.jpg", "author": "Remarksman", "license": "CC BY 2.0" },
|
||||
{
|
||||
"file": "Basilica di Santa Maria della Salute, Venice (37770336971).jpg",
|
||||
"author": "Dimitris Kamaras from Athens, Greece",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Black Sand Beach Iceland, Reynisfjara Beach.jpg", "author": "miketnorton", "license": "CC BY 2.0" },
|
||||
{ "file": "Bubbly Diamond on the Beach - Iceland.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "Burano Hausfassade Wäsche-20090315-RM-113017.jpg",
|
||||
"author": "Reinhold Möller",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Burano Hausfassade Wäsche-20090315-RM-113043.jpg",
|
||||
"author": "Reinhold Möller",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Burano Vendig Aug 2021 4.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Café A Brasileira. Sculpture.jpg", "author": "MFREYNAUD51", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Café de Flore, 172 boulevard Saint-Germain, Paris 6e.jpg",
|
||||
"author": "Celette",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Café Majestic - Porto - Portugal (53283088293).jpg",
|
||||
"author": "Vitor Oliveira from Torres Vedras, PORTUGAL",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Café Sperl, Wien.jpg", "author": "Jeremy Thompson", "license": "CC BY 2.0" },
|
||||
{ "file": "Caffè San Marco-Trieste.jpg", "author": "Tiesse", "license": "Public domain" },
|
||||
{ "file": "Caffè Sant'Eustachio.JPG", "author": "LPLT", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Canale Grande in the morning light - panoramio.jpg", "author": "Schölla Schwarz", "license": "CC BY 3.0" },
|
||||
{ "file": "Cinque Torri 01.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Cinque Torri 02.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Cinque Torri 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Cinque Torri, Dolomites - panoramio (1).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
|
||||
{ "file": "Cinque Torri, Dolomites - panoramio (2).jpg", "author": "Petr Kraumann", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Colourful houses in Corte della Comare, Burano (detail).jpg",
|
||||
"author": "Marco Ober",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Croda del Becco and Rifugio Biella 202008.jpg", "author": "Marie-Lan Taÿ Pamart", "license": "CC BY 4.0" },
|
||||
{
|
||||
"file": "Cuernos del Paine in Torres del Paine National Park.jpg",
|
||||
"author": "Pedro Szekely from Los Angeles, USA",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Dettifoss 2026 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dettifoss Panorama 01.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dettifoss Panorama 02.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dettifoss Panorama 03.jpg", "author": "Spike", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Dorsoduro Rio dei Carmini in Venice.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Dotonbori Osaka Japan01-r.jpg", "author": "Oilstreet", "license": "CC BY 2.5" },
|
||||
{ "file": "Dotonbori, Osaka, at night, November 2016.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Drei Zinnen Tre Cime di Lavaredo Dolomites.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Dyrholaey Arch.jpg", "author": "Eric Kilby", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "Dyrhólaey, seen from Reynisfjara, Iceland, 20240720 0831 2824.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{ "file": "Eibsee und Ammergauer Alpen.jpg", "author": "Octagon", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Ëur de Bredles Secëda Odles y Sas Rigais da Resciesa.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Faloria Cortina d'Ampezzo 10.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Falzarego-N.jpg", "author": "Supakon Mohr", "license": "CC BY-SA 3.0 de" },
|
||||
{ "file": "Fimmvörðuháls (1).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{ "file": "Fimmvörðuháls (9).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Fimmvorduhals-emergency-hut.jpg",
|
||||
"author": "Reen Eversdijk from Groningen, The Netherlands",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Fjaðrárgljúfur 04.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Forcella Nuvolau–Nuvolau lodge trail.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Four ladies wearing a yukata in front of the North Gate of Kiyomizu-dera temple Kyoto Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Frea pass Sella group Pisciadu Dolomites South Tyrol.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Funivia del Lagazuoi (passo Falzarego).jpg", "author": "Ale041VE", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Gion Hanamikôji-dôri.jpg", "author": "Yanajin33", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Glacier du Mont Mallet (Glacier du Géant) & Mont Blanc, 2010 July.jpg",
|
||||
"author": "Ximonic, Simo Räsänen",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Godafoss 2.jpg", "author": "Tord Dellsen", "license": "CC0" },
|
||||
{ "file": "Godafoss 3.jpg", "author": "Tord Dellsen", "license": "CC0" },
|
||||
{ "file": "Godafoss 4.jpg", "author": "Tord Dellsen", "license": "CC0" },
|
||||
{ "file": "Goðafoss Island.jpg", "author": "Superbass", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Gondola Guido Lorenzi - Son Forca Cristallo Italy - panoramio.jpg",
|
||||
"author": "Hiroki Ogawa",
|
||||
"license": "CC BY 3.0"
|
||||
},
|
||||
{ "file": "GR20 Corsica Montains-2.jpg", "author": "Hermes from mars", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Grand Canal Santa Maria della Salute Vaporetto Gondola Venice 01.jpg",
|
||||
"author": "Julian Herzog",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Gullfoss, Suðurland, Islandia, 2014-08-16, DD 119.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Hallgrímskirkja 1.jpg", "author": "Dmitry Brant", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Heinabergsjökull (38322450122).jpg", "author": "Markus Trienke", "license": "CC BY-SA 2.0" },
|
||||
{ "file": "Hverfjall July 2014.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Hverir Námaskarð.jpg", "author": "JavierOlivares", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Iceland Dettifoss 1972-4.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Iceland Godafoss 1972.jpg", "author": "Roger McLassus", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Il Sentiero degli dei.jpg", "author": "Stiflele", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Illusion, Burano Island. (39893205060).jpg",
|
||||
"author": "Mihai Lucîț from Baia Mare, Romania",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Islanda- Þórsmörk.jpg", "author": "Mario Falcetti", "license": "CC BY 4.0" },
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0935 2448.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0936 2452.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0937 2454.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Jökulsárlón glacier lagoon, Iceland, 20240719 0947 2477.jpg",
|
||||
"author": "Jakub Hałun",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Kansai Airport (2660200665).jpg",
|
||||
"author": "Kentaro Iemoto from Tokyo, Japan",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Kinkaku-ji in November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Kinkaku-ji the Golden Temple in Kyoto overlooking the lake - high rez.JPG",
|
||||
"author": "Jaycangel",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Kirkjufell in Fog (Unsplash).jpg", "author": "Nicholas Kampouris", "license": "CC0" },
|
||||
{ "file": "Kirkjufell with its reflection.jpg", "author": "_大面", "license": "CC BY 2.5" },
|
||||
{ "file": "Kirkjufell, Iceland (Unsplash LoKG7kKx7Zc).jpg", "author": "Steven Diaz", "license": "CC0" },
|
||||
{
|
||||
"file": "Kirkjufell, Vesturland, Islandia, 2014-08-14, DD 091.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -01.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -02.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -03.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -06.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Kiyomizu-dera, Kyoto, November 2016 -07.jpg", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "KyotoFushimiInariLarge.jpg", "author": "Paul Vlaar", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Lagazuoi 05.jpg", "author": "Clemens Stockner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago alla base di Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago alla base di Svínafellsjökull 02.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 3.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 5.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 6.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 7.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lago di Braies South Tyrol 9.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Lago di Limo, Parco Naturale Fanes - Sennes - Braies (BZ).jpg",
|
||||
"author": "53cri021",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Lagoa Rasa (Sete Cidades) 2.jpg", "author": "Walmaul", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Lake Ashi & Mt Fuji & Hakone Shrine.jpg", "author": "Charlie fong", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Landmannalaugar in summer 2009 (13).jpg", "author": "Chmee2/Valtameri", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Manarola NW Cemetery Corniglia Monterosso Cinque Terre Sep23 A7C 06872.jpg",
|
||||
"author": "Timothy A. Gonsalves",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Meiji Jingu Shrine Tokyo Japan.jpg", "author": "MediaByPanda", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Meiji shrine in August 2007 5.jpg", "author": "Syced", "license": "CC0" },
|
||||
{ "file": "Meiji Shrine, Tokyo; April 2009 (02).jpg", "author": "MIKI Yoshihito", "license": "CC BY 2.0" },
|
||||
{
|
||||
"file": "Meiji-jingū main hall vue centrale.jpg",
|
||||
"author": "Hyppolyte de Saint-Rambert",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Meiji-jingu-pathway.jpg", "author": "Nightcrafter", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Monte Kirkjufell (Luglio 2024).jpg", "author": "Paolova97", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Morning light in Venice (33726527456).jpg", "author": "Hervé Simon", "license": "CC BY-SA 2.0" },
|
||||
{ "file": "Mount Fuji and Shinkansen N700.jpg", "author": "Alpsdake", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Mount Myojingatake and Owakudani Station from Owakudani Valley.JPG",
|
||||
"author": "そらみみ",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Mount-Fuji from Tokaido Shinkansen window view.jpg", "author": "Indiana jo", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Mountain and grotto at Reynisfjara Beach in July, 2019.jpg",
|
||||
"author": "Alexander Grebenkov",
|
||||
"license": "CC BY 3.0"
|
||||
},
|
||||
{ "file": "Múlafossur2.jpg", "author": "Ekrem Canli", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Neon Signs in Dotonbori, Osaka 20190415 1.jpg", "author": "DXR", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Neunerspitze, 2968 m und Zehnerspitze 3016 m.jpg",
|
||||
"author": "2015 Michael 2015",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "NeunerZehnerFanes.JPG", "author": "Alex1011", "license": "CC BY 2.5" },
|
||||
{ "file": "Nishiki ichiba kyoto.jpg", "author": "Pitan", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Old Tokaido - Hakone.jpg", "author": "Quercus acuta", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Old Tokaido -01.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "On the hillside above Ginkaku-ji (2099332601).jpg", "author": "KimonBerlin", "license": "CC BY-SA 2.0" },
|
||||
{ "file": "Osaka Dotonbori Canal.jpg", "author": "32linesky", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 1.JPG",
|
||||
"author": "そらみみ",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{
|
||||
"file": "Owakudani Valley and Mount Kammurigatake near Owakudani Station 2.JPG",
|
||||
"author": "そらみみ",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Owakudani Valley near Owakudani Station 2.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Owakudani Valley near Owakudani Station.JPG", "author": "そらみみ", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Palazzo Balbi (Venice).jpg", "author": "Didier Descouens", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Palazzo dei Camerlenghi e Ponte Rialto a Venezia.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{
|
||||
"file": "Panorama of Canal Grande and Ponte di Rialto, Venice - September 2017.jpg",
|
||||
"author": "Martin Falbisoner",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Passo di Giau.jpg", "author": "Frisia Orientalis", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Passo Giau SP638.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Photos at teamlab planets tokyo.jpg", "author": "Sasa0403", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Ponte di Rialto facciata ovest di sera.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Ponte di Rialto Venice 1.jpg", "author": "kallerna", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Pontile Zattere (Venezia).jpg", "author": "Didier Descouens", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Preikestolen Norge.jpg", "author": "Stefan Krause, Germany", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Ra Gusela, view from Passo Giau.jpg", "author": "Marco Ober", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Reine i Lofoten LC0148.jpg", "author": "Jörg Hempel", "license": "CC BY-SA 2.0 de" },
|
||||
{
|
||||
"file": "Reynisfjara Black Sand Beach Iceland Travel Photography (210387065).jpeg",
|
||||
"author": "Giuseppe Milo",
|
||||
"license": "CC BY 3.0"
|
||||
},
|
||||
{
|
||||
"file": "Reynisfjara, Suðurland, Islandia, 2014-08-17, DD 164.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Reyniskirkja Church,Vík í Mýrdal, Suðurland,Ísland.jpg",
|
||||
"author": "JavierOlivares",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Rifugio Fanes 01.JPG", "author": "Bbruno", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Rifugio Fanes.jpg", "author": "Llorenzi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Rigi Kulm cross 20210905.jpg", "author": "Daniel Kraft", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Roca de la Ley, Parque Nacional de Þingvellir, Suðurland, Islandia, 2014-08-16, DD 022.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Salzwelten vantage point.jpg", "author": "Grandmaster Huon", "license": "CC0" },
|
||||
{ "file": "San Marco cathedral in Venice.JPG", "author": "Petar Milosevic", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Santa Maria della Salute from Hotel Monaco nightview.jpg",
|
||||
"author": "Wolfgang Moroder",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Santa Maria della Salute from Hotel Monaco.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Seljalandsfoss - panoramio (7).jpg", "author": "Zoran Kurelić Rabko", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Seljalandsfoss and Gljúfrabúi, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
|
||||
{ "file": "Seljalandsfoss July 2014 -1.JPG", "author": "Martin Falbisoner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Seljalandsfoss view from side, July 2019.jpg", "author": "Alexander Grebenkov", "license": "CC BY 3.0" },
|
||||
{
|
||||
"file": "Seljalandsfoss, Suðurland, Islandia, 2014-08-16, DD 189-191 HDR.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (13824517393).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (13824525803).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (13824839284).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Senso-ji Temple @ Asakusa (14019477806).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Seyðisfjörður church 1.jpg", "author": "Saifunny", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Seyðisfjörður Sept 2019 1.jpg", "author": "Kasa Fue", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Seydisfjordur, East Iceland (51440969579).jpg",
|
||||
"author": "Fabio Achilli from Milano, Italy",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Seydisfjordur, East Iceland (51441184230).jpg",
|
||||
"author": "Fabio Achilli from Milano, Italy",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Seyðisfjörður, Iceland (28328269217).jpg", "author": "Joe deSousa", "license": "CC0" },
|
||||
{
|
||||
"file": "Shadows and windows at Hallgrimskirkja Reykjavik Iceland 2026-02-07 21-21-01 1.jpg",
|
||||
"author": "G. Edward Johnson",
|
||||
"license": "CC BY 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Shariden, Kinkaku-ji, Kyoto, East View 20130811 1.jpg",
|
||||
"author": "DXR / Daniel Vorndran",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "Shinjuku-West Omoide-Yokocho.jpg", "author": "MaedaAkihiko", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Shinkansen N700S series photographed at Higashi-Tagonoura with Mount Fuji in the background, 10 April 2023.jpg",
|
||||
"author": "Takeshi Aida",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Skogafoss-02-2018-gje.jpg", "author": "Gerd Eichmann", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Sky Lagoon Cold Plunge.jpg", "author": "Laurenmcl", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Soca River Valley, Slovenia.jpg", "author": "Barry", "license": "CC BY 2.0" },
|
||||
{
|
||||
"file": "Start of the Old Hakone Highway (9412055176).jpg",
|
||||
"author": "Maarten Heerlien from Voorschoten, The Netherlands",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Strokkur, Área geotérmica de Geysir, Suðurland, Islandia, 2014-08-16, DD 086.JPG",
|
||||
"author": "Diego Delso",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Svínafellsjökull 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Svínafellsjökull glacier at sunset.jpg", "author": "Mikołaj Bańka", "license": "CC BY 4.0" },
|
||||
{ "file": "Svínafellsjökull.JPG", "author": "Bromr at Dutch Wikipedia", "license": "CC BY-SA 3.0" },
|
||||
{
|
||||
"file": "Taroko-Gorge Hualien Taiwan Pudu-Bridge-at-Taroko-National-Park-01.jpg",
|
||||
"author": "CEphoto, Uwe Aranas",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{
|
||||
"file": "The Lava Fields Of Þórsmörk Iceland (175491845).jpeg",
|
||||
"author": "Ilya Grigorik",
|
||||
"license": "CC BY-SA 3.0"
|
||||
},
|
||||
{ "file": "The park bench (2669222928).jpg", "author": "Rosino", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "The West Highland Way approaching the Heads of Glen Etive and Glen Coe - geograph.org.uk - 5014659.jpg",
|
||||
"author": "Tim Heaton",
|
||||
"license": "CC BY-SA 2.0"
|
||||
},
|
||||
{ "file": "Tokyo Shibuya Scramble Crossing 2018-10-09.jpg", "author": "Benh LIEU SONG", "license": "CC BY-SA 2.0" },
|
||||
{
|
||||
"file": "Torii on Lake Ashi @ Hakone Shrine (13776878594).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Torii on Lake Ashi @ Hakone Shrine (13776879364).jpg",
|
||||
"author": "Guilhem Vellut from Annecy, France",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{
|
||||
"file": "Torii path with lantern at Fushimi Inari Taisha Shrine, Kyoto, Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Trolltunga 2.jpg", "author": "Steinar Talmoen", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market -04.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market -05.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market -06.jpg", "author": "Aimaimyi", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Tsukiji Outer Market 2.jpg", "author": "Aw1805", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Ushguli Svaneti Georgia.JPG", "author": "Florian Pinel", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Vaporetto grand canal venice.jpg", "author": "John Salatas", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Vaporetto, Grand Canal, Venice.jpg", "author": "Peter K Burian", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Venice, Canal Grande, Dorsoduro, Plzo Balbi.jpg", "author": "Armin Kleiner", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Venice, Italy. Morning light.jpg", "author": "Dupont66", "license": "CC BY 4.0" },
|
||||
{ "file": "Via ferrata Ivano Dibona - panoramio (1).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
|
||||
{ "file": "Via ferrata Ivano Dibona - panoramio (2).jpg", "author": "Pavel Špindler", "license": "CC BY 3.0" },
|
||||
{ "file": "Via Ferrato Ivano Dibona 3.jpg", "author": "Schi11", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "View from Miradouro do Pico do Arieiro - Madeira 05.jpg", "author": "H. Zell", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Vire via ferrata Ivano Dibona.jpg", "author": "Evelyne Chassagneux-Bonini", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Water reflection of Kinkaku-ji Temple a sunny day, Kyoto, Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{
|
||||
"file": "Water reflection of Kinkaku-ji Temple with blue sky and white clouds, Kyoto, Japan.jpg",
|
||||
"author": "Basile Morin",
|
||||
"license": "CC BY-SA 4.0"
|
||||
},
|
||||
{ "file": "Water transport in Venice at Rialto bridge.jpg", "author": "Wolfgang Moroder", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Waterfalls of Iceland 23 02.jpg", "author": "Hornstrandir1", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Wengental 2019-10-18.jpg", "author": "2015 Michael 2015", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Wien, Café Central -- 2018 -- 3135.jpg", "author": "Dietmar Rabich", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Yanaka Ginza 2.jpg", "author": "Christophe95", "license": "CC BY-SA 4.0" },
|
||||
{
|
||||
"file": "Yanaka Ginza shopping street (4041690725).jpg",
|
||||
"author": "Yoshikazu TAKADA from Tokyo, Japan",
|
||||
"license": "CC BY 2.0"
|
||||
},
|
||||
{ "file": "Yanaka ginza street.jpg", "author": "Kentin", "license": "CC BY-SA 3.0" },
|
||||
{ "file": "Yanaka Ginza, Tokyo, Japan (6790100548).jpg", "author": "yeowatzup", "license": "CC BY 2.0" },
|
||||
{ "file": "Þingvellir, Almannagjá 01.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "Þingvellir, Almannagjá 07.jpg", "author": "Syrio", "license": "CC BY-SA 4.0" },
|
||||
{ "file": "関西国際空港全体写真20220811.jpg", "author": "Ankou1192", "license": "CC BY-SA 4.0" }
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useDemoAccountLock } from './useDemoLock';
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
describe('useDemoAccountLock', () => {
|
||||
it('FE-HOOK-DEMOLOCK-001: locks the demo account on a demo instance', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-002: knows the address the demo account had before the rename', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@nomad.app' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-003: leaves the admin of a demo instance alone', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-004: leaves the demo address alone on an instance that is not a demo', () => {
|
||||
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-HOOK-DEMOLOCK-005: is off while nobody is signed in', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: null });
|
||||
expect(renderHook(() => useDemoAccountLock()).result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { isDemoAccountEmail } from '@trek/shared';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
|
||||
/**
|
||||
* Whether the account signed in here is the shared demo account of a demo
|
||||
* instance. Every visitor signs in as that one account, so its Account tab,
|
||||
* API keys, MCP configuration, own AI connection and language sit under a
|
||||
* DemoLock; the server refuses the same writes (`demo-write.ts`). The admin
|
||||
* of a demo instance, and every account of a real one, see the sections as
|
||||
* they are.
|
||||
*/
|
||||
export function useDemoAccountLock(): boolean {
|
||||
const demoMode = useAuthStore((s) => s.demoMode);
|
||||
const email = useAuthStore((s) => s.user?.email);
|
||||
return demoMode && isDemoAccountEmail(email);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { formatCountdown, secondsUntilReset } from './demoWelcomeModel';
|
||||
|
||||
/**
|
||||
* Whether the photo credits have the dialog: not yet, now, or no longer
|
||||
* (the visitor came back from them, and the focus returns to their link).
|
||||
*/
|
||||
type CreditsView = 'unseen' | 'open' | 'left';
|
||||
|
||||
/**
|
||||
* The welcome dialog's state: whether it is still open, whether it shows the
|
||||
* photo credits in place of the welcome, and the live countdown to the next
|
||||
* hourly reset, ticking once a second in the server's time zone.
|
||||
*/
|
||||
export function useDemoWelcome() {
|
||||
const timeZone = useAuthStore((s) => s.serverTimezone);
|
||||
const [open, setOpen] = useState(true);
|
||||
const [credits, setCredits] = useState<CreditsView>('unseen');
|
||||
const [secondsLeft, setSecondsLeft] = useState(() => secondsUntilReset(new Date(), timeZone));
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const timer = setInterval(() => setSecondsLeft(secondsUntilReset(new Date(), timeZone)), 1000);
|
||||
return () => clearInterval(timer);
|
||||
}, [open, timeZone]);
|
||||
|
||||
return {
|
||||
open,
|
||||
close: () => setOpen(false),
|
||||
countdown: formatCountdown(secondsLeft),
|
||||
creditsOpen: credits === 'open',
|
||||
backFromCredits: credits === 'left',
|
||||
openCredits: () => setCredits('open'),
|
||||
closeCredits: () => setCredits('left'),
|
||||
};
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import type { JourneyMapGLHandle } from './JourneyMapGL'
|
||||
|
||||
import { JourneyMapGLMapbox, JourneyMapGLMaplibre } from '../Map/glLazy'
|
||||
import { useGlRenderer } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import type { MapPhoto } from './journeyPhotoLayer'
|
||||
import type { JourneyMapLayerVisibility } from './journeyMapLayers'
|
||||
@@ -56,10 +57,11 @@ function JourneyMapAuto({ ref, ...props }: Props) {
|
||||
const leafletRef = useRef<JourneyMapHandle>(null)
|
||||
const glRef = useRef<JourneyMapGLHandle>(null)
|
||||
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't
|
||||
// supplied a token yet. MapLibre/OpenFreeMap is tokenless.
|
||||
const useGL = provider === 'maplibre-gl' || (provider === 'mapbox-gl' && !!token)
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl' : 'mapbox-gl'
|
||||
// Fall back to Leaflet when the user selected Mapbox GL but hasn't supplied a
|
||||
// token yet (MapLibre/OpenFreeMap is tokenless), or when the browser has no WebGL2.
|
||||
const glRenderer = useGlRenderer(provider, token)
|
||||
const useGL = glRenderer !== null
|
||||
const glProvider = glRenderer ?? 'mapbox-gl'
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
highlightMarker: (id) => (useGL ? glRef.current : leafletRef.current)?.highlightMarker(id),
|
||||
|
||||
@@ -107,7 +107,8 @@ const glModule = vi.hoisted(() => () => ({
|
||||
}))
|
||||
|
||||
vi.mock('mapbox-gl', glModule)
|
||||
vi.mock('maplibre-gl', glModule)
|
||||
// maplibre-gl 6 has no default export: the same fakes, as named exports.
|
||||
vi.mock('maplibre-gl', () => glModule().default)
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -121,7 +122,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
|
||||
import React from 'react'
|
||||
import mapboxgl from 'mapbox-gl'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { render, screen, act } from '../../../tests/helpers/render'
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
|
||||
@@ -3,6 +3,7 @@ import type mapboxgl from 'mapbox-gl'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { isStandardFamily, supportsCustom3d, wantsTerrain, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import type { JourneyTrack } from '@trek/shared'
|
||||
import { SHOT_INDEX_ATTR, ensureJourneyPopupStyle, formatMarkerDate, journeyPopupHtml } from './journeyMapPopup'
|
||||
import { clusterPhotos, photoMarkerHtml, type MapPhoto } from './journeyPhotoLayer'
|
||||
@@ -360,11 +361,13 @@ function JourneyMapGL(
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: JourneyMapAuto has switched to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
marksFramedRef.current = false
|
||||
entryPointsRef.current = [...items.map(i => [i.lng, i.lat] as [number, number]), ...stableTrail.map(p => [p.lng, p.lat] as [number, number])]
|
||||
|
||||
@@ -1,116 +0,0 @@
|
||||
import { act, fireEvent } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import DemoBanner from './DemoBanner';
|
||||
|
||||
describe('DemoBanner', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-001
|
||||
it('renders without crashing', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(document.body).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-002
|
||||
it('overlay is visible on initial render with dismiss button', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-003
|
||||
it('shows English welcome title by default', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Welcome to/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-004
|
||||
it('clicking "Got it" dismisses the banner', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
const button = screen.getByText('Got it');
|
||||
await user.click(button);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-005
|
||||
it('clicking the overlay backdrop dismisses the banner', () => {
|
||||
const { container } = render(<DemoBanner />);
|
||||
// The outermost fixed div is the overlay backdrop
|
||||
const overlay = container.firstChild as HTMLElement;
|
||||
fireEvent.click(overlay);
|
||||
expect(screen.queryByText('Got it')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-006
|
||||
it('clicking the inner card does NOT dismiss', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<DemoBanner />);
|
||||
// The inner card is the direct parent of the "Got it" button's container
|
||||
const card = screen.getByText('Got it').closest('div[style*="background: white"]')!;
|
||||
await user.click(card);
|
||||
expect(screen.getByText('Got it')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-007
|
||||
it('shows reset timer', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Next reset in/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-008
|
||||
it('shows upload-disabled notice', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/File uploads.*disabled in demo/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-009
|
||||
it('shows "What is TREK?" section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('What is TREK?')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-010
|
||||
it('shows addon cards', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText('Vacay')).toBeInTheDocument();
|
||||
expect(screen.getByText('Atlas')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-011
|
||||
it('shows full version features section', () => {
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/Additionally in the full version/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// FE-COMP-DEMOBANNER-012
|
||||
it('self-host link points to GitHub', () => {
|
||||
render(<DemoBanner />);
|
||||
const link = screen.getByText('self-host it').closest('a')!;
|
||||
expect(link).toHaveAttribute('href', 'https://github.com/liketrek/TREK');
|
||||
expect(link).toHaveAttribute('target', '_blank');
|
||||
});
|
||||
|
||||
// Timer update test
|
||||
it('updates countdown timer after interval tick', async () => {
|
||||
vi.useFakeTimers({ shouldAdvanceTime: false });
|
||||
// Set time to XX:30 so minutesLeft = 59 - 30 = 29
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 30, 0));
|
||||
render(<DemoBanner />);
|
||||
expect(screen.getByText(/29 minutes/)).toBeInTheDocument();
|
||||
|
||||
// Advance to XX:31 and tick the interval; wrap in act so React flushes state update
|
||||
await act(async () => {
|
||||
vi.setSystemTime(new Date(2026, 3, 7, 12, 31, 0));
|
||||
vi.advanceTimersByTime(10000);
|
||||
});
|
||||
expect(screen.getByText(/28 minutes/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,613 +0,0 @@
|
||||
import {
|
||||
ArrowRightLeft,
|
||||
CalendarDays,
|
||||
Clock,
|
||||
Database,
|
||||
FileText,
|
||||
Github,
|
||||
Globe,
|
||||
Key,
|
||||
ListChecks,
|
||||
Map,
|
||||
Puzzle,
|
||||
Shield,
|
||||
Upload,
|
||||
Users,
|
||||
Wallet,
|
||||
} from 'lucide-react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
|
||||
interface DemoTexts {
|
||||
titleBefore: string;
|
||||
titleAfter: string;
|
||||
title: string;
|
||||
description: string;
|
||||
resetIn: string;
|
||||
minutes: string;
|
||||
uploadNote: string;
|
||||
fullVersionTitle: string;
|
||||
features: string[];
|
||||
addonsTitle: string;
|
||||
addons: [string, string][];
|
||||
whatIs: string;
|
||||
whatIsDesc: string;
|
||||
selfHost: string;
|
||||
selfHostLink: string;
|
||||
close: string;
|
||||
}
|
||||
|
||||
const texts: Record<string, DemoTexts> = {
|
||||
de: {
|
||||
titleBefore: 'Willkommen bei ',
|
||||
titleAfter: '',
|
||||
title: 'Willkommen zur TREK Demo',
|
||||
description:
|
||||
'Du kannst Reisen ansehen, bearbeiten und eigene erstellen. Alle Aenderungen werden jede Stunde automatisch zurueckgesetzt.',
|
||||
resetIn: 'Naechster Reset in',
|
||||
minutes: 'Minuten',
|
||||
uploadNote: 'Datei-Uploads (Fotos, Dokumente, Cover) sind in der Demo deaktiviert.',
|
||||
fullVersionTitle: 'In der Vollversion zusaetzlich:',
|
||||
features: [
|
||||
'Datei-Uploads (Fotos, Dokumente, Cover)',
|
||||
'API-Schluessel (Google Maps, Wetter)',
|
||||
'Benutzer- & Rechteverwaltung',
|
||||
'Automatische Backups',
|
||||
'Addon-Verwaltung (aktivieren/deaktivieren)',
|
||||
'OIDC / SSO Single Sign-On',
|
||||
],
|
||||
addonsTitle: 'Modulare Addons (in der Vollversion deaktivierbar)',
|
||||
addons: [
|
||||
['Vacay', 'Urlaubsplaner mit Kalender, Feiertagen & Fusion'],
|
||||
['Atlas', 'Weltkarte mit besuchten Laendern & Reisestatistiken'],
|
||||
['Packliste', 'Checklisten pro Reise'],
|
||||
['Budget', 'Kostenplanung mit Splitting'],
|
||||
['Dokumente', 'Dateien an Reisen anhaengen'],
|
||||
['Widgets', 'Waehrungsrechner & Zeitzonen'],
|
||||
],
|
||||
whatIs: 'Was ist TREK?',
|
||||
whatIsDesc:
|
||||
'Ein selbst-gehosteter Reiseplaner mit Echtzeit-Kollaboration, interaktiver Karte, OIDC Login und Dark Mode.',
|
||||
selfHost: 'Open Source — ',
|
||||
selfHostLink: 'selbst hosten',
|
||||
close: 'Verstanden',
|
||||
},
|
||||
en: {
|
||||
titleBefore: 'Welcome to ',
|
||||
titleAfter: '',
|
||||
title: 'Welcome to the TREK Demo',
|
||||
description: 'You can view, edit and create trips. All changes are automatically reset every hour.',
|
||||
resetIn: 'Next reset in',
|
||||
minutes: 'minutes',
|
||||
uploadNote: 'File uploads (photos, documents, covers) are disabled in demo mode.',
|
||||
fullVersionTitle: 'Additionally in the full version:',
|
||||
features: [
|
||||
'File uploads (photos, documents, covers)',
|
||||
'API key management (Google Maps, Weather)',
|
||||
'User & permission management',
|
||||
'Automatic backups',
|
||||
'Addon management (enable/disable)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Modular Addons (can be deactivated in full version)',
|
||||
addons: [
|
||||
['Vacay', 'Vacation planner with calendar, holidays & user fusion'],
|
||||
['Atlas', 'World map with visited countries & travel stats'],
|
||||
['Packing', 'Checklists per trip'],
|
||||
['Budget', 'Expense tracking with splitting'],
|
||||
['Documents', 'Attach files to trips'],
|
||||
['Widgets', 'Currency converter & timezones'],
|
||||
],
|
||||
whatIs: 'What is TREK?',
|
||||
whatIsDesc:
|
||||
'A self-hosted travel planner with real-time collaboration, interactive maps, OIDC login and dark mode.',
|
||||
selfHost: 'Open source — ',
|
||||
selfHostLink: 'self-host it',
|
||||
close: 'Got it',
|
||||
},
|
||||
et: {
|
||||
titleBefore: 'Tere tulemast ',
|
||||
titleAfter: 'i',
|
||||
title: 'Tere tulemast TREKi demosse',
|
||||
description: 'Saad reise vaadata, muuta ja luua. Kõik muudatused lähtestatakse automaatselt iga tunni järel.',
|
||||
resetIn: 'Järgmise lähtestamiseni',
|
||||
minutes: 'minutit',
|
||||
uploadNote: 'Failide (fotode, dokumentide ja kaanepiltide) üleslaadimine on demorežiimis keelatud.',
|
||||
fullVersionTitle: 'Täisversioonis lisaks:',
|
||||
features: [
|
||||
'Failide üleslaadimine (fotod, dokumendid, kaanepildid)',
|
||||
'API-võtmete haldus (Google Maps, ilm)',
|
||||
'Kasutajate ja õiguste haldus',
|
||||
'Automaatsed varukoopiad',
|
||||
'Lisamoodulite haldus (lubamine/keelamine)',
|
||||
'OIDC / SSO ühekordne sisselogimine',
|
||||
],
|
||||
addonsTitle: 'Lisamoodulid (täisversioonis saab välja lülitada)',
|
||||
addons: [
|
||||
['Vacay', 'Puhkuseplaneerija kalendri, pühade ja kasutajate plaanide ühendamisega'],
|
||||
['Atlas', 'Maailmakaart külastatud riikide ja reisistatistikaga'],
|
||||
['Pakkimine', 'Iga reisi kontrollnimekirjad'],
|
||||
['Eelarve', 'Kulude jälgimine ja jagamine'],
|
||||
['Dokumendid', 'Lisa reisidele faile'],
|
||||
['Vidinad', 'Valuutakalkulaator ja ajavööndid'],
|
||||
],
|
||||
whatIs: 'Mis on TREK?',
|
||||
whatIsDesc:
|
||||
'Ise majutatav reisiplaneerija reaalajas koostöö, interaktiivsete kaartide, OIDC sisselogimise ja tumeda režiimiga.',
|
||||
selfHost: 'Avatud lähtekood — ',
|
||||
selfHostLink: 'majuta ise',
|
||||
close: 'Selge',
|
||||
},
|
||||
es: {
|
||||
titleBefore: 'Bienvenido a ',
|
||||
titleAfter: '',
|
||||
title: 'Bienvenido a la demo de TREK',
|
||||
description: 'Puedes ver, editar y crear viajes. Todos los cambios se restablecen automáticamente cada hora.',
|
||||
resetIn: 'Próximo reinicio en',
|
||||
minutes: 'minutos',
|
||||
uploadNote: 'Las subidas de archivos (fotos, documentos, portadas) están desactivadas en el modo demo.',
|
||||
fullVersionTitle: 'Además, en la versión completa:',
|
||||
features: [
|
||||
'Subida de archivos (fotos, documentos, portadas)',
|
||||
'Gestión de claves API (Google Maps, tiempo)',
|
||||
'Gestión de usuarios y permisos',
|
||||
'Copias de seguridad automáticas',
|
||||
'Gestión de addons (activar/desactivar)',
|
||||
'Inicio de sesión único OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'Complementos modulares (se pueden desactivar en la versión completa)',
|
||||
addons: [
|
||||
['Vacaciones', 'Planificador de vacaciones con calendario, festivos y fusión de usuarios'],
|
||||
['Atlas', 'Mapa del mundo con países visitados y estadísticas de viaje'],
|
||||
['Equipaje', 'Listas de comprobación para cada viaje'],
|
||||
['Presupuesto', 'Control de gastos con reparto'],
|
||||
['Documentos', 'Adjunta archivos a los viajes'],
|
||||
['Widgets', 'Conversor de divisas y zonas horarias'],
|
||||
],
|
||||
whatIs: '¿Qué es TREK?',
|
||||
whatIsDesc:
|
||||
'Un planificador de viajes autohospedado con colaboración en tiempo real, mapas interactivos, inicio de sesión OIDC y modo oscuro.',
|
||||
selfHost: 'Código abierto — ',
|
||||
selfHostLink: 'alójalo tú mismo',
|
||||
close: 'Entendido',
|
||||
},
|
||||
zh: {
|
||||
titleBefore: '欢迎来到 ',
|
||||
titleAfter: '',
|
||||
title: '欢迎来到 TREK 演示版',
|
||||
description: '你可以查看、编辑和创建旅行。所有更改都会在每小时自动重置。',
|
||||
resetIn: '下次重置将在',
|
||||
minutes: '分钟后',
|
||||
uploadNote: '演示模式下已禁用文件上传(照片、文档、封面)。',
|
||||
fullVersionTitle: '完整版本还包括:',
|
||||
features: [
|
||||
'文件上传(照片、文档、封面)',
|
||||
'API 密钥管理(Google Maps、天气)',
|
||||
'用户和权限管理',
|
||||
'自动备份',
|
||||
'附加组件管理(启用/禁用)',
|
||||
'OIDC / SSO 单点登录',
|
||||
],
|
||||
addonsTitle: '模块化附加组件(完整版本可禁用)',
|
||||
addons: [
|
||||
['Vacay', '带日历、节假日和用户融合的假期规划器'],
|
||||
['Atlas', '带已访问国家和旅行统计的世界地图'],
|
||||
['Packing', '按旅行管理清单'],
|
||||
['Budget', '支持分摊的费用追踪'],
|
||||
['Documents', '将文件附加到旅行'],
|
||||
['Widgets', '货币换算和时区工具'],
|
||||
],
|
||||
whatIs: '什么是 TREK?',
|
||||
whatIsDesc: '一个支持实时协作、交互式地图、OIDC 登录和深色模式的自托管旅行规划器。',
|
||||
selfHost: '开源项目 - ',
|
||||
selfHostLink: '自行部署',
|
||||
close: '知道了',
|
||||
},
|
||||
'zh-TW': {
|
||||
titleBefore: '歡迎來到 ',
|
||||
titleAfter: '',
|
||||
title: '歡迎來到 TREK 展示版',
|
||||
description: '你可以檢視、編輯和建立行程。所有變更都會在每小時自動重設。',
|
||||
resetIn: '下次重設將在',
|
||||
minutes: '分鐘後',
|
||||
uploadNote: '展示模式下已停用檔案上傳(照片、文件、封面)。',
|
||||
fullVersionTitle: '完整版本還包含:',
|
||||
features: [
|
||||
'檔案上傳(照片、文件、封面)',
|
||||
'API 金鑰管理(Google Maps、天氣)',
|
||||
'使用者與權限管理',
|
||||
'自動備份',
|
||||
'附加元件管理(啟用/停用)',
|
||||
'OIDC / SSO 單一登入',
|
||||
],
|
||||
addonsTitle: '模組化附加元件(完整版本可停用)',
|
||||
addons: [
|
||||
['Vacay', '具備日曆、假日與使用者融合的假期規劃器'],
|
||||
['Atlas', '顯示已造訪國家與旅行統計的世界地圖'],
|
||||
['Packing', '依行程管理的檢查清單'],
|
||||
['Budget', '支援分攤的費用追蹤'],
|
||||
['Documents', '將檔案附加到行程'],
|
||||
['Widgets', '貨幣換算與時區工具'],
|
||||
],
|
||||
whatIs: 'TREK 是什麼?',
|
||||
whatIsDesc: '一個支援即時協作、互動式地圖、OIDC 登入和深色模式的自架旅行規劃器。',
|
||||
selfHost: '開源專案 - ',
|
||||
selfHostLink: '自行架設',
|
||||
close: '知道了',
|
||||
},
|
||||
ar: {
|
||||
titleBefore: 'مرحبًا بك في ',
|
||||
titleAfter: '',
|
||||
title: 'مرحبًا بك في النسخة التجريبية من TREK',
|
||||
description: 'يمكنك عرض الرحلات وتعديلها وإنشاء رحلات جديدة. تتم إعادة ضبط جميع التغييرات تلقائيًا كل ساعة.',
|
||||
resetIn: 'إعادة الضبط التالية خلال',
|
||||
minutes: 'دقيقة',
|
||||
uploadNote: 'رفع الملفات (الصور والمستندات وصور الغلاف) معطّل في وضع العرض التجريبي.',
|
||||
fullVersionTitle: 'وفي النسخة الكاملة أيضًا:',
|
||||
features: [
|
||||
'رفع الملفات (الصور والمستندات وصور الغلاف)',
|
||||
'إدارة مفاتيح API (خرائط Google والطقس)',
|
||||
'إدارة المستخدمين والصلاحيات',
|
||||
'نسخ احتياطية تلقائية',
|
||||
'إدارة الإضافات (تفعيل/تعطيل)',
|
||||
'تسجيل دخول موحد OIDC / SSO',
|
||||
],
|
||||
addonsTitle: 'إضافات مرنة (يمكن تعطيلها في النسخة الكاملة)',
|
||||
addons: [
|
||||
['Vacay', 'مخطط إجازات مع تقويم وعطل ودمج مستخدمين'],
|
||||
['Atlas', 'خريطة عالمية مع الدول التي تمت زيارتها وإحصاءات السفر'],
|
||||
['Packing', 'قوائم تجهيز لكل رحلة'],
|
||||
['Budget', 'تتبع المصروفات مع التقسيم'],
|
||||
['Documents', 'إرفاق الملفات بالرحلات'],
|
||||
['Widgets', 'محول عملات ومناطق زمنية'],
|
||||
],
|
||||
whatIs: 'ما هو TREK؟',
|
||||
whatIsDesc: 'مخطط رحلات مستضاف ذاتيًا مع تعاون لحظي وخرائط تفاعلية وتسجيل دخول OIDC ووضع داكن.',
|
||||
selfHost: 'مفتوح المصدر — ',
|
||||
selfHostLink: 'استضفه بنفسك',
|
||||
close: 'فهمت',
|
||||
},
|
||||
id: {
|
||||
titleBefore: 'Selamat datang di ',
|
||||
titleAfter: '',
|
||||
title: 'Selamat datang di Demo TREK',
|
||||
description:
|
||||
'Anda dapat melihat, mengedit, dan membuat perjalanan. Semua perubahan akan diatur ulang secara otomatis setiap jam.',
|
||||
resetIn: 'Atur ulang berikutnya dalam',
|
||||
minutes: 'menit',
|
||||
uploadNote: 'Unggah file (foto, dokumen, sampul) dinonaktifkan dalam mode demo.',
|
||||
fullVersionTitle: 'Selain itu dalam versi lengkap:',
|
||||
features: [
|
||||
'Unggah file (foto, dokumen, sampul)',
|
||||
'Manajemen kunci API (Google Maps, Cuaca)',
|
||||
'Manajemen pengguna & izin',
|
||||
'Pencadangan otomatis',
|
||||
'Manajemen Addon (aktifkan/nonaktifkan)',
|
||||
'OIDC / SSO single sign-on',
|
||||
],
|
||||
addonsTitle: 'Addon Modular (dapat dinonaktifkan di versi lengkap)',
|
||||
addons: [
|
||||
['Vacay', 'Perencana liburan dengan kalender, hari libur & penggabungan pengguna'],
|
||||
['Atlas', 'Peta dunia dengan negara yang dikunjungi & statistik perjalanan'],
|
||||
['Pengepakan', 'Daftar periksa per perjalanan'],
|
||||
['Anggaran', 'Pelacakan pengeluaran dengan pemisahan tagihan'],
|
||||
['Dokumen', 'Lampirkan file ke perjalanan'],
|
||||
['Widget', 'Konverter mata uang & zona waktu'],
|
||||
],
|
||||
whatIs: 'Apa itu TREK?',
|
||||
whatIsDesc:
|
||||
'Perencana perjalanan yang di-host sendiri dengan kolaborasi real-time, peta interaktif, login OIDC, dan mode gelap.',
|
||||
selfHost: 'Buka sumber — ',
|
||||
selfHostLink: 'host mandiri',
|
||||
close: 'Mengerti',
|
||||
},
|
||||
};
|
||||
|
||||
const featureIcons = [Upload, Key, Users, Database, Puzzle, Shield];
|
||||
const addonIcons = [CalendarDays, Globe, ListChecks, Wallet, FileText, ArrowRightLeft];
|
||||
|
||||
export default function DemoBanner(): React.ReactElement | null {
|
||||
const [dismissed, setDismissed] = useState<boolean>(false);
|
||||
const [minutesLeft, setMinutesLeft] = useState<number>(59 - new Date().getMinutes());
|
||||
const { language } = useTranslation();
|
||||
const t = texts[language] || texts.en;
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setMinutesLeft(59 - new Date().getMinutes()), 10000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
if (dismissed) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 99999,
|
||||
background: 'rgba(0,0,0,0.6)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingTop: 'max(16px, env(safe-area-inset-top))',
|
||||
paddingBottom: 'max(16px, calc(env(safe-area-inset-bottom) + 80px))',
|
||||
paddingInline: 16,
|
||||
overflow: 'auto',
|
||||
fontFamily: 'var(--font-system)',
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t.close}
|
||||
onClick={() => setDismissed(true)}
|
||||
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (e.target !== e.currentTarget) return;
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setDismissed(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
role="presentation"
|
||||
style={{
|
||||
background: 'white',
|
||||
borderRadius: 20,
|
||||
padding: '28px 24px 0',
|
||||
maxWidth: 480,
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 60px rgba(0,0,0,0.3)',
|
||||
maxHeight: 'min(90vh, calc(100dvh - 96px))',
|
||||
overflow: 'auto',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
onClick={(e: React.MouseEvent<HTMLDivElement>) => e.stopPropagation()}
|
||||
>
|
||||
{/* Header */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10, marginBottom: 14 }}>
|
||||
<img src="/icons/icon-dark.svg" alt="" style={{ width: 36, height: 36, borderRadius: 10 }} />
|
||||
<h2
|
||||
style={{
|
||||
margin: 0,
|
||||
fontSize: 'calc(17px * var(--fs-scale-subtitle, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#111827',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 5,
|
||||
}}
|
||||
>
|
||||
{t.titleBefore}
|
||||
<img src="/text-dark.svg" alt="TREK" style={{ height: 18 }} />
|
||||
{t.titleAfter}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(13px * var(--fs-scale-body, 1))',
|
||||
color: '#6b7280',
|
||||
lineHeight: 1.6,
|
||||
margin: '0 0 12px',
|
||||
}}
|
||||
>
|
||||
{t.description}
|
||||
</p>
|
||||
|
||||
{/* Timer + Upload note */}
|
||||
<div style={{ display: 'flex', gap: 8, marginBottom: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#f0f9ff',
|
||||
border: '1px solid #bae6fd',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<Clock size={13} style={{ flexShrink: 0, color: '#0284c7' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#0369a1', fontWeight: 600 }}>
|
||||
{t.resetIn} {minutesLeft} {t.minutes}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
background: '#fffbeb',
|
||||
border: '1px solid #fde68a',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
}}
|
||||
>
|
||||
<Upload size={13} style={{ flexShrink: 0, color: '#b45309' }} />
|
||||
<span style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', color: '#b45309' }}>
|
||||
{t.uploadNote}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* What is TREK */}
|
||||
<div
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 12,
|
||||
padding: '12px 14px',
|
||||
marginBottom: 16,
|
||||
border: '1px solid #e2e8f0',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
|
||||
<Map size={14} style={{ color: '#111827' }} />
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#111827',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{t.whatIs}
|
||||
</span>
|
||||
</div>
|
||||
<p style={{ fontSize: 'calc(12px * var(--fs-scale-body, 1))', color: '#64748b', lineHeight: 1.5, margin: 0 }}>
|
||||
{t.whatIsDesc}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Addons */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#374151',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Puzzle size={12} />
|
||||
{t.addonsTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.addons.map(([name, desc], i) => {
|
||||
const Icon = addonIcons[i];
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
style={{
|
||||
background: '#f8fafc',
|
||||
borderRadius: 10,
|
||||
padding: '8px 10px',
|
||||
border: '1px solid #f1f5f9',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 2 }}>
|
||||
<Icon size={12} style={{ flexShrink: 0, color: '#111827' }} />
|
||||
<span
|
||||
style={{ fontSize: 'calc(11px * var(--fs-scale-caption, 1))', fontWeight: 700, color: '#111827' }}
|
||||
>
|
||||
{name}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
color: '#94a3b8',
|
||||
margin: 0,
|
||||
lineHeight: 1.3,
|
||||
paddingInlineStart: 18,
|
||||
}}
|
||||
>
|
||||
{desc}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Full version features */}
|
||||
<p
|
||||
style={{
|
||||
fontSize: 'calc(10px * var(--fs-scale-caption, 1))',
|
||||
fontWeight: 700,
|
||||
color: '#374151',
|
||||
margin: '0 0 8px',
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.08em',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<Shield size={12} />
|
||||
{t.fullVersionTitle}
|
||||
</p>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 16 }}>
|
||||
{t.features.map((text, i) => {
|
||||
const Icon = featureIcons[i];
|
||||
return (
|
||||
<div
|
||||
key={text}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: '#4b5563',
|
||||
padding: '4px 0',
|
||||
}}
|
||||
>
|
||||
<Icon size={13} style={{ flexShrink: 0, color: '#9ca3af' }} />
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div
|
||||
style={{
|
||||
padding: '14px 0 20px',
|
||||
borderTop: '1px solid #e5e7eb',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
background: 'white',
|
||||
marginTop: 'auto',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
fontSize: 'calc(11px * var(--fs-scale-caption, 1))',
|
||||
color: '#9ca3af',
|
||||
}}
|
||||
>
|
||||
<Github size={13} />
|
||||
<span>{t.selfHost}</span>
|
||||
<a
|
||||
href="https://github.com/liketrek/TREK"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#111827', fontWeight: 600, textDecoration: 'none' }}
|
||||
>
|
||||
{t.selfHostLink}
|
||||
</a>
|
||||
</div>
|
||||
<button type="button"
|
||||
onClick={() => setDismissed(true)}
|
||||
style={{
|
||||
background: '#111827',
|
||||
color: 'white',
|
||||
border: 'none',
|
||||
borderRadius: 10,
|
||||
padding: '8px 20px',
|
||||
fontSize: 'calc(12px * var(--fs-scale-body, 1))',
|
||||
fontWeight: 600,
|
||||
cursor: 'pointer',
|
||||
fontFamily: 'inherit',
|
||||
}}
|
||||
>
|
||||
{t.close}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { MapView } from './MapView'
|
||||
import ErrorBoundary from '../shared/ErrorBoundary'
|
||||
import { MapViewGLMapbox, MapViewGLMaplibre } from './glLazy'
|
||||
import { useGlRenderer } from './glSupport'
|
||||
import { useRoadtripHazards } from './useRoadtripHazards'
|
||||
import { useTranslation } from '../../i18n/TranslationContext'
|
||||
import type { MapViewAutoProps, MapViewProps } from './mapViewContract'
|
||||
@@ -28,11 +29,10 @@ export function MapViewAuto(props: MapViewAutoProps) {
|
||||
const mapProps = { ...props, hazards: hazards.feed?.hazards } as MapViewProps
|
||||
const provider = useSettingsStore(s => s.settings.map_provider)
|
||||
const token = useSettingsStore(s => s.settings.mapbox_access_token)
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set,
|
||||
// so trip planner never shows an empty map due to a missing token.
|
||||
const glProvider = provider === 'maplibre-gl' ? 'maplibre-gl'
|
||||
: provider === 'mapbox-gl' && token ? 'mapbox-gl'
|
||||
: null
|
||||
// Fall back to Leaflet when Mapbox is selected but no token is set, or when the
|
||||
// browser has no WebGL2 (both GL engines need it), so the trip planner never
|
||||
// shows an empty map or one that fails to start.
|
||||
const glProvider = useGlRenderer(provider, token)
|
||||
// One chunk per engine: picking the binding here is what keeps mapbox-gl and
|
||||
// maplibre-gl out of each other's downloads.
|
||||
const MapViewGL = glProvider === 'maplibre-gl' ? MapViewGLMaplibre : MapViewGLMapbox
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* The trip map without WebGL2 (FE-COMP-MAPAUTO-WEBGL-*).
|
||||
*
|
||||
* A browser that cannot give a GL engine its WebGL2 context sees the Leaflet map
|
||||
* with the user's tiles, never a map that fails to start: up front when the probe
|
||||
* says no, and the moment the GL map's own constructor is refused when it said yes.
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { buildGlMap } from './glSupport';
|
||||
import { MapViewAuto } from './MapViewAuto';
|
||||
|
||||
const gl = vi.hoisted(() => ({ refuse: false }));
|
||||
|
||||
vi.mock('./MapView', () => ({
|
||||
MapView: () => <div data-testid="leaflet-map" />,
|
||||
}));
|
||||
|
||||
/** Stands in for MapViewGL: builds its map the way the real one does, through buildGlMap. */
|
||||
function FakeGlMap() {
|
||||
useEffect(() => {
|
||||
buildGlMap(() => {
|
||||
if (!gl.refuse) return {};
|
||||
const err = new Error('WebGL2 is required to display this map.');
|
||||
err.name = 'GPUInitializationError';
|
||||
throw err;
|
||||
});
|
||||
}, []);
|
||||
return <div data-testid="gl-map" />;
|
||||
}
|
||||
|
||||
vi.mock('./glLazy', () => ({ MapViewGLMaplibre: FakeGlMap, MapViewGLMapbox: FakeGlMap }));
|
||||
vi.mock('./useRoadtripHazards', () => ({ useRoadtripHazards: () => ({ feed: null }) }));
|
||||
|
||||
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
function browserWithWebGL2(available: boolean) {
|
||||
const ctx = { getExtension: () => ({ loseContext: () => {} }) };
|
||||
HTMLCanvasElement.prototype.getContext = vi.fn((type: string) =>
|
||||
available && type === 'webgl2' ? ctx : null
|
||||
) as unknown as typeof realGetContext;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
resetWebGLProbe();
|
||||
gl.refuse = false;
|
||||
seedStore(useSettingsStore, { settings: { map_provider: 'maplibre-gl', mapbox_access_token: '' } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = realGetContext;
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
describe('MapViewAuto without WebGL2', () => {
|
||||
it('FE-COMP-MAPAUTO-WEBGL-001: draws the MapLibre map where WebGL2 is there', async () => {
|
||||
browserWithWebGL2(true);
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
expect(await screen.findByTestId('gl-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('leaflet-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-WEBGL-002: draws the Leaflet map when the browser has no WebGL2', () => {
|
||||
browserWithWebGL2(false);
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
expect(screen.getByTestId('leaflet-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-MAPAUTO-WEBGL-003: falls back to Leaflet when the GPU refuses the context the probe promised', async () => {
|
||||
browserWithWebGL2(true);
|
||||
gl.refuse = true;
|
||||
|
||||
render(<MapViewAuto places={[]} />);
|
||||
|
||||
expect(await screen.findByTestId('leaflet-map')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('gl-map')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { act } from '@testing-library/react';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import React from 'react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { render } from '../../../tests/helpers/render';
|
||||
@@ -164,22 +164,21 @@ vi.mock('mapbox-gl', () => ({
|
||||
}));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
|
||||
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
},
|
||||
Map: vi.fn(function () {
|
||||
return glMap;
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts);
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create();
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup;
|
||||
}),
|
||||
}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import { buildPlace } from '../../../tests/helpers/factories'
|
||||
import { server } from '../../../tests/helpers/msw/server'
|
||||
import { useSettingsStore } from '../../store/settingsStore'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import { DEFAULT_MAP_ZOOM } from '../../constants/mapDefaults'
|
||||
import { MAP_LAYER_SWITCHER_INSET } from './MapLayerSwitcher'
|
||||
import type { GeoPosition, TrackingMode } from '../../hooks/useGeolocation'
|
||||
@@ -156,22 +156,21 @@ vi.mock('mapbox-gl', () => ({
|
||||
}))
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}))
|
||||
|
||||
// maplibre-gl 6 has no default export, so the fakes are named exports.
|
||||
vi.mock('maplibre-gl', () => ({
|
||||
default: {
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts)
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create()
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup
|
||||
}),
|
||||
},
|
||||
Map: vi.fn(function () {
|
||||
return glMap
|
||||
}),
|
||||
Marker: vi.fn(function (opts?: { element?: HTMLElement }) {
|
||||
return glMarkers.make(opts)
|
||||
}),
|
||||
LngLatBounds: vi.fn(function () {
|
||||
return glBounds.create()
|
||||
}),
|
||||
NavigationControl: vi.fn(),
|
||||
Popup: vi.fn(function () {
|
||||
return glPopup
|
||||
}),
|
||||
}))
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}))
|
||||
|
||||
@@ -352,7 +351,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-004: renders with the MapLibre provider and no token', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
@@ -373,7 +372,7 @@ describe('MapViewGL', () => {
|
||||
|
||||
it('FE-COMP-MAPVIEWGL-014: MapLibre maps disable the around-center mouse rotate (#1545)', async () => {
|
||||
const mapboxgl = (await import('mapbox-gl')).default
|
||||
const maplibregl = (await import('maplibre-gl')).default
|
||||
const maplibregl = await import('maplibre-gl')
|
||||
useSettingsStore.setState({
|
||||
settings: {
|
||||
...useSettingsStore.getState().settings,
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useTransportRoutes } from '../../hooks/useTransportRoutes'
|
||||
import { visibleRouteReservations } from '../../utils/reservationRoutes'
|
||||
import { safeHexColor } from '../../utils/safeColor'
|
||||
import { MAPBOX_DEFAULT_STYLE, styleForActiveProvider, basemapLanguage, type GlMapProvider } from './glProviders'
|
||||
import { buildGlMap } from './glSupport'
|
||||
import LocationButton from './LocationButton'
|
||||
import { useIsPhone } from '../../mobile/useIsPhone'
|
||||
import { useGeolocation } from '../../hooks/useGeolocation'
|
||||
@@ -1104,13 +1105,12 @@ export function MapViewGL({
|
||||
antialias: mapboxQuality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = mapboxQuality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's mouse-rotate inverts its sign at a mid-screen line it gets by
|
||||
// re-projecting the map center — a line that drifts with the bearing, so a
|
||||
// right-button drag near mid-screen ping-pongs instead of rotating (#1545).
|
||||
// aroundCenter: false restores the plain dx-based rotate mapbox-gl uses.
|
||||
// MapLibre's around-center rotate ping-pongs near mid-screen (#1545); false is mapbox-gl's dx rotate.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: MapViewAuto has switched to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
popupRef.current = new gl.Popup({
|
||||
closeButton: false,
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
RASTER_FALLBACK_TILE_URL,
|
||||
attributionForTile,
|
||||
} from '../../constants/mapDefaults'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { hasWebGL, reportWebGLFailure } from '../../utils/webgl'
|
||||
import { isGpuInitFailure } from './glSupport'
|
||||
import { isChunkLoadError, reloadOnceForChunk } from '../../utils/chunkReload'
|
||||
|
||||
/** The Leaflet layer maplibre-gl-leaflet hands back. */
|
||||
@@ -96,6 +97,8 @@ function attachGlLayer(map: L.Map, layer: GlLeafletLayer, style: string): boolea
|
||||
layer.addTo(map)
|
||||
} catch (err) {
|
||||
console.warn('[basemap] no WebGL context for the vector basemap, drawing raster tiles', err)
|
||||
// The GPU, not a context budget: the GL maps elsewhere in the app switch to Leaflet too.
|
||||
if (isGpuInitFailure(err)) reportWebGLFailure()
|
||||
detachBasemapLayer(layer)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import * as maplibregl from 'maplibre-gl'
|
||||
import 'maplibre-gl/dist/maplibre-gl.css'
|
||||
import rtlTextPluginUrl from '@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url'
|
||||
import { registerRtlTextPlugin } from '../rtlText'
|
||||
import workerUrl from 'maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url'
|
||||
|
||||
/**
|
||||
* The only module in the client that pulls maplibre-gl in at runtime. Same rule as
|
||||
@@ -11,9 +10,22 @@ import { registerRtlTextPlugin } from '../rtlText'
|
||||
* Popup, LngLatBounds — which is why the components take the engine as a prop and
|
||||
* stay engine-agnostic. Where the two genuinely differ, the caller already guards
|
||||
* on the provider (projection, accessToken, aroundCenter).
|
||||
*
|
||||
* MapLibre 6 ships as ES modules only and has no default export any more, so the
|
||||
* namespace is what goes out as this module's default. It is a frozen module
|
||||
* namespace: nothing may assign to it, which is why the accessToken writes stay
|
||||
* behind their mapbox-only guard.
|
||||
*/
|
||||
// Arabic/Hebrew/Persian labels come out unjoined and reversed without this.
|
||||
// MapLibre's signature is (url, lazy) and it resolves a promise — see rtlText.ts.
|
||||
registerRtlTextPlugin(() => maplibregl.setRTLTextPlugin(rtlTextPluginUrl, true))
|
||||
|
||||
// MapLibre 6 finds its worker next to its own module, which inside a bundle is a
|
||||
// chunk in /assets/ with no worker beside it. Vite builds the worker into its own
|
||||
// file and hands back the URL; set before any Map is constructed, the
|
||||
// maplibre-gl-leaflet bridge included, since it only ever reaches the engine
|
||||
// together with this module.
|
||||
maplibregl.setWorkerUrl(workerUrl)
|
||||
|
||||
// No RTL text plugin here, unlike mapbox. MapLibre 6.9 shapes Arabic and orders
|
||||
// bidirectional text itself; the old plugin would replace that implementation, and
|
||||
// a v6 worker can only load the plugin's UMD build through eval.
|
||||
|
||||
export default maplibregl
|
||||
|
||||
@@ -1,29 +1,33 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
/**
|
||||
* The two lines this fix actually adds. Everything else is a helper: the guard in
|
||||
* rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting either registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green.
|
||||
* What each engine module does on import. Everything else is a helper: the guard
|
||||
* in rtlText.ts has its own tests, but a guard nobody calls looks exactly like a
|
||||
* working fix from the outside. Deleting the mapbox registration, swapping an
|
||||
* argument or dropping the deferred flag left the whole suite green, and so would
|
||||
* a maplibre engine that forgot its worker URL.
|
||||
*
|
||||
* The engines are mocked because importing them for real builds a worker pool
|
||||
* under jsdom, and the plugin URL is mocked so the 133 KB payload never loads.
|
||||
*/
|
||||
const { mapboxSet, maplibreSet } = vi.hoisted(() => ({
|
||||
const { mapboxSet, maplibreSet, maplibreWorker } = vi.hoisted(() => ({
|
||||
mapboxSet: vi.fn(),
|
||||
maplibreSet: vi.fn(),
|
||||
maplibreWorker: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: { setRTLTextPlugin: mapboxSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ default: { setRTLTextPlugin: maplibreSet } }));
|
||||
vi.mock('maplibre-gl', () => ({ setRTLTextPlugin: maplibreSet, setWorkerUrl: maplibreWorker }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl-worker.mjs?worker&url', () => ({ default: '/assets/maplibre-worker.js' }));
|
||||
vi.mock('@mapbox/mapbox-gl-rtl-text/dist/mapbox-gl-rtl-text.js?url', () => ({ default: '/assets/rtl-plugin.js' }));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
mapboxSet.mockReset();
|
||||
maplibreSet.mockReset();
|
||||
maplibreWorker.mockReset();
|
||||
});
|
||||
|
||||
describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
@@ -37,13 +41,12 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
expect(mapboxSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), null, true);
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers it lazily', async () => {
|
||||
it('FE-MAP-RTL-002: importing the maplibre engine registers no plugin', async () => {
|
||||
await import('./maplibre');
|
||||
|
||||
expect(maplibreSet).toHaveBeenCalledTimes(1);
|
||||
// MapLibre 5 takes (url, lazy) and returns a promise, which is the reason
|
||||
// the call stays with each engine instead of going through one wrapper.
|
||||
expect(maplibreSet).toHaveBeenCalledWith(expect.stringContaining('rtl'), true);
|
||||
// MapLibre 6.9 shapes right-to-left text itself. The plugin would replace
|
||||
// that, and a v6 worker could only run its UMD build through eval.
|
||||
expect(maplibreSet).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-003: an engine still loads when its registration throws', async () => {
|
||||
@@ -54,13 +57,13 @@ describe('the GL engines register the RTL text plugin (#2235)', () => {
|
||||
expect(engine.default).toBeDefined();
|
||||
});
|
||||
|
||||
it('FE-MAP-RTL-004: a rejected registration promise does not surface as an unhandled rejection', async () => {
|
||||
maplibreSet.mockReturnValue(Promise.reject(new Error('already registered')));
|
||||
|
||||
it('FE-MAP-RTL-004: importing the maplibre engine points it at the bundled worker', async () => {
|
||||
const engine = await import('./maplibre');
|
||||
|
||||
expect(engine.default).toBeDefined();
|
||||
// Give the rejection a tick to go unhandled if nothing caught it.
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// MapLibre 6 looks for its worker next to its own module, which in a bundle
|
||||
// is a chunk with no worker beside it. Without this no map draws a tile.
|
||||
expect(maplibreWorker).toHaveBeenCalledTimes(1);
|
||||
expect(maplibreWorker).toHaveBeenCalledWith('/assets/maplibre-worker.js');
|
||||
expect(engine.default.setWorkerUrl).toBe(maplibreWorker);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Whether a map may be drawn with a GL engine (FE-MAP-GLSUP-*).
|
||||
*
|
||||
* MapLibre 6 and Mapbox GL 3 throw out of their Map constructor without WebGL2.
|
||||
* What is under test is the one decision every GL map goes through: the user's
|
||||
* provider when it can run here, Leaflet otherwise, and Leaflet from the moment a
|
||||
* GPU that claimed WebGL2 refuses the context after all.
|
||||
*/
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { hasWebGL, resetWebGLProbe } from '../../utils/webgl';
|
||||
import { buildGlMap, isGpuInitFailure, pickGlRenderer, useGlRenderer, useWebGL2 } from './glSupport';
|
||||
|
||||
const realGetContext = HTMLCanvasElement.prototype.getContext;
|
||||
|
||||
/** A browser whose canvas hands out a webgl2 context, or none. */
|
||||
function browserWithWebGL2(available: boolean) {
|
||||
const ctx = { getExtension: vi.fn(() => ({ loseContext: vi.fn() })) };
|
||||
const getContext = vi.fn((type: string) => (available && type === 'webgl2' ? ctx : null));
|
||||
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext;
|
||||
return getContext;
|
||||
}
|
||||
|
||||
function gpuError(): Error {
|
||||
const err = new Error('WebGL2 is required to display this map.');
|
||||
err.name = 'GPUInitializationError';
|
||||
return err;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
HTMLCanvasElement.prototype.getContext = realGetContext;
|
||||
resetWebGLProbe();
|
||||
});
|
||||
|
||||
describe('pickGlRenderer', () => {
|
||||
it('FE-MAP-GLSUP-001: keeps the GL provider the user picked when WebGL2 is there', () => {
|
||||
expect(pickGlRenderer('maplibre-gl', '', () => true)).toBe('maplibre-gl');
|
||||
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => true)).toBe('mapbox-gl');
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-002: draws with Leaflet when the browser has no WebGL2', () => {
|
||||
expect(pickGlRenderer('maplibre-gl', '', () => false)).toBeNull();
|
||||
expect(pickGlRenderer('mapbox-gl', 'pk.token', () => false)).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-003: Mapbox without a token stays on Leaflet, MapLibre needs none', () => {
|
||||
expect(pickGlRenderer('mapbox-gl', '', () => true)).toBeNull();
|
||||
expect(pickGlRenderer('maplibre-gl', null, () => true)).toBe('maplibre-gl');
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-004: a Leaflet user never pays for the probe', () => {
|
||||
const webgl2 = vi.fn(() => true);
|
||||
|
||||
expect(pickGlRenderer('leaflet', 'pk.token', webgl2)).toBeNull();
|
||||
expect(pickGlRenderer(undefined, undefined, webgl2)).toBeNull();
|
||||
expect(webgl2).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('isGpuInitFailure', () => {
|
||||
it('FE-MAP-GLSUP-005: knows both engines’ way of saying the GPU refused', () => {
|
||||
expect(isGpuInitFailure(gpuError())).toBe(true);
|
||||
expect(isGpuInitFailure(new Error('Failed to initialize WebGL.'))).toBe(true);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-006: anything else is not a GPU failure', () => {
|
||||
expect(isGpuInitFailure(new TypeError('container is null'))).toBe(false);
|
||||
expect(isGpuInitFailure('Failed to initialize WebGL')).toBe(false);
|
||||
expect(isGpuInitFailure(null)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildGlMap', () => {
|
||||
it('FE-MAP-GLSUP-007: hands back the map the engine built', () => {
|
||||
const map = { remove: vi.fn() };
|
||||
|
||||
expect(buildGlMap(() => map)).toBe(map);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-008: a GPU that claimed WebGL2 and refused it gives null and turns the answer to no', () => {
|
||||
browserWithWebGL2(true);
|
||||
expect(hasWebGL()).toBe(true);
|
||||
|
||||
expect(
|
||||
buildGlMap(() => {
|
||||
throw gpuError();
|
||||
})
|
||||
).toBeNull();
|
||||
expect(hasWebGL()).toBe(false);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-009: any other error is a bug and is thrown on', () => {
|
||||
browserWithWebGL2(true);
|
||||
|
||||
expect(() =>
|
||||
buildGlMap(() => {
|
||||
throw new TypeError('style is undefined');
|
||||
})
|
||||
).toThrow('style is undefined');
|
||||
expect(hasWebGL()).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useGlRenderer', () => {
|
||||
it('FE-MAP-GLSUP-010: asks the canvas for webgl2 only', () => {
|
||||
const getContext = browserWithWebGL2(false);
|
||||
|
||||
const { result } = renderHook(() => useGlRenderer('maplibre-gl', ''));
|
||||
|
||||
expect(result.current).toBeNull();
|
||||
expect(getContext.mock.calls.map((call) => call[0])).toEqual(['webgl2']);
|
||||
});
|
||||
|
||||
it('FE-MAP-GLSUP-011: switches every mounted map to Leaflet once a GL map fails to build', () => {
|
||||
browserWithWebGL2(true);
|
||||
const trip = renderHook(() => useGlRenderer('maplibre-gl', ''));
|
||||
const terrain = renderHook(() => useWebGL2());
|
||||
expect(trip.result.current).toBe('maplibre-gl');
|
||||
expect(terrain.result.current).toBe(true);
|
||||
|
||||
act(() => {
|
||||
buildGlMap(() => {
|
||||
throw gpuError();
|
||||
});
|
||||
});
|
||||
|
||||
expect(trip.result.current).toBeNull();
|
||||
expect(terrain.result.current).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useSyncExternalStore } from 'react';
|
||||
import { hasWebGL, reportWebGLFailure, subscribeWebGL, webGLFailures } from '../../utils/webgl';
|
||||
import type { GlMapProvider } from './glProviders';
|
||||
|
||||
/*
|
||||
* Whether a map may be drawn with a GL engine at all, for every place that would
|
||||
* build one: the trip map, the journey maps, the settings preview and the 3D tour.
|
||||
*
|
||||
* MapLibre 6 and Mapbox GL 3 both need WebGL2 and throw out of their Map
|
||||
* constructor without it. A browser that cannot give it gets the Leaflet map with
|
||||
* the user's tiles instead, never a map that fails to start. Like the GL
|
||||
* renderers it guards, this module stays free of both SDKs (`check:gl-split`).
|
||||
*/
|
||||
|
||||
/**
|
||||
* The GL engine to draw with, or null for Leaflet: the user's provider, as long
|
||||
* as it can run here. Mapbox also needs a token, MapLibre does not.
|
||||
*/
|
||||
export function pickGlRenderer(
|
||||
provider: string | null | undefined,
|
||||
mapboxToken: string | null | undefined,
|
||||
webgl2: () => boolean = hasWebGL
|
||||
): GlMapProvider | null {
|
||||
const wanted: GlMapProvider | null =
|
||||
provider === 'maplibre-gl' ? 'maplibre-gl' : provider === 'mapbox-gl' && mapboxToken ? 'mapbox-gl' : null;
|
||||
// Asked only for a GL provider: a Leaflet user never pays for the probe's context.
|
||||
return wanted && webgl2() ? wanted : null;
|
||||
}
|
||||
|
||||
/** Re-renders the caller when a GL map found out that WebGL2 does not work after all. */
|
||||
function useWebGLFailures(): number {
|
||||
return useSyncExternalStore(subscribeWebGL, webGLFailures, webGLFailures);
|
||||
}
|
||||
|
||||
/** pickGlRenderer for a component, switching to Leaflet the moment a GL map fails to build. */
|
||||
export function useGlRenderer(
|
||||
provider: string | null | undefined,
|
||||
mapboxToken: string | null | undefined
|
||||
): GlMapProvider | null {
|
||||
useWebGLFailures();
|
||||
return pickGlRenderer(provider, mapboxToken);
|
||||
}
|
||||
|
||||
/** Whether 3D (the GL terrain map) can run here, kept current the same way. */
|
||||
export function useWebGL2(): boolean {
|
||||
useWebGLFailures();
|
||||
return hasWebGL();
|
||||
}
|
||||
|
||||
/**
|
||||
* The GPU refusing a context, as either engine reports it from its Map
|
||||
* constructor. Matched on the name and message because the class itself lives in
|
||||
* the maplibre chunk, and Mapbox throws a plain Error.
|
||||
*/
|
||||
export function isGpuInitFailure(err: unknown): boolean {
|
||||
if (!(err instanceof Error)) return false;
|
||||
return err.name === 'GPUInitializationError' || /failed to initialize webgl/i.test(err.message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a GL map, the second line of defence behind the probe: a GPU that
|
||||
* claims WebGL2 and still fails is reported, so every map switches to Leaflet,
|
||||
* and the caller gets null instead of a throw. Anything else is a bug and is
|
||||
* thrown on.
|
||||
*/
|
||||
export function buildGlMap<T>(construct: () => T): T | null {
|
||||
try {
|
||||
return construct();
|
||||
} catch (err) {
|
||||
if (!isGpuInitFailure(err)) throw err;
|
||||
reportWebGLFailure();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,18 @@
|
||||
* this because their labels are drawn server-side, which is why it only surfaced
|
||||
* once the default basemap became a vector style.
|
||||
*
|
||||
* Both engines expose the fix as an optional plugin and neither loads it by
|
||||
* default. Registering is global to the engine, so it happens once per engine
|
||||
* module rather than at each map's construction site.
|
||||
* Mapbox GL exposes the fix as an optional plugin and does not load it by
|
||||
* default. Registering is global to the engine, so it happens once in the engine
|
||||
* module rather than at each map's construction site. MapLibre needed the same
|
||||
* plugin up to version 5; since 6.9 it shapes and reorders the text itself, and
|
||||
* its engine module registers nothing.
|
||||
*
|
||||
* The call itself stays with the caller: this is one of the places the two engines
|
||||
* genuinely differ. MapLibre 5 takes `(url, lazy)` and returns a promise, Mapbox
|
||||
* GL 3 takes `(url, callback, deferred)` and returns nothing — so each engine
|
||||
* module passes its own invocation rather than a shared wrapper guessing an arity.
|
||||
* The call itself stays with the caller: Mapbox GL 3 takes
|
||||
* `(url, callback, deferred)` and returns nothing, while MapLibre took
|
||||
* `(url, lazy)` and returned a promise, so the guard below accepts either rather
|
||||
* than guessing an arity.
|
||||
*
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it makes MapLibre
|
||||
* Deliberately no getRTLTextPluginStatus() pre-check: reading it made MapLibre
|
||||
* build its worker pool, which is an eager side effect for a module that is
|
||||
* imported for its default export, and throws outright under jsdom. Registering
|
||||
* twice is reported — by a throw or a rejected promise depending on the engine —
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
|
||||
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn() }))
|
||||
vi.mock('../../utils/webgl', () => ({ hasWebGL: vi.fn(() => true), resetWebGLProbe: vi.fn(), reportWebGLFailure: vi.fn() }))
|
||||
|
||||
const { hasWebGL } = await import('../../utils/webgl')
|
||||
const { renderTripMapImage } = await import('./tripMapImage')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { buildRouteOverlay, wrapSvg } from './tripMapSvg'
|
||||
import { hasWebGL } from '../../utils/webgl'
|
||||
import { normalizeStyleForProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import type { TripOverviewDay } from '../Map/tripRouteGeometry'
|
||||
import { importChunk } from '../../utils/chunkReload'
|
||||
|
||||
@@ -49,18 +50,20 @@ export async function renderTripMapImage(
|
||||
try {
|
||||
const gl = (await importChunk(() => import('../Map/engines/maplibre'))).default
|
||||
|
||||
container = document.createElement('div')
|
||||
// A const of its own: the map is built inside a closure, where the let's narrowing is gone.
|
||||
const box = document.createElement('div')
|
||||
container = box
|
||||
// Off-screen rather than hidden: MapLibre needs a laid-out box with real
|
||||
// dimensions, and `display:none` gives it a zero-sized canvas.
|
||||
container.style.cssText =
|
||||
box.style.cssText =
|
||||
`position:fixed;left:-10000px;top:0;width:${width}px;height:${height}px;pointer-events:none;`
|
||||
document.body.appendChild(container)
|
||||
document.body.appendChild(box)
|
||||
|
||||
const bounds = new gl.LngLatBounds()
|
||||
for (const [lat, lng] of points) bounds.extend([lng, lat])
|
||||
|
||||
const instance = new gl.Map({
|
||||
container,
|
||||
const instance = buildGlMap(() => new gl.Map({
|
||||
container: box,
|
||||
style: normalizeStyleForProvider('maplibre-gl', opts.style),
|
||||
bounds,
|
||||
fitBoundsOptions: { padding: 42, animate: false },
|
||||
@@ -70,7 +73,9 @@ export async function renderTripMapImage(
|
||||
// MapLibre 5 moved the WebGL flags off the top level into this bag.
|
||||
canvasContextAttributes: { preserveDrawingBuffer: true, antialias: true },
|
||||
fadeDuration: 0,
|
||||
})
|
||||
}))
|
||||
// A GPU that offered WebGL2 and then refused it: reported, so the app's maps follow.
|
||||
if (!instance) return null
|
||||
map = instance
|
||||
|
||||
const ready = await new Promise<boolean>(resolve => {
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { buildUser } from '../../../tests/helpers/factories';
|
||||
import { render, screen } from '../../../tests/helpers/render';
|
||||
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import AccountDemoLock from './AccountDemoLock';
|
||||
|
||||
beforeEach(() => resetAllStores());
|
||||
|
||||
function renderCards() {
|
||||
return render(
|
||||
<AccountDemoLock>
|
||||
<button type="button">Save profile</button>
|
||||
</AccountDemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AccountDemoLock', () => {
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-001: the shared demo account sees its cards under the lock, with the reason', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
const { container } = renderCards();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(screen.queryByRole('button', { name: 'Save profile' })).not.toBeInTheDocument();
|
||||
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
|
||||
screen.getByRole('button', { hidden: true, name: 'Save profile' })
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-002: the admin of a demo instance gets the cards as they are', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin' }) });
|
||||
const { container } = renderCards();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
|
||||
expect(container.querySelector('[data-demo-lock]')).toBeNull();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNTDEMOLOCK-003: the demo address on an instance that is not a demo is left alone', () => {
|
||||
seedStore(useAuthStore, { demoMode: false, user: buildUser({ email: 'demo@trek.app' }) });
|
||||
renderCards();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save profile' })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from '../../i18n';
|
||||
import DemoLock from '../Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock';
|
||||
|
||||
/**
|
||||
* The account cards under the demo lock, on the desktop tab and the phone
|
||||
* screen alike. Every visitor of a demo instance signs in as the one shared
|
||||
* demo account, so its profile, password, two factor setup and passkeys stay
|
||||
* on view but out of reach, while the admin of a demo instance and every
|
||||
* account of a real one get the cards as they are. Both views wrap their
|
||||
* cards in this, so whom the lock holds and the reason it gives are written
|
||||
* once.
|
||||
*/
|
||||
export default function AccountDemoLock({ children }: { children: ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const locked = useDemoAccountLock();
|
||||
return (
|
||||
<DemoLock locked={locked} reason={t('demo.lock.account')}>
|
||||
{children}
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
@@ -395,10 +395,20 @@ describe('AccountTab – MFA', () => {
|
||||
expect(screen.getByText(/requires two-factor authentication/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-036: MFA section shows demoBlocked message in demo mode', () => {
|
||||
seedStore(useAuthStore, { demoMode: true });
|
||||
it('FE-COMP-ACCOUNT-036: the shared demo account sees the whole tab under the demo lock', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
|
||||
const { container } = render(<AccountTab />);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(container.querySelector('[data-demo-lock] [inert]')).toContainElement(
|
||||
screen.getByRole('button', { hidden: true, name: /Set up authenticator/ }),
|
||||
);
|
||||
});
|
||||
|
||||
it('FE-COMP-ACCOUNT-036b: the admin of a demo instance keeps the tab unlocked, 2FA included', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'admin@trek.app', role: 'admin', mfa_enabled: false }) });
|
||||
render(<AccountTab />);
|
||||
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Set up authenticator/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
SETTINGS_BUTTON, SETTINGS_BUTTON_DANGER, SETTINGS_BUTTON_PRIMARY, SettingRow, SettingRows, SettingsHint, StatusPill,
|
||||
} from './settingsKit'
|
||||
import { stripTrailingSlashes, useAccountSettings } from './useAccountSettings'
|
||||
import AccountDemoLock from './AccountDemoLock'
|
||||
|
||||
/** The small turning ring a button shows while its request is on the way. */
|
||||
const SPINNER = 'h-3.5 w-3.5 flex-none animate-spin rounded-full border-2 border-current border-t-transparent'
|
||||
@@ -71,6 +72,8 @@ export default function AccountTab(): React.ReactElement {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
<Section title={t('settings.account')} icon={User}>
|
||||
{/* Who is signed in: the picture with its two actions, the role and the SSO link. */}
|
||||
<div className="flex items-center gap-4 rounded-[14px] border border-edge-faint bg-surface-card px-4 py-3.5">
|
||||
@@ -221,10 +224,7 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
)}
|
||||
<SettingsHint>{t('settings.mfa.description')}</SettingsHint>
|
||||
{demoMode ? (
|
||||
<p className="m-0 font-medium text-warning" style={fs(12.5, 'body')}>{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
@@ -357,12 +357,12 @@ export default function AccountTab(): React.ReactElement {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</Section>
|
||||
|
||||
{/* Passkeys */}
|
||||
<PasskeysSection demoMode={demoMode} />
|
||||
</AccountDemoLock>
|
||||
|
||||
{/* Delete Account Blocked */}
|
||||
<DialogShell
|
||||
|
||||
@@ -483,3 +483,18 @@ describe('DisplaySettingsTab week start (#2029)', () => {
|
||||
expect(await screen.findByText('Save failed')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('DisplaySettingsTab – the demo account', () => {
|
||||
it('FE-COMP-DISPLAY-DEMO-001: the shared demo account sees English under the demo lock', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
seedStore(useSettingsStore, { settings: buildSettings({ language: 'en' }), isLoaded: true });
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The demo always runs in English.');
|
||||
});
|
||||
|
||||
it('FE-COMP-DISPLAY-DEMO-002: any other account of a demo instance picks its language freely', () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
render(<DisplaySettingsTab />);
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -18,6 +18,8 @@ import { DEFAULT_START_PAGE, DEFAULT_START_TRIP_TAB } from '../../utils/startDes
|
||||
import type { DistanceUnit } from '../../types'
|
||||
import { DEFAULT_WEEK_START } from '@trek/shared'
|
||||
import { weekStartOptions } from '../../utils/calendarWeek'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
/** A select on the right of a row: wide enough for a currency or a weekday, never wider than the row. */
|
||||
const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
@@ -25,6 +27,7 @@ const SELECT_BOX = 'w-[260px] max-w-full'
|
||||
export default function DisplaySettingsTab(): React.ReactElement {
|
||||
const { settings, save } = useSettingSaver()
|
||||
const { t, locale } = useTranslation()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const [tempUnit, setTempUnit] = useState<string>(settings.temperature_unit || DEFAULT_SETTINGS.temperature_unit)
|
||||
const [distanceUnit, setDistanceUnit] = useState<DistanceUnit>(settings.distance_unit || DEFAULT_SETTINGS.distance_unit)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
@@ -123,7 +126,9 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
}
|
||||
/>
|
||||
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one */}
|
||||
{/* Language: every option at a glance on a wide window, a compact list on a narrow one.
|
||||
The shared demo account always runs in English. */}
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
|
||||
<SettingRow label={t('settings.language')} stacked>
|
||||
<div className="hidden sm:block">
|
||||
<ChoiceChips
|
||||
@@ -162,6 +167,7 @@ export default function DisplaySettingsTab(): React.ReactElement {
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
</DemoLock>
|
||||
|
||||
{/* Place names (#1799): searches can answer in another language than the app. */}
|
||||
<SettingRow
|
||||
|
||||
@@ -18,10 +18,13 @@ import { SETTINGS_BUTTON, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON, Setting
|
||||
import { OAUTH_PRESETS, useMcpIntegration, type OAuthClient } from './useMcpIntegration'
|
||||
import { useIntegrationGates } from './useIntegrationGates'
|
||||
import WandererConnectionSection from './WandererConnectionSection'
|
||||
import DemoLock from '../Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../Demo/useDemoLock'
|
||||
|
||||
export default function IntegrationsTab(): React.ReactElement {
|
||||
const S = useIntegrations()
|
||||
const managed = S.managed
|
||||
const demoLocked = useDemoAccountLock()
|
||||
return (
|
||||
<>
|
||||
{/* Immich, Synology Photos and AirTrail all connect to a server the reader
|
||||
@@ -42,9 +45,15 @@ export default function IntegrationsTab(): React.ReactElement {
|
||||
{S.llmEnabled && S.ownParsing && !managed && <LlmConnectionSection />}
|
||||
{/* Above MCP on purpose: an API key needs no addon, and someone looking for
|
||||
one should not have to read past a section about AI assistants. */}
|
||||
<ApiKeysSection />
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.apiKeys')}>
|
||||
<ApiKeysSection />
|
||||
</DemoLock>
|
||||
{/* TREK inside Claude rides on the MCP server, so it follows that addon. */}
|
||||
{S.mcpEnabled && <IntegrationsMcpSection S={S} />}
|
||||
{S.mcpEnabled && (
|
||||
<DemoLock locked={demoLocked} reason={S.t('demo.lock.mcp')}>
|
||||
<IntegrationsMcpSection S={S} />
|
||||
</DemoLock>
|
||||
)}
|
||||
<McpTokenModals S={S} />
|
||||
<OAuthClientModals S={S} />
|
||||
</>
|
||||
|
||||
@@ -33,6 +33,12 @@ vi.mock('./MapboxPreview', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
// jsdom has no WebGL2; these cases are about a browser that has it.
|
||||
vi.mock('../../utils/webgl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/webgl')>()),
|
||||
hasWebGL: () => true,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
resetAllStores();
|
||||
vi.clearAllMocks();
|
||||
|
||||
@@ -132,7 +132,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, save: saveMapSettings,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save: saveMapSettings,
|
||||
} = useMapSettingsForm()
|
||||
// One chunk per engine — see components/Map/glLazy.tsx.
|
||||
const GlMapPreview = provider === 'maplibre-gl' ? GlMapPreviewMaplibre : GlMapPreviewMapbox
|
||||
@@ -318,7 +318,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
)}
|
||||
|
||||
<div className="relative h-[200px] w-full overflow-hidden rounded-[14px] border border-edge-faint bg-surface-tertiary">
|
||||
{provider !== 'leaflet' ? (
|
||||
{glPreview ? (
|
||||
/* A net of its own: the preview is the one place a user flips providers
|
||||
live, so it is the likeliest chunk to fail — and a broken preview must
|
||||
not take the rest of the settings tab with it. */
|
||||
@@ -363,6 +363,7 @@ export default function MapSettingsTab(): React.ReactElement {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{webgl2Missing && <SettingsHint>{t('map.webgl2.fallback')}</SettingsHint>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button type="button"
|
||||
|
||||
@@ -32,7 +32,7 @@ const mapboxModule = vi.hoisted(() => ({ accessToken: '', Map: mapboxCtor }));
|
||||
|
||||
vi.mock('mapbox-gl', () => ({ default: mapboxModule }));
|
||||
vi.mock('mapbox-gl/dist/mapbox-gl.css', () => ({}));
|
||||
vi.mock('maplibre-gl', () => ({ default: { Map: maplibreCtor } }));
|
||||
vi.mock('maplibre-gl', () => ({ Map: maplibreCtor }));
|
||||
vi.mock('maplibre-gl/dist/maplibre-gl.css', () => ({}));
|
||||
|
||||
vi.mock('../Map/mapboxSetup', () => ({
|
||||
@@ -45,7 +45,7 @@ vi.mock('../Map/mapboxSetup', () => ({
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup';
|
||||
import { MAPBOX_DEFAULT_STYLE, OPENFREEMAP_DEFAULT_STYLE } from '../Map/glProviders';
|
||||
import mapboxgl from 'mapbox-gl';
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import * as maplibregl from 'maplibre-gl';
|
||||
import GlMapPreviewWithEngine from './MapboxPreview';
|
||||
|
||||
// The engine is a prop now, not a module import — that is what keeps mapbox-gl and
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { isStandardFamily, supportsCustom3d, addCustom3dBuildings, addTerrainAndSky } from '../Map/mapboxSetup'
|
||||
import { MAPBOX_DEFAULT_STYLE, normalizeStyleForProvider, type GlMapProvider } from '../Map/glProviders'
|
||||
import { buildGlMap } from '../Map/glSupport'
|
||||
import { fs } from '../shared/DialogShell'
|
||||
|
||||
interface Props {
|
||||
@@ -46,11 +47,13 @@ export default function GlMapPreview({ provider = 'mapbox-gl', token = '', style
|
||||
antialias: quality,
|
||||
}
|
||||
if (!isMapLibre) mapOptions.projection = quality ? 'globe' : 'mercator'
|
||||
// MapLibre 5's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// MapLibre's around-center mouse rotate ping-pongs near mid-screen (#1545)
|
||||
// — see MapViewGL. Keep the plain dx-based rotate everywhere.
|
||||
if (isMapLibre) mapOptions.aroundCenter = false
|
||||
|
||||
const map = new gl.Map(mapOptions as any)
|
||||
// Null when the GPU refused WebGL2: the settings form has switched the preview to Leaflet by then.
|
||||
const map = buildGlMap(() => new gl.Map(mapOptions as any))
|
||||
if (!map) return
|
||||
mapRef.current = map
|
||||
|
||||
map.on('load', () => {
|
||||
|
||||
@@ -140,4 +140,13 @@ describe('AiConnectionSection', () => {
|
||||
const { container } = render(<AiConnectionSection />);
|
||||
await waitFor(() => expect(container).toBeEmptyDOMElement());
|
||||
});
|
||||
|
||||
it('FE-COMP-AI-CONNECTION-008: the shared demo account sees its own connection locked, and no admin hint', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<AiConnectionSection />);
|
||||
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
|
||||
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { hidden: true, name: 'Connect your own provider' })).toBeDisabled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '../../../i18n';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../Assistant/useAssistantMapFollow';
|
||||
import DemoLock from '../../Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../../Demo/useDemoLock';
|
||||
import ConfirmDialog from '../../shared/ConfirmDialog';
|
||||
import CustomSelect from '../../shared/CustomSelect';
|
||||
import { fs } from '../../shared/DialogShell';
|
||||
@@ -202,6 +204,25 @@ function AssistantBehaviour() {
|
||||
);
|
||||
}
|
||||
|
||||
/** Where the shared demo account would connect a provider of its own: shown, and locked. */
|
||||
function LockedOwnConnection() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<DemoLock locked reason={t('demo.lock.ai')} size="row">
|
||||
<SettingRows>
|
||||
<SettingRow
|
||||
label={t('demo.lock.aiTitle')}
|
||||
control={
|
||||
<button type="button" disabled className={SETTINGS_BUTTON} style={fs(13, 'body')}>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
</SettingRows>
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
/** The connect panel, opened on purpose or because nothing is connected yet. */
|
||||
function ConnectPanel({ S }: { S: AiConnections }) {
|
||||
const { t } = useTranslation();
|
||||
@@ -254,6 +275,7 @@ export default function AiConnectionSection({
|
||||
}): React.ReactElement | null {
|
||||
const { t } = useTranslation();
|
||||
const S = useAiConnections();
|
||||
const demoLocked = useDemoAccountLock();
|
||||
const text = aiSectionText(assistant, parsing);
|
||||
if (S.view === 'hidden') return null;
|
||||
const own = S.own;
|
||||
@@ -277,7 +299,8 @@ export default function AiConnectionSection({
|
||||
{S.view === 'own' && own && <OwnConnection S={S} own={own} />}
|
||||
{S.view === 'instance' && <InstanceConnection S={S} />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <AssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
{S.view === 'adminOnly' && !demoLocked && <SettingsHint>{t('assistant.setup.adminOnly')}</SettingsHint>}
|
||||
{demoLocked && S.view !== 'loading' && <LockedOwnConnection />}
|
||||
|
||||
{S.panel && <ConnectPanel S={S} />}
|
||||
<SectionFooter S={S} />
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useAuthStore } from '../../store/authStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import type { Place } from '../../types';
|
||||
import { normalizeStyleForProvider } from '../Map/glProviders';
|
||||
import { useWebGL2 } from '../Map/glSupport';
|
||||
import { useToast } from '../shared/Toast';
|
||||
import { type MapProvider, normalizeProvider, previewPlace, slotStyle, styleForProvider } from './mapSettingsModel';
|
||||
|
||||
@@ -51,6 +52,11 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
}, [settings]);
|
||||
|
||||
const previewPlaces = useMemo((): Place[] => [previewPlace()], []);
|
||||
// Both GL engines need WebGL2. Without it the preview shows the Leaflet map the
|
||||
// trip planner falls back to, and both shells say why.
|
||||
const webgl2 = useWebGL2();
|
||||
const glPreview = provider !== 'leaflet' && webgl2;
|
||||
const webgl2Missing = provider !== 'leaflet' && !webgl2;
|
||||
|
||||
const save = async (): Promise<void> => {
|
||||
setSaving(true);
|
||||
@@ -105,6 +111,8 @@ export function useMapSettingsForm({ mirrorStyleBeforeSave = false }: MapSetting
|
||||
mapboxQuality,
|
||||
setMapboxQuality,
|
||||
previewPlaces,
|
||||
glPreview,
|
||||
webgl2Missing,
|
||||
save,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { GeoJSONSource, Map as MapLibreMap } from 'maplibre-gl';
|
||||
import { useCallback, useEffect, useRef, useState, type RefObject } from 'react';
|
||||
import type { TourBaseLayer } from '../../Map/MapLayerSwitcher';
|
||||
import { buildGlMap } from '../../Map/glSupport';
|
||||
import { isTourLayerHit } from '../../Map/mapRasterOverlay';
|
||||
import {
|
||||
addTerrainLayers,
|
||||
@@ -110,8 +111,11 @@ export function useTerrainMap({ scene, baseLayer, data, exaggeration }: Options)
|
||||
let map: MapLibreMap | null = null;
|
||||
void import('../../Map/engines/maplibre').then(({ default: engine }) => {
|
||||
if (cancelled) return;
|
||||
map = createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle });
|
||||
setGl({ map, engine });
|
||||
// Null when the GPU refused WebGL2. The report switches the planner back to the 2D map.
|
||||
map = buildGlMap(() =>
|
||||
createTerrainMap(engine, container, scene, { onReady: markReady, onStyleIdle: countStyle })
|
||||
);
|
||||
if (map) setGl({ map, engine });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
|
||||
@@ -14,7 +14,7 @@ import { useInAppNotificationStore } from '../../../store/inAppNotificationStore
|
||||
import { useDashboardPlugins } from '../../../components/Dashboard/useDashboardPlugins'
|
||||
import { useAssistantHomeLauncher } from '../../../components/Assistant/useAssistantHome'
|
||||
import type { TripCardBadge } from '../../../api/client'
|
||||
import DemoBanner from '../../../components/Layout/DemoBanner'
|
||||
import DemoWelcome from '../../../components/Demo/DemoWelcome'
|
||||
import { IcsSubscribeModal } from '../../../components/Planner/IcsSubscribeModal'
|
||||
import PluginWidgets from '../../../components/Plugins/PluginWidgets'
|
||||
import { entityGradient } from '../../../utils/gradients'
|
||||
@@ -247,7 +247,7 @@ export default function MDashboard(): React.ReactElement {
|
||||
|
||||
<MUserMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
|
||||
|
||||
{demoMode && <DemoBanner />}
|
||||
{demoMode && <DemoWelcome />}
|
||||
|
||||
<div className="px-4 pb-[calc(var(--bottom-nav-h,84px)+32px)] pt-[calc(var(--m-safe-top,12px)+82px)]">
|
||||
{loadError && (
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import type { AiConnectionView, AiStatusResponse } from '@trek/shared';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { buildUser } from '../../../../tests/helpers/factories';
|
||||
import { render, screen, waitFor } from '../../../../tests/helpers/render';
|
||||
import { seedStore } from '../../../../tests/helpers/store';
|
||||
import { useAssistantStore } from '../../../store/assistantStore';
|
||||
import { useAuthStore } from '../../../store/authStore';
|
||||
|
||||
const repo = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
@@ -83,4 +86,20 @@ describe('MAiConnectionSection', () => {
|
||||
expect(await screen.findByText('What would you like to chat with?')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Paste an API key/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-DEMO-001: the shared demo account sees the place for its own connection locked', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'demo@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<MAiConnectionSection />);
|
||||
expect(await screen.findByRole('note')).toHaveTextContent('Your own AI connection cannot be added in the demo.');
|
||||
expect(screen.queryByText('Your admin sets this up.')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-AI-DEMO-002: any other account keeps the admin hint', async () => {
|
||||
seedStore(useAuthStore, { user: buildUser({ email: 'admin@trek.app' }), isAuthenticated: true, demoMode: true });
|
||||
repo.status.mockResolvedValue(status({ canConnectOwn: false }));
|
||||
render(<MAiConnectionSection />);
|
||||
expect(await screen.findByText('Your admin sets this up.')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('note')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { AiConnectionView } from '@trek/shared';
|
||||
import { ChevronDown, Loader2, LogOut, MessageCircle, Sparkles, Unplug } from 'lucide-react';
|
||||
import React, { useState } from 'react';
|
||||
import { useAssistantCostEstimates, useAssistantMapFollow } from '../../../components/Assistant/useAssistantMapFollow';
|
||||
import DemoLock from '../../../components/Demo/DemoLock';
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock';
|
||||
import AiConnectPanel from '../../../components/Settings/ai/AiConnectPanel';
|
||||
import { aiSectionText, CONNECTION_STATUS } from '../../../components/Settings/ai/aiConnectModel';
|
||||
import { useAiConnections } from '../../../components/Settings/ai/useAiConnections';
|
||||
@@ -117,6 +119,30 @@ function MInstanceConnection({ S }: { S: AiConnections }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Under the connection: the hint that only the admin connects one, or, for the
|
||||
* shared demo account, the locked place where its own would go.
|
||||
*/
|
||||
function MOwnConnectionNote({ view }: { view: AiConnections['view'] }) {
|
||||
const { t } = useTranslation();
|
||||
const demoLocked = useDemoAccountLock();
|
||||
if (view === 'loading') return null;
|
||||
if (!demoLocked) return view === 'adminOnly' ? <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint> : null;
|
||||
return (
|
||||
<DemoLock locked reason={t('demo.lock.ai')} size="row" className="mt-3">
|
||||
<MSetRow
|
||||
first
|
||||
label={t('demo.lock.aiTitle')}
|
||||
trailing={
|
||||
<MSetButton variant="ghost" disabled>
|
||||
{t('assistant.errorAction.connectOwn')}
|
||||
</MSetButton>
|
||||
}
|
||||
/>
|
||||
</DemoLock>
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether the trip map follows the assistant, the phone's row for the shared switch. */
|
||||
function MAssistantBehaviour() {
|
||||
const { t } = useTranslation();
|
||||
@@ -180,7 +206,7 @@ export default function MAiConnectionSection({
|
||||
{S.view === 'own' && S.own && <MOwnConnection S={S} own={S.own} />}
|
||||
{S.view === 'instance' && <MInstanceConnection S={S} />}
|
||||
{assistant && (S.view === 'own' || S.view === 'instance') && <MAssistantBehaviour />}
|
||||
{S.view === 'adminOnly' && <MSetHint>{t('assistant.setup.adminOnly')}</MSetHint>}
|
||||
<MOwnConnectionNote view={S.view} />
|
||||
|
||||
{S.panel && (
|
||||
<div
|
||||
|
||||
@@ -22,6 +22,7 @@ import MConfirmSheet from './MConfirmSheet'
|
||||
import PasswordChecklist from '../../../components/shared/PasswordChecklist'
|
||||
import { fmtDate, usePasskeys } from '../../../components/Settings/usePasskeys'
|
||||
import { stripTrailingSlashes, useAccountSettings } from '../../../components/Settings/useAccountSettings'
|
||||
import AccountDemoLock from '../../../components/Settings/AccountDemoLock'
|
||||
|
||||
/**
|
||||
* "Account" section — AccountTab parity: profile + avatar, password change,
|
||||
@@ -78,6 +79,8 @@ export default function MSettingsAccount() {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* The shared demo account: everything here is shown, nothing can be changed. */}
|
||||
<AccountDemoLock>
|
||||
{/* ── Profile ─────────────────────────────────────────────── */}
|
||||
<MSetCard title={t('settings.account')} icon={User}>
|
||||
<div className="mb-4 flex items-center gap-4">
|
||||
@@ -185,10 +188,7 @@ export default function MSettingsAccount() {
|
||||
)}
|
||||
<p className="text-[0.75rem] leading-relaxed text-m-muted">{t('settings.mfa.description')}</p>
|
||||
|
||||
{demoMode ? (
|
||||
<p className="mt-2 text-[0.75rem] font-semibold text-[color:var(--m-st-pending)]">{t('settings.mfa.demoBlocked')}</p>
|
||||
) : (
|
||||
<>
|
||||
<>
|
||||
<p className="mt-2 text-[0.78125rem] font-bold text-m-ink">
|
||||
{user?.mfa_enabled ? t('settings.mfa.enabled') : t('settings.mfa.disabled')}
|
||||
</p>
|
||||
@@ -287,11 +287,11 @@ export default function MSettingsAccount() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
</MSetCard>
|
||||
|
||||
<MPasskeysCard demoMode={demoMode} />
|
||||
</AccountDemoLock>
|
||||
|
||||
<MConfirmSheet
|
||||
open={showDeleteConfirm === 'blocked'}
|
||||
|
||||
@@ -12,6 +12,8 @@ import { MSetCard, MSetEyebrow, MSetHint, MSetSelectRow, MSetSegments, MSetRow }
|
||||
import MToggle from '../../components/MToggle'
|
||||
import MSetPickerSheet from './MSetPickerSheet'
|
||||
import { preferredNavAppOptions } from '../../../components/Planner/placeNavigation'
|
||||
import DemoLock from '../../../components/Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
|
||||
|
||||
/**
|
||||
* "General" settings section — the demo's Language & region and Travel & map
|
||||
@@ -20,6 +22,7 @@ import { preferredNavAppOptions } from '../../../components/Planner/placeNavigat
|
||||
export default function MSettingsGeneral() {
|
||||
const { t, locale } = useTranslation()
|
||||
const { settings, save } = useSettingSaver()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const [currencyOpen, setCurrencyOpen] = useState(false)
|
||||
const [langOpen, setLangOpen] = useState(false)
|
||||
const [startTabOpen, setStartTabOpen] = useState(false)
|
||||
@@ -121,8 +124,11 @@ export default function MSettingsGeneral() {
|
||||
<MSetEyebrow className="mb-[5px]">{t('settings.currency')}</MSetEyebrow>
|
||||
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
|
||||
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
|
||||
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
|
||||
{/* The shared demo account always runs in English. */}
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.language')} size="row">
|
||||
<MSetEyebrow className="mb-[5px] mt-[14px]">{t('settings.language')}</MSetEyebrow>
|
||||
<MSetSelectRow label={language.label} trailing={chevron} onClick={() => setLangOpen(true)} />
|
||||
</DemoLock>
|
||||
|
||||
<MSetEyebrow className="mb-[6px] mt-[14px]">{t('settings.temperature')}</MSetEyebrow>
|
||||
<MSetSegments
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function MSettingsMap() {
|
||||
const {
|
||||
managed, saving, provider, changeProvider, mapTileUrl, setMapTileUrl, mapboxToken, setMapboxToken,
|
||||
cartoKey, setCartoKey, cartoNeedsKey, mapboxStyle, setMapboxStyle, mapbox3d, setMapbox3d,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, save,
|
||||
mapboxQuality, setMapboxQuality, previewPlaces, glPreview, webgl2Missing, save,
|
||||
} = useMapSettingsForm({ mirrorStyleBeforeSave: true })
|
||||
const [presetOpen, setPresetOpen] = useState(false)
|
||||
const [styleOpen, setStyleOpen] = useState(false)
|
||||
@@ -170,7 +170,7 @@ export default function MSettingsMap() {
|
||||
)}
|
||||
|
||||
<div className="relative mt-3 h-[200px] w-full overflow-hidden rounded-xl">
|
||||
{provider !== 'leaflet' ? (
|
||||
{glPreview ? (
|
||||
/* See MapSettingsTab: the preview gets its own net. */
|
||||
<ErrorBoundary boundaryId="settings:map-preview" resetKeys={[provider]} fallback={<div className="h-full w-full bg-m-card" />}>
|
||||
<Suspense fallback={<div className="h-full w-full bg-m-card animate-pulse" />}>
|
||||
@@ -208,6 +208,7 @@ export default function MSettingsMap() {
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{webgl2Missing && <MSetHint>{t('map.webgl2.fallback')}</MSetHint>}
|
||||
|
||||
<MSetButton className="mt-3" onClick={save} disabled={saving}>
|
||||
<Save size={14} />
|
||||
|
||||
@@ -8,6 +8,8 @@ import MChip from '../../components/MChip'
|
||||
import { MSetCard, MSetEyebrow, MSetInput, MSetTextarea, MSetButton, MSetHint, MSetRow } from './MSettingsUi'
|
||||
import MConfirmSheet from './MConfirmSheet'
|
||||
import MSegmented from '../../components/MSegmented'
|
||||
import DemoLock from '../../../components/Demo/DemoLock'
|
||||
import { useDemoAccountLock } from '../../../components/Demo/useDemoLock'
|
||||
|
||||
function CopyButton({ text, copied, onCopy }: { text: string; copied: boolean; onCopy: (text: string) => void }) {
|
||||
return (
|
||||
@@ -37,6 +39,7 @@ function CodeBlock({ children }: { children: string }) {
|
||||
*/
|
||||
export default function MSettingsMcp() {
|
||||
const { t, locale } = useTranslation()
|
||||
const demoLocked = useDemoAccountLock()
|
||||
const {
|
||||
activeMcpTab: activeTab,
|
||||
setActiveMcpTab: setActiveTab,
|
||||
@@ -119,6 +122,7 @@ export default function MSettingsMcp() {
|
||||
)
|
||||
|
||||
return (
|
||||
<DemoLock locked={demoLocked} reason={t('demo.lock.mcp')}>
|
||||
<MSetCard title={t('settings.mcp.title')} icon={Terminal} className="mt-3">
|
||||
<MSetEyebrow className="mb-[5px]">{t('settings.mcp.endpoint')}</MSetEyebrow>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -518,5 +522,6 @@ export default function MSettingsMcp() {
|
||||
onConfirm={() => tokenDeleteId !== null && deleteToken(tokenDeleteId)}
|
||||
/>
|
||||
</MSetCard>
|
||||
</DemoLock>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -331,7 +331,7 @@ function ChatBubbleRow({ msg, own, showHeader, isLastInGroup, marginTop, is12h,
|
||||
{hasReply && (
|
||||
<div className="mb-1 rounded-[10px] bg-[color:var(--m-card)] px-[10px] py-[5px]">
|
||||
<div className="font-geist text-[0.625rem] font-bold opacity-70">{msg.reply_username}</div>
|
||||
<div className="truncate text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
<div className="line-clamp-2 text-[0.71875rem] opacity-80">{msg.reply_text?.slice(0, 80)}</div>
|
||||
</div>
|
||||
)}
|
||||
<span className="whitespace-pre-wrap break-words">{msg.text}</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react'
|
||||
import { useTranslation } from '../i18n'
|
||||
import Navbar from '../components/Layout/Navbar'
|
||||
import DemoBanner from '../components/Layout/DemoBanner'
|
||||
import DemoWelcome from '../components/Demo/DemoWelcome'
|
||||
import TripFormModal from '../components/Trips/TripFormModal'
|
||||
import ConfirmDialog from '../components/shared/ConfirmDialog'
|
||||
import CopyTripDialog from '../components/shared/CopyTripDialog'
|
||||
@@ -138,7 +138,7 @@ function DashboardPageDesktop(): React.ReactElement {
|
||||
<Navbar />
|
||||
<HelpAnchor id="dashboard" />
|
||||
<div className="trek-dash trek-dash-shell">
|
||||
{demoMode && <DemoBanner />}
|
||||
{demoMode && <DemoWelcome />}
|
||||
<div className="trek-dash-scroll">
|
||||
<MobileTopBar />
|
||||
<main className="page" data-no-sidebar={sidebarVisible ? undefined : 'true'}>
|
||||
|
||||
@@ -5,11 +5,13 @@ import { act, fireEvent, render, screen, waitFor } from '../../../tests/helpers/
|
||||
import { idleLegVariants, idleRoundHikes } from '../../../tests/helpers/tourPlannerStub';
|
||||
import { useTripFerratas } from '../../components/Ferratas/useTripFerratas';
|
||||
import type { MapViewProps } from '../../components/Map/mapViewContract';
|
||||
import { ToastContainer } from '../../components/shared/Toast';
|
||||
import { useTourMapView } from '../../components/Tours/planner/useTourMapView';
|
||||
import { clearPaddleObstacles, publishPaddleObstacles, selectPaddleObstacle } from '../../store/paddleObstacleStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { useTourMapViewportStore } from '../../store/tourMapViewportStore';
|
||||
import { initialTourPlaceToolState, useTourPlaceToolStore } from '../../store/tourPlaceToolStore';
|
||||
import { resetWebGLProbe } from '../../utils/webgl';
|
||||
import { TourPlannerMapStage, type TourPlannerMapStageProps } from './TourPlannerMapStage';
|
||||
|
||||
type Handler = (event?: unknown) => void;
|
||||
@@ -75,6 +77,12 @@ const stage = vi.hoisted(() => {
|
||||
});
|
||||
|
||||
vi.mock('../../components/Map/engines/maplibre', () => ({ default: stage.engine }));
|
||||
// jsdom has no WebGL2: the stage is told it is there, except where a case takes it away.
|
||||
const webgl = vi.hoisted(() => ({ available: true }));
|
||||
vi.mock('../../utils/webgl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../utils/webgl')>()),
|
||||
hasWebGL: () => webgl.available,
|
||||
}));
|
||||
vi.mock('../../components/Map/MapViewAuto', () => ({
|
||||
MapViewAuto: (props: MapViewProps) => {
|
||||
stage.flatMap.props = props;
|
||||
@@ -185,6 +193,8 @@ async function openTerrain() {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
webgl.available = true;
|
||||
resetWebGLProbe();
|
||||
window.sessionStorage.clear();
|
||||
useTourPlaceToolStore.setState(initialTourPlaceToolState());
|
||||
stage.listeners.clear();
|
||||
@@ -928,3 +938,44 @@ describe('TourPlannerMapStage', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('3D without WebGL2', () => {
|
||||
// The stage translates with the t it is handed, and stageProps hands it one that returns the key.
|
||||
const NO_3D = 'map.webgl2.terrain';
|
||||
|
||||
function renderWithToasts(tourPlanner: Planner) {
|
||||
return render(
|
||||
<>
|
||||
<ToastContainer />
|
||||
<TourPlannerMapStage {...stageProps(tourPlanner, true)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-WEBGL-001: the 3D switch keeps the 2D map and says why', async () => {
|
||||
webgl.available = false;
|
||||
renderWithToasts(planner('edit-saved'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
|
||||
|
||||
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'flat map' })).toBeInTheDocument();
|
||||
expect(stage.engine.Map).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('FE-PAGE-TOURSTAGE-WEBGL-002: a GPU that refuses the terrain context brings the 2D map back', async () => {
|
||||
stage.engine.Map.mockImplementationOnce(function () {
|
||||
webgl.available = false;
|
||||
const err = new Error('WebGL2 is required to display this map.');
|
||||
err.name = 'GPUInitializationError';
|
||||
throw err;
|
||||
});
|
||||
renderWithToasts(planner('edit-saved'));
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'flat map' }));
|
||||
|
||||
expect(await screen.findByText(NO_3D)).toBeInTheDocument();
|
||||
expect(await screen.findByRole('button', { name: 'flat map' })).toBeInTheDocument();
|
||||
expect(stage.engine.Map).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,11 +5,13 @@ import FerrataDetailOverlay from '../../components/Ferratas/FerrataDetailOverlay
|
||||
import { ferrataLabel, type FerrataGroup } from '../../components/Ferratas/ferrataGroups';
|
||||
import { useFerrataMapLayer } from '../../components/Ferratas/useFerrataMapLayer';
|
||||
import { useOutsidePressGuard } from '../../components/Ferratas/useOutsidePressGuard';
|
||||
import { useWebGL2 } from '../../components/Map/glSupport';
|
||||
import { MAP_LAYER_SWITCHER_INSET, MAP_ROUND_CONTROL_SIZE } from '../../components/Map/MapLayerSwitcher';
|
||||
import { MapUndoHint } from '../../components/Map/MapUndoHint';
|
||||
import { MapViewAuto as MapView } from '../../components/Map/MapViewAuto';
|
||||
import type { AssistantMapProps } from '../../components/Map/mapViewContract';
|
||||
import LocationSelect from '../../components/Planner/LocationSelect';
|
||||
import { useToast } from '../../components/shared/Toast';
|
||||
import { ChecklistObstaclePanel } from '../../components/Tours/paddle/ObstacleDecisionPanel';
|
||||
import PaddleObstacleOverlay from '../../components/Tours/paddle/PaddleObstacleOverlay';
|
||||
import { PaddleObstaclePlaceButton } from '../../components/Tours/paddle/PaddleObstaclePlaceButton';
|
||||
@@ -25,6 +27,7 @@ import { selectPaddleObstacle, usePaddleObstacleStore } from '../../store/paddle
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { publishTourFerrataCrossings } from '../../store/tourFerrataCrossingStore';
|
||||
import { publishTourMapViewport } from '../../store/tourMapViewportStore';
|
||||
import { subscribeWebGL } from '../../utils/webgl';
|
||||
import { useFerrataAdopt } from './useFerrataAdopt';
|
||||
import { useOneCornerCard } from './useOneCornerCard';
|
||||
import { useTourMapPoints } from './useTourMapPoints';
|
||||
@@ -112,7 +115,22 @@ export function TourPlannerMapStage({
|
||||
paddleChecklist,
|
||||
assistantRoutes,
|
||||
}: Readonly<TourPlannerMapStageProps>): ReactElement {
|
||||
const [terrain3d, setTerrain3d] = useState(false);
|
||||
const [terrainAsked, setTerrain3d] = useState(false);
|
||||
// The terrain is a MapLibre map, so it needs WebGL2. Without it, or once its GPU
|
||||
// refused the context, the stage stays on the 2D map and says why.
|
||||
const webgl2 = useWebGL2();
|
||||
const terrain3d = terrainAsked && webgl2;
|
||||
const toast = useToast();
|
||||
const openTerrain = useCallback(() => {
|
||||
if (webgl2) setTerrain3d(true);
|
||||
else toast.info(t('map.webgl2.terrain'));
|
||||
}, [webgl2, toast, t]);
|
||||
// A GPU that offered WebGL2 and then refused the terrain's context: the 2D map is
|
||||
// back by itself, and this says why.
|
||||
useEffect(() => {
|
||||
if (!terrainAsked) return;
|
||||
return subscribeWebGL(() => toast.info(t('map.webgl2.terrain')));
|
||||
}, [terrainAsked, toast, t]);
|
||||
// How high the 3D map's controls reach: one row, or two once the grade legend moved above the dock.
|
||||
const [terrainControlsPx, setTerrainControlsPx] = useState(MAP_ROUND_CONTROL_SIZE);
|
||||
const [terrainLegend, setTerrainLegend] = useState<TerrainLegendLayout | null>(null);
|
||||
@@ -287,7 +305,7 @@ export function TourPlannerMapStage({
|
||||
viewBaseLayer={baseLayer}
|
||||
onViewBaseLayerChange={tourPlanner.setMapBaseLayer}
|
||||
tourLayers={tourLayers}
|
||||
onToggleTerrain3d={() => setTerrain3d(true)}
|
||||
onToggleTerrain3d={openTerrain}
|
||||
ferrataLayer={ferrataLayer}
|
||||
onViewportChange={viewportChanged}
|
||||
placeTool={placeTool}
|
||||
|
||||
@@ -42,15 +42,15 @@ describe('hasWebGL', () => {
|
||||
expect(hasWebGL()).toBe(false)
|
||||
})
|
||||
|
||||
it('FE-UTIL-WEBGL-003: falls back to webgl1 when only webgl2 is refused', () => {
|
||||
// Exactly the order maplibre-gl asks in. A stricter probe would black out
|
||||
// maps that would in fact have drawn.
|
||||
it('FE-UTIL-WEBGL-003: says no when only webgl1 is on offer', () => {
|
||||
// MapLibre 6 asks for webgl2 and nothing else. A probe that settled for
|
||||
// webgl1 would wave the map through to a GPUInitializationError.
|
||||
const { ctx } = fakeContext()
|
||||
const getContext = vi.fn((type: string) => (type === 'webgl2' ? null : ctx))
|
||||
HTMLCanvasElement.prototype.getContext = getContext as unknown as typeof realGetContext
|
||||
|
||||
expect(hasWebGL()).toBe(true)
|
||||
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2', 'webgl'])
|
||||
expect(hasWebGL()).toBe(false)
|
||||
expect(getContext.mock.calls.map((c) => c[0])).toEqual(['webgl2'])
|
||||
})
|
||||
|
||||
it('FE-UTIL-WEBGL-004: gives the probe context straight back', () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* Whether this browser will hand MapLibre a WebGL context.
|
||||
* Whether this browser will hand a GL map a WebGL2 context. MapLibre 6 and
|
||||
* Mapbox GL 3 both ask for webgl2 and nothing else.
|
||||
*
|
||||
* The default basemap has been a vector style since 4.1.0, and maplibre-gl builds
|
||||
* its GL context inside the Map constructor: no context, no map, and the throw
|
||||
@@ -28,7 +29,39 @@ export function hasWebGL(): boolean {
|
||||
return probed
|
||||
}
|
||||
|
||||
/** Test seam. Nothing in the app clears this: the answer cannot change mid-session. */
|
||||
const listeners = new Set<() => void>()
|
||||
let failures = 0
|
||||
|
||||
/**
|
||||
* A GL map that failed to build although the probe said yes: a GPU that offers
|
||||
* WebGL2 and then cannot create the context, or a context budget that ran out.
|
||||
* From here on the answer is no, and every map that asked is told, so it can
|
||||
* redraw itself without WebGL instead of waiting for the next page load.
|
||||
*/
|
||||
export function reportWebGLFailure(): void {
|
||||
if (probed === false) return
|
||||
probed = false
|
||||
failures++
|
||||
for (const listener of listeners) listener()
|
||||
}
|
||||
|
||||
/** For useSyncExternalStore: tells a map when reportWebGLFailure changed the answer. */
|
||||
export function subscribeWebGL(listener: () => void): () => void {
|
||||
listeners.add(listener)
|
||||
return () => {
|
||||
listeners.delete(listener)
|
||||
}
|
||||
}
|
||||
|
||||
/** For useSyncExternalStore: changes each time reportWebGLFailure turned the answer to no. */
|
||||
export function webGLFailures(): number {
|
||||
return failures
|
||||
}
|
||||
|
||||
/**
|
||||
* Test seam. The app never clears this, it only turns it to no through
|
||||
* reportWebGLFailure: a browser does not gain WebGL2 mid-session.
|
||||
*/
|
||||
export function resetWebGLProbe(): void {
|
||||
probed = null
|
||||
}
|
||||
@@ -45,12 +78,13 @@ function probe(): boolean {
|
||||
canvas.width = 1
|
||||
canvas.height = 1
|
||||
|
||||
// Exactly the order maplibre-gl asks in (Map._setupPainter): webgl2 first,
|
||||
// webgl1 second. A stricter probe would black out maps that would in fact
|
||||
// have drawn; a looser one would let the throw through anyway.
|
||||
const gl = (canvas.getContext('webgl2') ?? canvas.getContext('webgl')) as WebGLRenderingContext | null
|
||||
// Exactly what maplibre-gl asks for (Map._setupPainter): WebGL2 and nothing
|
||||
// else, since MapLibre 6 dropped WebGL1. A browser that only offers WebGL1
|
||||
// passes a looser probe and then throws GPUInitializationError out of the Map
|
||||
// constructor, which is the crash this probe exists to prevent.
|
||||
const gl = canvas.getContext('webgl2')
|
||||
if (!gl) {
|
||||
console.warn('[basemap] this browser refuses a WebGL context, vector basemaps are unavailable')
|
||||
console.warn('[basemap] this browser refuses a WebGL2 context, vector basemaps are unavailable')
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import maplibregl from 'maplibre-gl'
|
||||
import { AttributionControl, type Map } from 'maplibre-gl'
|
||||
|
||||
/**
|
||||
* GHSA-jrc7-96c5-q579: MapLibre's attribution sanitizer let a second dangerous
|
||||
* attribute through after removing the first, so a tile source's attribution
|
||||
* could run script. TREK carried a patch for this on 5.x; 6.x replaced the
|
||||
* sanitizer with an allow list, and these cases hold the upgrade to it.
|
||||
*/
|
||||
function attribution(html: string, fromSource = false) {
|
||||
const control = new maplibregl.AttributionControl({
|
||||
const control = new AttributionControl({
|
||||
compact: false,
|
||||
customAttribution: fromSource ? undefined : html,
|
||||
})
|
||||
@@ -16,17 +22,17 @@ function attribution(html: string, fromSource = false) {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
getCanvasContainer: () => document.createElement('div'),
|
||||
} as unknown as maplibregl.Map
|
||||
} as unknown as Map
|
||||
return control.onAdd(map)
|
||||
}
|
||||
|
||||
describe('MapLibre attribution sanitizer backport', () => {
|
||||
it.each([false, true])('removes adjacent event handlers (source: %s)', fromSource => {
|
||||
describe('MapLibre attribution sanitizer', () => {
|
||||
it.each([false, true])('leaves no event handler behind (source: %s)', fromSource => {
|
||||
const container = attribution('<details open onload="1" ontoggle="alert(1)">Credit</details>', fromSource)
|
||||
const details = container.querySelector('.maplibregl-ctrl-attrib-inner details')!
|
||||
expect(details.hasAttribute('onload')).toBe(false)
|
||||
expect(details.hasAttribute('ontoggle')).toBe(false)
|
||||
expect(details.textContent).toBe('Credit')
|
||||
const inner = container.querySelector('.maplibregl-ctrl-attrib-inner')!
|
||||
// <details> is not on the allow list, so it goes with its subtree.
|
||||
expect(inner.querySelector('details')).toBeNull()
|
||||
expect(inner.querySelectorAll('[onload], [ontoggle]')).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('removes consecutive dangerous URLs and handlers in nested markup', () => {
|
||||
|
||||
@@ -173,11 +173,11 @@ describe('MDashboard', () => {
|
||||
expect(screen.queryByText('dashboard.emptyTitle')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-008: demo mode adds the banner', () => {
|
||||
it('FE-MOB-DASH-008: demo mode opens the demo welcome', () => {
|
||||
mocks.dash = buildDash({ demoMode: true });
|
||||
render(<MDashboard />);
|
||||
|
||||
expect(screen.getByText(/demo/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('dialog')).toHaveTextContent('Welcome to TREK');
|
||||
});
|
||||
|
||||
it('FE-MOB-DASH-009: unread notifications mark the bell and it opens the list', () => {
|
||||
|
||||
@@ -385,12 +385,13 @@ describe('MSettingsAccount – MFA', () => {
|
||||
await screen.findByText('JBSWY3DPEHPK3PXP');
|
||||
}
|
||||
|
||||
it('FE-MOB-SETACC-023: demo mode replaces the controls with the demo notice', () => {
|
||||
seedStore(useAuthStore, { demoMode: true });
|
||||
it('FE-MOB-SETACC-023: the shared demo account sees the controls under the demo lock', () => {
|
||||
seedStore(useAuthStore, { demoMode: true, user: buildUser({ email: 'demo@trek.app', mfa_enabled: false }) });
|
||||
renderAccount();
|
||||
|
||||
expect(screen.getByText('Not available in demo mode')).toBeInTheDocument();
|
||||
expect(screen.getByRole('note')).toHaveTextContent('The shared demo account cannot be changed.');
|
||||
expect(screen.queryByRole('button', { name: 'Set up authenticator' })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { hidden: true, name: 'Set up authenticator' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('FE-MOB-SETACC-024: the ?mfa=required entry shows the policy banner', () => {
|
||||
|
||||
@@ -29,6 +29,11 @@ vi.mock('../../../../src/components/Settings/MapboxPreview', () => ({
|
||||
/>
|
||||
),
|
||||
}));
|
||||
// jsdom has no WebGL2; these cases are about a browser that has it.
|
||||
vi.mock('../../../../src/utils/webgl', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../../src/utils/webgl')>()),
|
||||
hasWebGL: () => true,
|
||||
}));
|
||||
|
||||
const OSM_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png';
|
||||
const CARTO_DARK_TILES = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
|
||||
|
||||
Generated
+42
-71
@@ -30,7 +30,6 @@
|
||||
"client": {
|
||||
"name": "@trek/client",
|
||||
"version": "4.3.4",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "0.15.25",
|
||||
"@fontsource/abril-fatface": "^5.3.0",
|
||||
@@ -80,7 +79,7 @@
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.577.0",
|
||||
"mapbox-gl": "^3.31.0",
|
||||
"maplibre-gl": "5.24.0",
|
||||
"maplibre-gl": "6.15.0",
|
||||
"modern-screenshot": "4.7.0",
|
||||
"plyr": "^3.8.4",
|
||||
"react": "^19.3.0",
|
||||
@@ -4971,44 +4970,35 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@mapbox/tiny-sdf": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.2.0.tgz",
|
||||
"integrity": "sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==",
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/tiny-sdf/-/tiny-sdf-2.3.0.tgz",
|
||||
"integrity": "sha512-gBvcNDBBB66bgQYvnaJ6Jk+VCaMmVq5+IZR2kWPisT88Z8s0kBQt6hfVi8QgRW8Vpg0UWZzBA4ZSyzHHC+VeRA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/unitbezier": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-0.0.1.tgz",
|
||||
"integrity": "sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==",
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@mapbox/vector-tile": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-2.0.5.tgz",
|
||||
"integrity": "sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==",
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/vector-tile/-/vector-tile-3.0.0.tgz",
|
||||
"integrity": "sha512-Qf10S1uIHMk20ri/IVBnpS+esUEkVaR5Hftmz88jTInrpmWgPGJfPe3LVjjlE77trLx8tH6qjTG7uWH9hIq/0Q==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "~1.1.0",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"pbf": "^4.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@mapbox/whoots-js": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/whoots-js/-/whoots-js-3.1.0.tgz",
|
||||
"integrity": "sha512-Es6WcD0nO5l+2BOQS4uLfNPYQaNDfbot3X1XUoloz+x0mPDS3eeORZJl06HXjwBG1fOGwCRnzK88LMdxKRrd6Q==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
"pbf": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/geojson-vt": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.1.0.tgz",
|
||||
"integrity": "sha512-2eIY4gZxeKIVOZVNkAMb+5NgXhgsMQpOveTQAvnp53LYqHGJZDidk7Ew0Tged9PThidpbS+NFTh0g4zivhPDzQ==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-6.3.0.tgz",
|
||||
"integrity": "sha512-3Zw2jsU9pIJG3G2CQFbLjWj2MlAHodhD7cSY+15iywUuLmmkgzcKRnueVRZ4AcC/5wOtfhidcJAAzBpoBNs3Xg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"kdbush": "^4.0.2"
|
||||
"kdbush": "^4.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-leaflet": {
|
||||
@@ -5023,12 +5013,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec": {
|
||||
"version": "24.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-24.10.0.tgz",
|
||||
"integrity": "sha512-lichxSiagMEBBrqHF0trtMQH9RKh+9jUlIJl0qW0QHvt2H/tbvUWdE+ZzI2Jd0/pT7j/iavLonlPu7EQ/ixTOw==",
|
||||
"version": "26.4.4",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/maplibre-gl-style-spec/-/maplibre-gl-style-spec-26.4.4.tgz",
|
||||
"integrity": "sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "~2.0.2",
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.3",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"json-stringify-pretty-compact": "^4.0.0",
|
||||
"minimist": "^1.2.8",
|
||||
@@ -5041,16 +5031,10 @@
|
||||
"gl-style-validate": "dist/gl-style-validate.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/maplibre-gl-style-spec/node_modules/@mapbox/unitbezier": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@mapbox/unitbezier/-/unitbezier-1.0.0.tgz",
|
||||
"integrity": "sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/@maplibre/mlt": {
|
||||
"version": "1.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.1.12.tgz",
|
||||
"integrity": "sha512-ZeK5w2TTeHOajcLaEQs1KZXw2V9wIKo1PmThlxlsHoXsQsYlBqLJzPOd6tJHRtGTChUY3DPPmjXRArYVvAbmZw==",
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@maplibre/mlt/-/mlt-1.3.0.tgz",
|
||||
"integrity": "sha512-7M2O7ABM80Oi8XPkfkrjKB8mOBIxGoVgkA52Mu4RMWoBvvMdagQ522+WxVYujZsMpqIr3BpzrTbXrm4k8lCzCQ==",
|
||||
"license": "(MIT OR Apache-2.0)",
|
||||
"dependencies": {
|
||||
"@mapbox/point-geometry": "^1.1.0"
|
||||
@@ -5067,18 +5051,6 @@
|
||||
"pbf": "^5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@maplibre/vt-pbf/node_modules/pbf": {
|
||||
"version": "5.1.0",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.0.tgz",
|
||||
"integrity": "sha512-Wv0yo0+uZepnoNEKsquhar1F18LogB8oeEikIhUXG16udbiXG7JecHGySwoo6kuMgjmbQYzdrTZlO+/K9t8eZg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"pbf": "bin/pbf"
|
||||
}
|
||||
},
|
||||
"node_modules/@microsoft/tsdoc": {
|
||||
"version": "0.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz",
|
||||
@@ -13603,9 +13575,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/earcut": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz",
|
||||
"integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==",
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/earcut/-/earcut-3.2.4.tgz",
|
||||
"integrity": "sha512-FHc6yNpnTORB0tM5kmZ03iD2mDuNakDE2lvf4Hw1XkfxtEHIGVi01B/GqUSwx+NcC1WGjidAfpTXzeALtPtfpg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ecdsa-sig-formatter": {
|
||||
@@ -17490,27 +17462,26 @@
|
||||
]
|
||||
},
|
||||
"node_modules/maplibre-gl": {
|
||||
"version": "5.24.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-5.24.0.tgz",
|
||||
"integrity": "sha512-ALyFxgtd5R+65UqZ/++lOqwWcC0SNho9c27fYSyLmG7AfnAul2o46F05aDJGPbFU57wos9dgcIySHs0Xe6ia3A==",
|
||||
"version": "6.15.0",
|
||||
"resolved": "https://registry.npmjs.org/maplibre-gl/-/maplibre-gl-6.15.0.tgz",
|
||||
"integrity": "sha512-kxFetbvUHDOVaAP1CYb226Mdk2QhzIPddqM0xJTFD35wu78Mpd6NM7+H68ALjc6Oa0kAOX5EAdMKyvD2u3qfcg==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@mapbox/jsonlint-lines-primitives": "^2.0.2",
|
||||
"@mapbox/point-geometry": "^1.1.0",
|
||||
"@mapbox/tiny-sdf": "^2.1.0",
|
||||
"@mapbox/unitbezier": "^0.0.1",
|
||||
"@mapbox/vector-tile": "^2.0.4",
|
||||
"@mapbox/whoots-js": "^3.1.0",
|
||||
"@maplibre/geojson-vt": "^6.1.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^24.8.1",
|
||||
"@maplibre/mlt": "^1.1.8",
|
||||
"@maplibre/vt-pbf": "^4.3.0",
|
||||
"@mapbox/tiny-sdf": "^2.3.0",
|
||||
"@mapbox/unitbezier": "^1.0.0",
|
||||
"@mapbox/vector-tile": "^3.0.0",
|
||||
"@maplibre/geojson-vt": "^6.3.0",
|
||||
"@maplibre/maplibre-gl-style-spec": "^26.4.4",
|
||||
"@maplibre/mlt": "^1.3.0",
|
||||
"@maplibre/vt-pbf": "^4.3.2",
|
||||
"@types/geojson": "^7946.0.16",
|
||||
"earcut": "^3.0.2",
|
||||
"bidi-js": "^1.1.0",
|
||||
"earcut": "^3.2.4",
|
||||
"gl-matrix": "^3.4.4",
|
||||
"kdbush": "^4.0.2",
|
||||
"kdbush": "^4.1.0",
|
||||
"murmurhash-js": "^1.0.0",
|
||||
"pbf": "^4.0.1",
|
||||
"pbf": "^5.1.2",
|
||||
"potpack": "^2.1.0",
|
||||
"quickselect": "^3.0.0",
|
||||
"tinyqueue": "^3.0.0"
|
||||
@@ -19398,9 +19369,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pbf": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-4.0.2.tgz",
|
||||
"integrity": "sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==",
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/pbf/-/pbf-5.1.2.tgz",
|
||||
"integrity": "sha512-mnvGdvOrIvJOBGUEdGkrVXjN8E/VkIJCkf2eS1DH2yv82ORUlLttmDt0rWY38yYZmVwciZwBUvHM20qxBZf40w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"resolve-protobuf-schema": "^2.1.0"
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { currentTimestampKysely } from '../dialect/kysely-functions';
|
||||
import { Addons } from '../entities/Addons.entity';
|
||||
import { AppSettings } from '../entities/AppSettings.entity';
|
||||
import { DocumentProviders } from '../entities/DocumentProviders.entity';
|
||||
import { PhotoProviders } from '../entities/PhotoProviders.entity';
|
||||
import { Settings } from '../entities/Settings.entity';
|
||||
import { Trips } from '../entities/Trips.entity';
|
||||
import { Users } from '../entities/Users.entity';
|
||||
import type { DB } from '../kysely/db';
|
||||
@@ -36,6 +40,15 @@ export interface DemoInstanceKeyRow {
|
||||
value: string | null;
|
||||
}
|
||||
|
||||
/** The states `applyAddonStates` writes: three shelves and the switches under them in `app_settings`. */
|
||||
export interface DemoAddonStates {
|
||||
/** Each shelf names the rows it switches on and the rows it switches off. */
|
||||
readonly addons: { readonly on: readonly string[]; readonly off: readonly string[] };
|
||||
readonly photoProviders: { readonly on: readonly string[]; readonly off: readonly string[] };
|
||||
readonly documentProviders: { readonly on: readonly string[]; readonly off: readonly string[] };
|
||||
readonly appSettings: Readonly<Record<string, string>>;
|
||||
}
|
||||
|
||||
/**
|
||||
* DMS9's 13-column `places` insert tuple — kept as a tuple, not an object,
|
||||
* so `demo-seed.ts`'s three ~30-row example-trip data arrays (Tokyo/Kyoto,
|
||||
@@ -400,4 +413,48 @@ export class DemoRepository {
|
||||
await repo.upsert({ key: row.key, value: row.value }, { onConflictFields: ['key'], onConflictAction: 'merge' });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// The fixed demo set (demo-addon-policy.ts), applied by the seed and the reset
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Writes the demo's addon states in one transaction, so a reset that fails
|
||||
* halfway does not leave half the shelf switched. One UPDATE per shelf and
|
||||
* state (generic inherited `nativeUpdate` over `id IN (...)`, the statement
|
||||
* `setEnabled` issues for one row) and one upsert for all the switches
|
||||
* (`AppSettingsRepository#setValue`'s), however long the lists are. A named
|
||||
* row the database does not hold, like an empty list, is an update that
|
||||
* matches nothing, never an insert: the seeders own which rows exist.
|
||||
*/
|
||||
async applyAddonStates(states: DemoAddonStates): Promise<void> {
|
||||
this.validateRequestContext();
|
||||
const switches = Object.entries(states.appSettings).map(([key, value]) => ({ key, value }));
|
||||
await this.em.transactional(async () => {
|
||||
const addons = this.em.getRepository(Addons);
|
||||
await addons.nativeUpdate({ id: { $in: [...states.addons.on] } }, { enabled: true });
|
||||
await addons.nativeUpdate({ id: { $in: [...states.addons.off] } }, { enabled: false });
|
||||
const photos = this.em.getRepository(PhotoProviders);
|
||||
await photos.nativeUpdate({ id: { $in: [...states.photoProviders.on] } }, { enabled: 1 });
|
||||
await photos.nativeUpdate({ id: { $in: [...states.photoProviders.off] } }, { enabled: 0 });
|
||||
const documents = this.em.getRepository(DocumentProviders);
|
||||
await documents.nativeUpdate({ id: { $in: [...states.documentProviders.on] } }, { enabled: 1 });
|
||||
await documents.nativeUpdate({ id: { $in: [...states.documentProviders.off] } }, { enabled: 0 });
|
||||
await this.em
|
||||
.getRepository(AppSettings)
|
||||
.upsertMany(switches, { onConflictFields: ['key'], onConflictAction: 'merge' });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A user's own settings rows, written as given in one statement (the demo
|
||||
* account's fixed language). The upsert `SettingsRepository#upsertForUser`
|
||||
* issues for one key, for every row at once.
|
||||
*/
|
||||
async setUserSettings(userId: number, rows: readonly { key: string; value: string }[]): Promise<void> {
|
||||
await this.em.getRepository(Settings).upsertMany(
|
||||
rows.map(({ key, value }) => ({ user: userId, key, value })),
|
||||
{ onConflictFields: ['user', 'key'], onConflictAction: 'merge' },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { DemoAddonStates, DemoRepository } from '../db/repositories/DemoRepository';
|
||||
|
||||
/**
|
||||
* What a demo instance offers, whatever its admin switched during the hour.
|
||||
*
|
||||
* The demo shows everything TREK can do on its own: every trip, global and
|
||||
* integration addon is on. What stays off is what needs a server of the
|
||||
* visitor's own, which a public demo has no address for and no business
|
||||
* reaching: the document sync providers, the wanderer connection under Tours,
|
||||
* the photo providers under Journey, AirTrail and Dawarich. The AI features
|
||||
* (the assistant, AI parsing) and the MCP server stay off as well: they spend
|
||||
* the instance's AI credit or hand out tokens to a shared account, so they are
|
||||
* a feature of a self-hosted install, not of the public demo. So does the
|
||||
* legacy `memories` row, which nothing reads any more.
|
||||
*
|
||||
* Each list names its rows outright instead of "everything but", so a release
|
||||
* that adds an addon has to decide where it goes: the policy test fails on a
|
||||
* seeded row that is in neither list. The demo seed applies this on every boot
|
||||
* and after every reopen, and the hourly reset applies it once more after the
|
||||
* swap, so the baseline cannot bring back an admin's toggle.
|
||||
*/
|
||||
export const DEMO_ADDON_POLICY = {
|
||||
/** Rows of the `addons` table. */
|
||||
addons: {
|
||||
on: [
|
||||
// Trip addons
|
||||
'packing',
|
||||
'budget',
|
||||
'documents',
|
||||
'collab',
|
||||
'roadtrip',
|
||||
'tours',
|
||||
// Global addons
|
||||
'vacay',
|
||||
'atlas',
|
||||
'journey',
|
||||
'collections',
|
||||
// Integrations TREK runs by itself
|
||||
'naver_list_import',
|
||||
],
|
||||
off: [
|
||||
'airtrail',
|
||||
'dawarich',
|
||||
'mcp',
|
||||
'llm_parsing',
|
||||
'ai_assistant',
|
||||
// The trip Photos addon of old releases. Its migration seeds the row
|
||||
// switched off and nothing removes it; the client reads Photos from the
|
||||
// photo providers (addonStore's isEnabled) and the admin panel keeps the
|
||||
// row out of its trip list, so it switches nothing.
|
||||
'memories',
|
||||
],
|
||||
},
|
||||
/** Rows of `photo_providers`, Journey's shelf. */
|
||||
photoProviders: {
|
||||
on: [],
|
||||
off: ['immich', 'synologyphotos'],
|
||||
},
|
||||
/** Rows of `document_providers`, the Documents addon's shelf. */
|
||||
documentProviders: {
|
||||
on: [],
|
||||
off: ['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive'],
|
||||
},
|
||||
/**
|
||||
* The switches that sit under an addon in the admin panel, stored in
|
||||
* `app_settings`. wanderer is the one under Tours that reaches a server of
|
||||
* the visitor's own; the rest are on.
|
||||
*/
|
||||
appSettings: {
|
||||
tours_wanderer_enabled: 'false',
|
||||
tours_hiking_map_enabled: 'true',
|
||||
tours_cycle_map_enabled: 'true',
|
||||
ski_routing_enabled: 'true',
|
||||
bag_tracking_enabled: 'true',
|
||||
collab_chat_enabled: 'true',
|
||||
collab_notes_enabled: 'true',
|
||||
collab_links_enabled: 'true',
|
||||
collab_polls_enabled: 'true',
|
||||
collab_whatsnext_enabled: 'true',
|
||||
},
|
||||
} as const satisfies DemoAddonStates;
|
||||
|
||||
/** Puts the instance's addon states to the demo set, in one transaction. A row the database does not hold is skipped. */
|
||||
export async function applyDemoAddonPolicy(
|
||||
demo: DemoRepository,
|
||||
policy: DemoAddonStates = DEMO_ADDON_POLICY,
|
||||
): Promise<void> {
|
||||
await demo.applyAddonStates(policy);
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
} from '../db/repositories/DemoRepository';
|
||||
import type { CarriedUserSessionRow } from '../db/repositories/UserSessions.repository';
|
||||
import { dbNow } from '../db/types';
|
||||
import { logError } from '../nest/audit/audit-log.logger';
|
||||
import { DatabaseConnectionLostError, type DatabaseBackupStrategy } from '../nest/database/database-backup.interface';
|
||||
import { applyDemoAddonPolicy } from './demo-addon-policy';
|
||||
import { RequestContext, type EntityManager } from '@mikro-orm/core';
|
||||
|
||||
import fs from 'fs';
|
||||
@@ -155,6 +157,15 @@ async function resetDemoUser(database: DatabaseBackupStrategy): Promise<void> {
|
||||
console.error('[Demo Reset] Failed to restore the active sessions:', e instanceof Error ? e.message : e);
|
||||
}
|
||||
}
|
||||
|
||||
// The reopen's schema bootstrap ran the demo seed, which applies the demo
|
||||
// addon set already. Applied here once more so the reset itself states
|
||||
// what the demo offers after the hour, whatever the baseline holds.
|
||||
try {
|
||||
await applyDemoAddonPolicy(demoFresh);
|
||||
} catch (e: unknown) {
|
||||
logError(`[Demo Reset] Failed to apply the demo addon set: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('[Demo Reset] Database restored from baseline');
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { readEnv } from '../app-config';
|
||||
import { DemoRepository, type NewDemoPlaceRow } from '../db/repositories/DemoRepository';
|
||||
import { DEMO_PASS } from '../nest/common/demo';
|
||||
import { logInfo } from '../nest/audit/audit-log.logger';
|
||||
import { DEMO_ACCOUNT_SETTINGS, DEMO_PASS } from '../nest/common/demo';
|
||||
import { applyDemoAddonPolicy } from './demo-addon-policy';
|
||||
// Static like in demo-reset.job.ts: the module top is inert, everything that
|
||||
// touches the database happens inside the functions.
|
||||
import { markExampleTripsSeeded, requireEntityManager } from './demo-reset';
|
||||
@@ -53,6 +55,16 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
|
||||
// Disable registration in demo mode
|
||||
await demo.setAllowRegistrationFalse();
|
||||
|
||||
// The fixed demo set: the addons every visitor can try, and the demo
|
||||
// account's language. This runs on every boot and again after every reopen
|
||||
// (the hourly reset included), so neither an admin's toggle nor an old
|
||||
// baseline outlasts the hour.
|
||||
await applyDemoAddonPolicy(demo);
|
||||
await demo.setUserSettings(
|
||||
demoId,
|
||||
Object.entries(DEMO_ACCOUNT_SETTINGS).map(([key, value]) => ({ key, value })),
|
||||
);
|
||||
|
||||
// Check if admin already has example trips
|
||||
const adminTripCount = await demo.countTripsByUser(adminId);
|
||||
if (adminTripCount > 0) {
|
||||
@@ -61,6 +73,16 @@ async function seedDemoData(): Promise<{ adminId: number; demoId: number }> {
|
||||
return { adminId, demoId };
|
||||
}
|
||||
|
||||
// A demo whose content belongs to the demo account itself (built over the
|
||||
// API, then saved as the baseline) is left alone. Without this the old
|
||||
// example trips would come back on every reopen, the hourly reset included,
|
||||
// and the mark below would save them into the next baseline.
|
||||
const demoTripCount = await demo.countTripsByUser(demoId);
|
||||
if (demoTripCount > 0) {
|
||||
logInfo('[Demo] The demo account owns its own trips, skipping the example trips');
|
||||
return { adminId, demoId };
|
||||
}
|
||||
|
||||
console.log('[Demo] Seeding example trips...');
|
||||
await seedExampleTrips(demo, adminId, demoId);
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { currentSessionId, decodeSessionClaims } from '../auth-core/jwt-verify';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../common/demo-write';
|
||||
import { DEMO_ACCOUNT_REFUSAL, DEMO_WRITE_ERROR, isDemoWriteBlocked, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { ManagedForbidden } from '../common/managed';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
@@ -194,6 +194,11 @@ export class AuthController {
|
||||
|
||||
@Put('me/settings')
|
||||
async updateSettings(@CurrentUser() user: User, @Body() body: SettingsUpdateDto, @Req() req: Request) {
|
||||
// The name and the address belong to the Account tab. Every visitor of a
|
||||
// demo instance shares this account, so they stay what the demo seeded.
|
||||
if (body.username !== undefined || body.email !== undefined) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
|
||||
}
|
||||
const result = await this.profile.updateSettings(user.id, body);
|
||||
await this.auditApiKeys(user.id, result.changedKeys ?? [], req);
|
||||
// The address itself stays out of the row, like the key values above; the row
|
||||
@@ -228,6 +233,7 @@ export class AuthController {
|
||||
|
||||
@Delete('avatar')
|
||||
async deleteAvatar(@CurrentUser() user: User) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.account);
|
||||
return this.profile.deleteAvatar(user.id);
|
||||
}
|
||||
|
||||
@@ -312,6 +318,7 @@ export class AuthController {
|
||||
@Post('mcp-tokens')
|
||||
@HttpCode(201)
|
||||
async createMcpToken(@CurrentUser() user: User, @Body() body: McpTokenCreateDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
await this.limit('login', req, 5);
|
||||
const result = await this.tokens.createMcpToken(user.id, body.name);
|
||||
return { token: result.token };
|
||||
@@ -319,6 +326,7 @@ export class AuthController {
|
||||
|
||||
@Delete('mcp-tokens/:id')
|
||||
async deleteMcpToken(@CurrentUser() user: User, @Param('id') id: string) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
await this.tokens.deleteMcpToken(user.id, id);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -343,6 +351,7 @@ export class AuthController {
|
||||
@Post('api-tokens')
|
||||
@HttpCode(201)
|
||||
async createApiToken(@CurrentUser() user: User, @Body() body: ApiTokenCreateDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.apiKeys);
|
||||
await this.limit('login', req, 5);
|
||||
// No `scopes` means the key reads everything, which is what every key minted
|
||||
// before this field existed does. Narrowing stays opt-in so the change
|
||||
|
||||
@@ -5,6 +5,7 @@ import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { MfaExempt } from '../auth-core/mfa-policy.guard';
|
||||
import { setAuthCookie } from '../common/cookie';
|
||||
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { sessionClientFrom } from '../sessions/sessions.service';
|
||||
import {
|
||||
@@ -69,6 +70,9 @@ export class PasskeyController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerOptions(@CurrentUser() user: User, @Body() body: PasskeyRegisterOptionsDto, @Req() req: Request) {
|
||||
// Every visitor shares the demo account: a passkey enrolled on it would be
|
||||
// one stranger's key to everybody's demo.
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.limit('mfa', req, 5);
|
||||
// The Origin header is only a pre-ceremony sanity input (see PasskeyService)
|
||||
// — the RP ID itself is never derived from request headers.
|
||||
@@ -81,6 +85,7 @@ export class PasskeyController {
|
||||
@HttpCode(200)
|
||||
@UseGuards(PasskeyEnabledGuard, JwtAuthGuard)
|
||||
async registerVerify(@CurrentUser() user: User, @Body() body: PasskeyRegisterVerifyDto, @Req() req: Request) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
const result = await this.passkeys.passkeyRegisterVerify(user.id, body);
|
||||
await this.audit.writeAudit({ userId: user.id, action: 'user.passkey_register', ip: getClientIp(req) });
|
||||
return { success: true, credential: result.credential };
|
||||
@@ -142,6 +147,7 @@ export class PasskeyController {
|
||||
@Patch('credentials/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
async rename(@CurrentUser() user: User, @Param('id') id: string, @Body() body: PasskeyRenameDto) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.passkeys.renamePasskey(user.id, id, body?.name);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -154,6 +160,7 @@ export class PasskeyController {
|
||||
@Body() body: PasskeyDeleteDto,
|
||||
@Req() req: Request,
|
||||
) {
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.passkeys);
|
||||
await this.limit('login', req, 5);
|
||||
await this.passkeys.deletePasskey(user.id, id, body?.password);
|
||||
await this.audit.writeAudit({
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { readEnv } from '../../app-config';
|
||||
import { RuntimeEnvService } from '../app-config/runtime-env.service';
|
||||
import { isDemoEmail } from './demo';
|
||||
import { DEMO_ACCOUNT_SETTINGS, isDemoEmail } from './demo';
|
||||
import { DomainError } from './domain-error';
|
||||
|
||||
/** The exact 403 body the six upload endpoints have always returned. */
|
||||
export const DEMO_WRITE_ERROR = {
|
||||
@@ -29,3 +31,48 @@ export function isDemoWriteBlocked(env: RuntimeEnvService, email: string | null
|
||||
// `this.demo.isDemoUserId(userId)` instead: the same check, resolving `env`/
|
||||
// the repository via its own constructor rather than taking them as
|
||||
// parameters.
|
||||
|
||||
/**
|
||||
* The shared account every visitor of a demo instance signs in as. The same
|
||||
* reading of the environment as the password, MFA and session refusals in
|
||||
* `auth/`, so a controller that has no RuntimeEnvService of its own (passkeys,
|
||||
* OAuth) asks the same question without a new constructor parameter.
|
||||
*/
|
||||
export function isDemoAccount(user: { email?: string | null } | null | undefined): boolean {
|
||||
return readEnv().demo.enabled && isDemoEmail(user?.email);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the demo account is told when it tries to change something every other
|
||||
* visitor shares. One sentence per area, so the client can show it as it is.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_REFUSAL = {
|
||||
apiKeys: 'API keys cannot be created in demo mode.',
|
||||
mcp: 'MCP clients cannot be connected in demo mode.',
|
||||
account: 'The demo account cannot be changed in demo mode.',
|
||||
passkeys: 'Passkeys cannot be changed in demo mode.',
|
||||
} as const;
|
||||
|
||||
/** Refuses the write with a 403 for the demo account; does nothing for anybody else. */
|
||||
export function refuseForDemoAccount(user: { email?: string | null } | null | undefined, message: string): void {
|
||||
if (isDemoAccount(user)) throw new DomainError(403, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a settings write of this key is one the demo account does not get to
|
||||
* make (`DEMO_ACCOUNT_SETTINGS`). The route answers as if it saved nothing,
|
||||
* rather than refusing: a client that sends the language along with other keys
|
||||
* keeps the rest of its save.
|
||||
*/
|
||||
export function isDemoLockedSetting(user: { email?: string | null } | null | undefined, key: string): boolean {
|
||||
return Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key) && isDemoAccount(user);
|
||||
}
|
||||
|
||||
/** The settings of a bulk save without the keys the demo account keeps fixed. */
|
||||
export function withoutDemoLockedSettings(
|
||||
user: { email?: string | null } | null | undefined,
|
||||
settings: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
if (!isDemoAccount(user)) return settings;
|
||||
return Object.fromEntries(Object.entries(settings).filter(([key]) => !Object.hasOwn(DEMO_ACCOUNT_SETTINGS, key)));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { DEMO_ACCOUNT_EMAILS } from '@trek/shared';
|
||||
|
||||
// Central registry of demo-user email addresses.
|
||||
//
|
||||
// Historical: the demo account was seeded as "demo@trek.app" (see
|
||||
@@ -23,8 +25,17 @@ export const DEMO_PASS = 'demo12345';
|
||||
* Includes the historical `demo@nomad.app` identifier so instances that
|
||||
* upgraded in place without resetting the DB still hit demo-mode guards.
|
||||
*/
|
||||
export const DEMO_EMAILS: ReadonlySet<string> = new Set([DEMO_EMAIL_PRIMARY, 'demo@nomad.app']);
|
||||
export const DEMO_EMAILS: ReadonlySet<string> = new Set(DEMO_ACCOUNT_EMAILS);
|
||||
|
||||
export function isDemoEmail(email: string | null | undefined): boolean {
|
||||
return !!email && DEMO_EMAILS.has(email);
|
||||
}
|
||||
|
||||
/**
|
||||
* User settings the shared demo account always holds, whatever a visitor picks.
|
||||
* The demo seed writes them on every boot and after every hourly reset, and the
|
||||
* settings routes leave them alone for the demo account (`demo-write.ts`), so a
|
||||
* visitor cannot switch the demo every other visitor sees into another
|
||||
* language.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_SETTINGS: Readonly<Record<string, string>> = { language: 'en' };
|
||||
|
||||
@@ -4,6 +4,7 @@ import { CookieAuthGuard } from '../auth-core/cookie-auth.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { OptionalJwtGuard } from '../auth-core/optional-jwt.guard';
|
||||
import { DEMO_ACCOUNT_REFUSAL, refuseForDemoAccount } from '../common/demo-write';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { OauthClientCreateDto, OauthConsentDto } from './oauth.dto';
|
||||
import { OauthService } from './oauth.service';
|
||||
@@ -122,6 +123,8 @@ export class OauthApiController {
|
||||
if (body.state) url.searchParams.set('state', body.state);
|
||||
return { redirect: url.toString() };
|
||||
}
|
||||
// Saying no stays possible. Handing the shared demo account to an app does not.
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const scopes = validation.scopes!;
|
||||
await this.oauth.saveConsent(body.client_id, user.id, scopes, ip);
|
||||
const code = await this.oauth.createAuthCode({
|
||||
@@ -157,6 +160,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async createClient(@CurrentUser() user: User, @Body() body: OauthClientCreateDto, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.createOAuthClient(
|
||||
user.id,
|
||||
body.name,
|
||||
@@ -176,6 +180,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async rotateClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.rotateOAuthClientSecret(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
@@ -187,6 +192,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async deleteClient(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.deleteOAuthClient(user.id, id, getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
@@ -205,6 +211,7 @@ export class OauthApiController {
|
||||
@UseGuards(CookieAuthGuard)
|
||||
async revokeSession(@CurrentUser() user: User, @Param('id') id: string, @Req() req: Request) {
|
||||
await this.requireMcp403();
|
||||
refuseForDemoAccount(user, DEMO_ACCOUNT_REFUSAL.mcp);
|
||||
const result = await this.oauth.revokeSession(user.id, Number(id), getClientIp(req));
|
||||
if (result.error) {
|
||||
throw new HttpException({ error: result.error }, result.status || 400);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { getClientIp } from '../audit/client-ip';
|
||||
import { AdminGuard } from '../auth-core/admin.guard';
|
||||
import { CurrentUser } from '../auth-core/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth-core/jwt-auth.guard';
|
||||
import { isDemoLockedSetting, withoutDemoLockedSettings } from '../common/demo-write';
|
||||
import { isManagedLockedKey, splitManagedKeys } from '../common/managed';
|
||||
import { ResponseContract } from '../common/response-contract';
|
||||
import { SettingsWritesService } from './settings-writes.service';
|
||||
@@ -99,6 +100,10 @@ export class SettingsController {
|
||||
if (isManagedLockedKey(body.key) && this.env.isManaged()) {
|
||||
return { success: true, key: body.key, managed: true };
|
||||
}
|
||||
// The demo account keeps its language: every visitor sees the same demo.
|
||||
if (isDemoLockedSetting(user, body.key)) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
}
|
||||
// The client echoes a redacted secret back unchanged — treat as a no-op.
|
||||
if (body.value === MASKED_SETTING_VALUE) {
|
||||
return { success: true, key: body.key, unchanged: true };
|
||||
@@ -113,7 +118,7 @@ export class SettingsController {
|
||||
async bulk(@CurrentUser() user: User, @Body() body: SettingsBulkDto, @Headers('x-socket-id') socketId?: string) {
|
||||
this.assertMayWriteInstanceEndpoint(body.settings);
|
||||
this.assertValidValues(body.settings);
|
||||
const { allowed, blocked } = splitManagedKeys(body.settings, this.env.isManaged());
|
||||
const { allowed, blocked } = splitManagedKeys(withoutDemoLockedSettings(user, body.settings), this.env.isManaged());
|
||||
try {
|
||||
const updated = await this.writes.saveMany(user.id, allowed, socketId);
|
||||
return { success: true, updated, ...(blocked.length ? { managed_keys: blocked } : {}) };
|
||||
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Demo account locks e2e: on a demo instance the shared demo account cannot
|
||||
* mint API keys or MCP tokens, change its name, address or picture, or touch
|
||||
* passkeys, and its language stays English. Every visitor signs in as that one
|
||||
* account, so each of these would be one stranger's change to everybody's demo.
|
||||
*
|
||||
* Boots AuthModule (with the passkey routes) and SettingsModule with the real
|
||||
* JwtAuthGuard, the Zod pipe and the exception filter over a migrated temp
|
||||
* SQLite db. The other accounts on the same instance, and the demo address on
|
||||
* an instance that is not a demo, keep every one of these routes.
|
||||
*/
|
||||
import { Settings } from '../../src/db/entities/Settings.entity';
|
||||
import { AppConfigModule } from '../../src/nest/app-config/app-config.module';
|
||||
import { AuthModule } from '../../src/nest/auth/auth.module';
|
||||
import { DEMO_ACCOUNT_REFUSAL } from '../../src/nest/common/demo-write';
|
||||
import { DemoModule } from '../../src/nest/common/demo.module';
|
||||
import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter';
|
||||
import { ZodValidationPipe } from '../../src/nest/common/zod-validation.pipe';
|
||||
import { MailerService } from '../../src/nest/notifications/mailer/mailer.service';
|
||||
import { RealtimeModule } from '../../src/nest/realtime/realtime.module';
|
||||
import { RealtimeService } from '../../src/nest/realtime/realtime.service';
|
||||
import { SettingsModule } from '../../src/nest/settings/settings.module';
|
||||
import { createSnapshotTestDb } from '../helpers/db-mock';
|
||||
import { deleteRows, findRow, insertRow } from '../helpers/factories/rows';
|
||||
import { makeUser } from '../helpers/factories/users';
|
||||
import { FakeRealtimeService } from '../helpers/fake-realtime';
|
||||
import { resetRateLimits } from '../helpers/test-db';
|
||||
import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { TestUnitOfWorkModule } from '../helpers/test-uow';
|
||||
import { sessionCookie } from './harness';
|
||||
import { Test } from '@nestjs/testing';
|
||||
|
||||
import cookieParser from 'cookie-parser';
|
||||
import type { Server } from 'http';
|
||||
import request from 'supertest';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../src/nest/audit/audit-log.logger', () => ({
|
||||
LOG_LEVEL: 'error',
|
||||
logInfo: vi.fn(),
|
||||
logDebug: vi.fn(),
|
||||
logError: vi.fn(),
|
||||
logWarn: vi.fn(),
|
||||
}));
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
const DEMO = 1;
|
||||
const VISITOR_ADMIN = 2;
|
||||
|
||||
let orm: TestOrm;
|
||||
let server: Server;
|
||||
let app: Awaited<ReturnType<typeof build>>;
|
||||
|
||||
async function build() {
|
||||
const moduleRef = await Test.createTestingModule({
|
||||
imports: [
|
||||
await TestUnitOfWorkModule.forRoot(db),
|
||||
await createTestMikroOrmModule(db),
|
||||
AppConfigModule,
|
||||
RealtimeModule,
|
||||
DemoModule,
|
||||
AuthModule,
|
||||
SettingsModule,
|
||||
],
|
||||
})
|
||||
.overrideProvider(RealtimeService)
|
||||
.useValue(new FakeRealtimeService())
|
||||
.overrideProvider(MailerService)
|
||||
.useValue({ sendPasswordResetEmail: vi.fn() })
|
||||
.compile();
|
||||
const nest = moduleRef.createNestApplication();
|
||||
nest.use(cookieParser());
|
||||
nest.useGlobalPipes(new ZodValidationPipe());
|
||||
nest.useGlobalFilters(new TrekExceptionFilter());
|
||||
await nest.init();
|
||||
return nest;
|
||||
}
|
||||
|
||||
const as = (userId: number) => ({ Cookie: sessionCookie(userId) });
|
||||
|
||||
async function languageOf(userId: number): Promise<string | undefined> {
|
||||
return (await findRow(orm, Settings, { user: userId, key: 'language' }))?.value;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
orm = await createTestOrm(db);
|
||||
await makeUser(orm, { id: DEMO, username: 'demo', email: 'demo@trek.app' });
|
||||
await makeUser(orm, { id: VISITOR_ADMIN, username: 'admin', email: 'admin@example.test', role: 'admin' });
|
||||
app = await build();
|
||||
server = app.getHttpServer();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
await resetRateLimits(app);
|
||||
await deleteRows(orm, Settings);
|
||||
await insertRow(orm, Settings, { user: DEMO, key: 'language', value: 'en' });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
await orm.close();
|
||||
});
|
||||
|
||||
describe('Demo account locks e2e', () => {
|
||||
it('DEMOLOCK-001: the demo account cannot create an API key', async () => {
|
||||
const res = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' });
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.apiKeys });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-002: the demo account can neither create nor delete an MCP token', async () => {
|
||||
const created = await request(server).post('/api/auth/mcp-tokens').set(as(DEMO)).send({ name: 'Claude' });
|
||||
expect(created.status).toBe(403);
|
||||
expect(created.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp });
|
||||
|
||||
const removed = await request(server).delete('/api/auth/mcp-tokens/1').set(as(DEMO));
|
||||
expect(removed.status).toBe(403);
|
||||
expect(removed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.mcp });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-003: the name, the address and the picture of the demo account stay as seeded', async () => {
|
||||
const name = await request(server).put('/api/auth/me/settings').set(as(DEMO)).send({ username: 'renamed' });
|
||||
expect(name.status).toBe(403);
|
||||
expect(name.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account });
|
||||
|
||||
const address = await request(server)
|
||||
.put('/api/auth/me/settings')
|
||||
.set(as(DEMO))
|
||||
.send({ email: 'mine@example.test' });
|
||||
expect(address.status).toBe(403);
|
||||
|
||||
const avatar = await request(server).delete('/api/auth/avatar').set(as(DEMO));
|
||||
expect(avatar.status).toBe(403);
|
||||
expect(avatar.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.account });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-004: passkeys of the demo account can be neither renamed nor deleted', async () => {
|
||||
const renamed = await request(server)
|
||||
.patch('/api/auth/passkey/credentials/abc')
|
||||
.set(as(DEMO))
|
||||
.send({ name: 'Laptop' });
|
||||
expect(renamed.status).toBe(403);
|
||||
expect(renamed.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys });
|
||||
|
||||
const deleted = await request(server)
|
||||
.delete('/api/auth/passkey/credentials/abc')
|
||||
.set(as(DEMO))
|
||||
.send({ password: 'demo12345' });
|
||||
expect(deleted.status).toBe(403);
|
||||
expect(deleted.body).toEqual({ error: DEMO_ACCOUNT_REFUSAL.passkeys });
|
||||
});
|
||||
|
||||
it('DEMOLOCK-005: a language picked on the demo account is answered as unchanged and not stored', async () => {
|
||||
const one = await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' });
|
||||
expect(one.status).toBe(200);
|
||||
expect(one.body).toEqual({ success: true, key: 'language', unchanged: true });
|
||||
expect(await languageOf(DEMO)).toBe('en');
|
||||
});
|
||||
|
||||
it('DEMOLOCK-006: a bulk save of the demo account keeps every other key and drops the language', async () => {
|
||||
const res = await request(server)
|
||||
.post('/api/settings/bulk')
|
||||
.set(as(DEMO))
|
||||
.send({ settings: { language: 'fr', temperature_unit: 'fahrenheit' } });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({ success: true, updated: 1 });
|
||||
expect(await languageOf(DEMO)).toBe('en');
|
||||
|
||||
const settings = await request(server).get('/api/settings').set(as(DEMO));
|
||||
expect(settings.body.settings).toEqual(expect.objectContaining({ language: 'en', temperature_unit: 'fahrenheit' }));
|
||||
});
|
||||
|
||||
it('DEMOLOCK-007: the admin of a demo instance keeps every one of these routes', async () => {
|
||||
const token = await request(server).post('/api/auth/api-tokens').set(as(VISITOR_ADMIN)).send({ name: 'Admin' });
|
||||
expect(token.status).toBe(201);
|
||||
|
||||
const language = await request(server)
|
||||
.put('/api/settings')
|
||||
.set(as(VISITOR_ADMIN))
|
||||
.send({ key: 'language', value: 'de' });
|
||||
expect(language.status).toBe(200);
|
||||
expect(await languageOf(VISITOR_ADMIN)).toBe('de');
|
||||
});
|
||||
|
||||
it('DEMOLOCK-008: off a demo instance the demo address is an account like any other', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
const token = await request(server).post('/api/auth/api-tokens').set(as(DEMO)).send({ name: 'Mine' });
|
||||
expect(token.status).toBe(201);
|
||||
|
||||
await request(server).put('/api/settings').set(as(DEMO)).send({ key: 'language', value: 'de' });
|
||||
expect(await languageOf(DEMO)).toBe('de');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* The demo addon policy: which addons, providers and addon switches a demo
|
||||
* instance runs with, and that the seed and the hourly reset both put them back.
|
||||
*
|
||||
* The lists are explicit on purpose, so a release that adds an addon has to say
|
||||
* whether a demo shows it: DEMOPOL-001 fails on a seeded row that is in neither
|
||||
* list, and on a listed id the database does not hold.
|
||||
*/
|
||||
import { Addons } from '../../../src/db/entities/Addons.entity';
|
||||
import { DocumentProviders } from '../../../src/db/entities/DocumentProviders.entity';
|
||||
import { PhotoProviders } from '../../../src/db/entities/PhotoProviders.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { DEMO_ADDON_POLICY } from '../../../src/demo/demo-addon-policy';
|
||||
import { resetDemoUser } from '../../../src/demo/demo-reset';
|
||||
import { seedDemoData } from '../../../src/demo/demo-seed';
|
||||
import type { DatabaseBackupStrategy } from '../../../src/nest/database/database-backup.interface';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { findRow, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { readAppSetting, readUserSetting, setAppSetting, setUserSetting } from '../../helpers/factories/settings';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
|
||||
import type Database from 'better-sqlite3';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const BASELINE = path.resolve(__dirname, '..', '..', '..', 'data', 'travel-baseline.db');
|
||||
|
||||
let db: Database.Database;
|
||||
let t: TestOrm;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
||||
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
||||
db = createSnapshotTestDb();
|
||||
t = await createTestOrm(db);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
const seed = () => withRequestContext(t.orm, () => seedDemoData());
|
||||
|
||||
async function enabledIds(entity: typeof Addons | typeof PhotoProviders | typeof DocumentProviders) {
|
||||
const rows = (await findRows(t, entity as typeof Addons)) as { id: string; enabled: unknown }[];
|
||||
return {
|
||||
on: rows
|
||||
.filter((r) => !!r.enabled)
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
off: rows
|
||||
.filter((r) => !r.enabled)
|
||||
.map((r) => r.id)
|
||||
.sort(),
|
||||
};
|
||||
}
|
||||
|
||||
const sorted = (ids: readonly string[]) => [...ids].sort();
|
||||
|
||||
/** What an admin of a demo instance might have switched during the hour. */
|
||||
async function scramble(): Promise<void> {
|
||||
await updateRows(t, Addons, { id: 'airtrail' }, { enabled: true });
|
||||
await updateRows(t, Addons, { id: 'packing' }, { enabled: false });
|
||||
await updateRows(t, Addons, { id: 'journey' }, { enabled: false });
|
||||
await updateRows(t, PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
||||
await updateRows(t, DocumentProviders, { id: 'paperless' }, { enabled: 1 });
|
||||
await setAppSetting(t, 'tours_wanderer_enabled', 'true');
|
||||
await setAppSetting(t, 'collab_chat_enabled', 'false');
|
||||
}
|
||||
|
||||
async function expectPolicyApplied(): Promise<void> {
|
||||
expect(await enabledIds(Addons)).toEqual({
|
||||
on: sorted(DEMO_ADDON_POLICY.addons.on),
|
||||
off: sorted(DEMO_ADDON_POLICY.addons.off),
|
||||
});
|
||||
expect(await enabledIds(PhotoProviders)).toEqual({ on: [], off: sorted(DEMO_ADDON_POLICY.photoProviders.off) });
|
||||
expect(await enabledIds(DocumentProviders)).toEqual({
|
||||
on: [],
|
||||
off: sorted(DEMO_ADDON_POLICY.documentProviders.off),
|
||||
});
|
||||
const keys = Object.keys(DEMO_ADDON_POLICY.appSettings);
|
||||
const values = await Promise.all(keys.map((key) => readAppSetting(t, key)));
|
||||
expect(Object.fromEntries(keys.map((key, i) => [key, values[i]]))).toEqual(DEMO_ADDON_POLICY.appSettings);
|
||||
}
|
||||
|
||||
describe('demo addon policy', () => {
|
||||
it('DEMOPOL-001: names every seeded addon and provider exactly once, and nothing the database lacks', async () => {
|
||||
const shelves = [
|
||||
[Addons, DEMO_ADDON_POLICY.addons],
|
||||
[PhotoProviders, DEMO_ADDON_POLICY.photoProviders],
|
||||
[DocumentProviders, DEMO_ADDON_POLICY.documentProviders],
|
||||
] as const;
|
||||
const seeded = await Promise.all(shelves.map(([entity]) => findRows(t, entity as typeof Addons)));
|
||||
shelves.forEach(([, shelf], i) => {
|
||||
const listed = [...shelf.on, ...shelf.off];
|
||||
expect(new Set(listed).size).toBe(listed.length);
|
||||
expect(sorted(listed)).toEqual(seeded[i].map((r) => r.id).sort());
|
||||
});
|
||||
});
|
||||
|
||||
it('DEMOPOL-002: keeps off exactly what reaches a server of the visitor’s own, the AI features, MCP and the legacy Photos row', () => {
|
||||
expect(sorted(DEMO_ADDON_POLICY.addons.off)).toEqual([
|
||||
'ai_assistant',
|
||||
'airtrail',
|
||||
'dawarich',
|
||||
'llm_parsing',
|
||||
'mcp',
|
||||
'memories',
|
||||
]);
|
||||
expect(sorted(DEMO_ADDON_POLICY.photoProviders.off)).toEqual(['immich', 'synologyphotos']);
|
||||
expect(sorted(DEMO_ADDON_POLICY.documentProviders.off)).toEqual(
|
||||
sorted(['paperless', 'papra', 'nextcloud', 'opencloud', 'synologydrive']),
|
||||
);
|
||||
expect(DEMO_ADDON_POLICY.appSettings.tours_wanderer_enabled).toBe('false');
|
||||
const otherSwitches = Object.entries(DEMO_ADDON_POLICY.appSettings).filter(
|
||||
([key]) => key !== 'tours_wanderer_enabled',
|
||||
);
|
||||
expect(otherSwitches.every(([, value]) => value === 'true')).toBe(true);
|
||||
});
|
||||
|
||||
it('DEMOPOL-003: the first demo boot puts the whole shelf to the demo set', async () => {
|
||||
await scramble();
|
||||
await seed();
|
||||
await expectPolicyApplied();
|
||||
});
|
||||
|
||||
it('DEMOPOL-004: a later boot, with the example trips already there, puts it back again', async () => {
|
||||
await seed();
|
||||
await scramble();
|
||||
await seed();
|
||||
await expectPolicyApplied();
|
||||
});
|
||||
|
||||
it('DEMOPOL-005: the demo account runs in English, whatever a visitor picked before', async () => {
|
||||
const { demoId } = await seed();
|
||||
await setUserSetting(t, demoId, 'language', 'de');
|
||||
await seed();
|
||||
expect(await readUserSetting(t, demoId, 'language')).toBe('en');
|
||||
});
|
||||
|
||||
it('DEMOPOL-006: the admin account keeps the language it chose', async () => {
|
||||
const { adminId } = await seed();
|
||||
await setUserSetting(t, adminId, 'language', 'de');
|
||||
await seed();
|
||||
expect(await readUserSetting(t, adminId, 'language')).toBe('de');
|
||||
});
|
||||
});
|
||||
|
||||
describe('demo addon policy across the hourly reset', () => {
|
||||
it('DEMOPOL-007: the reset puts the demo set back after the baseline swap, whatever the baseline held', async () => {
|
||||
await seed();
|
||||
vi.spyOn(fs, 'existsSync').mockImplementation(((p: fs.PathLike) => String(p) === BASELINE) as typeof fs.existsSync);
|
||||
// The stand-in swap brings back a baseline whose shelf an admin had changed.
|
||||
const database = {
|
||||
canSnapshot: () => true,
|
||||
checkpoint: vi.fn(async () => undefined),
|
||||
replace: vi.fn(async () => {
|
||||
await scramble();
|
||||
return { reopenError: undefined };
|
||||
}),
|
||||
} as unknown as DatabaseBackupStrategy;
|
||||
|
||||
await withRequestContext(t.orm, () => resetDemoUser(database));
|
||||
t.clear();
|
||||
|
||||
expect(database.replace).toHaveBeenCalled();
|
||||
await expectPolicyApplied();
|
||||
expect(await findRow(t, Users, { email: 'demo@trek.app' })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,7 @@ import { takeExampleTripsSeeded } from '../../../src/demo/demo-reset';
|
||||
import { seedDemoData } from '../../../src/demo/demo-seed';
|
||||
import { withRequestContext } from '../../../src/nest/database/request-context';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { countRows, findRows } from '../../helpers/factories/rows';
|
||||
import { countRows, deleteRows, findRows, updateRows } from '../../helpers/factories/rows';
|
||||
import { readAppSetting } from '../../helpers/factories/settings';
|
||||
import { readUser } from '../../helpers/factories/users';
|
||||
import { createTestOrm } from '../../helpers/test-orm';
|
||||
@@ -167,4 +167,28 @@ describe('demo seeding', () => {
|
||||
await seed();
|
||||
expect(takeExampleTripsSeeded()).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMOSEED-006: leaves a demo alone whose trips belong to the demo account, not the admin', async () => {
|
||||
takeExampleTripsSeeded();
|
||||
const { adminId, demoId } = await seed();
|
||||
expect(takeExampleTripsSeeded()).toBe(true);
|
||||
|
||||
// The demo content is rebuilt as the demo user's own: the admin's example
|
||||
// trips go, one trip owned by the demo account stays.
|
||||
const orm = await createTestOrm(db);
|
||||
const exampleTrips = await findRows(orm, Trips, { user_id: adminId }, { id: 'asc' });
|
||||
const keep = exampleTrips[0];
|
||||
await deleteRows(orm, Trips, { id: { $in: exampleTrips.slice(1).map((t) => t.id) } });
|
||||
await updateRows(orm, Trips, { id: keep.id }, { user: demoId });
|
||||
await orm.close();
|
||||
|
||||
await seed();
|
||||
|
||||
const after = await createTestOrm(db);
|
||||
const trips = await findRows(after, Trips, {}, { id: 'asc' });
|
||||
await after.close();
|
||||
expect(trips.map((t) => ({ id: t.id, user_id: t.user_id }))).toEqual([{ id: keep.id, user_id: demoId }]);
|
||||
// Nothing was seeded, so no baseline is due either.
|
||||
expect(takeExampleTripsSeeded()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,11 +12,23 @@
|
||||
*/
|
||||
import { RuntimeEnvService } from '../../../src/nest/app-config/runtime-env.service';
|
||||
import { isDemoEmail } from '../../../src/nest/common/demo';
|
||||
import { isDemoWriteBlocked, DEMO_WRITE_ERROR } from '../../../src/nest/common/demo-write';
|
||||
import {
|
||||
DEMO_ACCOUNT_REFUSAL,
|
||||
DEMO_WRITE_ERROR,
|
||||
isDemoAccount,
|
||||
isDemoLockedSetting,
|
||||
isDemoWriteBlocked,
|
||||
refuseForDemoAccount,
|
||||
withoutDemoLockedSettings,
|
||||
} from '../../../src/nest/common/demo-write';
|
||||
import { DomainError } from '../../../src/nest/common/domain-error';
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/nest/common/demo', () => ({ isDemoEmail: vi.fn(() => false) }));
|
||||
vi.mock('../../../src/nest/common/demo', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/common/demo')>()),
|
||||
isDemoEmail: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
const env = new RuntimeEnvService();
|
||||
|
||||
@@ -60,3 +72,50 @@ describe('isDemoWriteBlocked', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('the demo account locks', () => {
|
||||
const demo = { email: 'demo@trek.app' };
|
||||
const other = { email: 'alice@example.com' };
|
||||
const onDemoInstance = () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app');
|
||||
};
|
||||
|
||||
it('DEMO-WRITE-006: isDemoAccount is the demo address on a demo instance, and nothing else', () => {
|
||||
vi.mocked(isDemoEmail).mockImplementation((email) => email === 'demo@trek.app');
|
||||
expect(isDemoAccount(demo)).toBe(false);
|
||||
onDemoInstance();
|
||||
expect(isDemoAccount(demo)).toBe(true);
|
||||
expect(isDemoAccount(other)).toBe(false);
|
||||
expect(isDemoAccount(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMO-WRITE-007: refuseForDemoAccount throws a 403 with the message for the demo account only', () => {
|
||||
onDemoInstance();
|
||||
expect(() => refuseForDemoAccount(other, DEMO_ACCOUNT_REFUSAL.apiKeys)).not.toThrow();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
refuseForDemoAccount(demo, DEMO_ACCOUNT_REFUSAL.apiKeys);
|
||||
} catch (err) {
|
||||
thrown = err;
|
||||
}
|
||||
expect(thrown).toBeInstanceOf(DomainError);
|
||||
expect((thrown as DomainError).getStatus()).toBe(403);
|
||||
expect((thrown as DomainError).getResponse()).toEqual({ error: 'API keys cannot be created in demo mode.' });
|
||||
});
|
||||
|
||||
it('DEMO-WRITE-008: the language is the setting the demo account keeps', () => {
|
||||
onDemoInstance();
|
||||
expect(isDemoLockedSetting(demo, 'language')).toBe(true);
|
||||
expect(isDemoLockedSetting(demo, 'temperature_unit')).toBe(false);
|
||||
expect(isDemoLockedSetting(other, 'language')).toBe(false);
|
||||
});
|
||||
|
||||
it('DEMO-WRITE-009: a bulk save of the demo account loses the language and keeps the rest', () => {
|
||||
const settings = { language: 'de', distance_unit: 'imperial' };
|
||||
expect(withoutDemoLockedSettings(demo, settings)).toBe(settings);
|
||||
onDemoInstance();
|
||||
expect(withoutDemoLockedSettings(demo, settings)).toEqual({ distance_unit: 'imperial' });
|
||||
expect(withoutDemoLockedSettings(other, settings)).toBe(settings);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The OAuth half of the MCP lock on a demo instance: the shared demo account
|
||||
* cannot register, rotate or delete an OAuth client, end a connected app, or
|
||||
* approve an app's request for access. Turning a request down stays possible.
|
||||
* Every refusal comes before the service is asked, so nothing is written.
|
||||
*
|
||||
* The controller comes out of OauthModule with OauthService replaced by the
|
||||
* stand-in below, so it is wired the way the app wires it.
|
||||
*/
|
||||
import { DEMO_ACCOUNT_REFUSAL } from '../../../src/nest/common/demo-write';
|
||||
import { OauthApiController } from '../../../src/nest/oauth/oauth-api.controller';
|
||||
import { OauthModule } from '../../../src/nest/oauth/oauth.module';
|
||||
import { OauthService } from '../../../src/nest/oauth/oauth.service';
|
||||
import type { User } from '../../../src/types';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestModule, type TestModule } from '../../helpers/test-module';
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
import type { Request } from 'express';
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../src/nest/audit/client-ip', () => ({ getClientIp: vi.fn(() => '1.2.3.4') }));
|
||||
|
||||
const demo = { id: 1, role: 'user', email: 'demo@trek.app' } as User;
|
||||
const other = { id: 2, role: 'user', email: 'alice@example.test' } as User;
|
||||
const req = { ip: '1.2.3.4', headers: {} } as unknown as Request;
|
||||
|
||||
function service() {
|
||||
return {
|
||||
mcpEnabled: vi.fn().mockResolvedValue(true),
|
||||
validateAuthorizeRequest: vi.fn().mockResolvedValue({ valid: true, scopes: ['trips:read'], resource: null }),
|
||||
saveConsent: vi.fn(),
|
||||
createAuthCode: vi.fn().mockResolvedValue('the_code'),
|
||||
createOAuthClient: vi.fn().mockResolvedValue({ client_id: 'c1', client_secret: 's' }),
|
||||
rotateOAuthClientSecret: vi.fn().mockResolvedValue({ client_secret: 's2' }),
|
||||
deleteOAuthClient: vi.fn().mockResolvedValue({}),
|
||||
revokeSession: vi.fn().mockResolvedValue({}),
|
||||
};
|
||||
}
|
||||
|
||||
const db = createSnapshotTestDb();
|
||||
// The module hands the controller this very object, so each test re-arms its
|
||||
// mocks in place instead of building a new container.
|
||||
const svc = service();
|
||||
let t: TestModule;
|
||||
let api: OauthApiController;
|
||||
|
||||
beforeAll(async () => {
|
||||
t = await createTestModule({ db, imports: [OauthModule], overrides: [{ provide: OauthService, useValue: svc }] });
|
||||
api = t.get(OauthApiController);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await t.close();
|
||||
db.close();
|
||||
});
|
||||
|
||||
async function refusal(fn: () => Promise<unknown>): Promise<{ status: number; body: unknown }> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (err) {
|
||||
expect(err).toBeInstanceOf(HttpException);
|
||||
const e = err as HttpException;
|
||||
return { status: e.getStatus(), body: e.getResponse() };
|
||||
}
|
||||
throw new Error('expected a refusal');
|
||||
}
|
||||
|
||||
const consent = (approved: boolean) => ({
|
||||
client_id: 'c1',
|
||||
redirect_uri: 'https://app.example.test/cb',
|
||||
scope: 'trips:read',
|
||||
state: 'st',
|
||||
code_challenge: 'cc',
|
||||
code_challenge_method: 'S256' as const,
|
||||
approved,
|
||||
});
|
||||
|
||||
const MCP_REFUSAL = { status: 403, body: { error: DEMO_ACCOUNT_REFUSAL.mcp } };
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubEnv('DEMO_MODE', 'true');
|
||||
Object.assign(svc, service());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe('OAuth routes for the demo account', () => {
|
||||
it('OAUTH-DEMO-001: registering, rotating and deleting a client are refused before the service runs', async () => {
|
||||
expect(await refusal(() => api.createClient(demo, { name: 'CLI', allowed_scopes: ['a'] }, req))).toEqual(
|
||||
MCP_REFUSAL,
|
||||
);
|
||||
expect(await refusal(() => api.rotateClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
|
||||
expect(await refusal(() => api.deleteClient(demo, 'c1', req))).toEqual(MCP_REFUSAL);
|
||||
expect(svc.createOAuthClient).not.toHaveBeenCalled();
|
||||
expect(svc.rotateOAuthClientSecret).not.toHaveBeenCalled();
|
||||
expect(svc.deleteOAuthClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-002: a connected app cannot be ended from the demo account', async () => {
|
||||
expect(await refusal(() => api.revokeSession(demo, '7', req))).toEqual(MCP_REFUSAL);
|
||||
expect(svc.revokeSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-003: approving an app is refused, turning it down still redirects', async () => {
|
||||
expect(await refusal(() => api.authorize(demo, consent(true), req))).toEqual(MCP_REFUSAL);
|
||||
expect(svc.saveConsent).not.toHaveBeenCalled();
|
||||
expect(svc.createAuthCode).not.toHaveBeenCalled();
|
||||
|
||||
const denied = await api.authorize(demo, consent(false), req);
|
||||
expect((denied as { redirect: string }).redirect).toContain('error=access_denied');
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-004: every other account of the demo instance keeps the routes', async () => {
|
||||
expect(await api.createClient(other, { name: 'CLI', allowed_scopes: ['a'] }, req)).toEqual({
|
||||
client_id: 'c1',
|
||||
client_secret: 's',
|
||||
});
|
||||
const approved = await api.authorize(other, consent(true), req);
|
||||
expect((approved as { redirect: string }).redirect).toContain('code=the_code');
|
||||
});
|
||||
|
||||
it('OAUTH-DEMO-005: off a demo instance the demo address keeps them too', async () => {
|
||||
vi.stubEnv('DEMO_MODE', 'false');
|
||||
expect(await api.deleteClient(demo, 'c1', req)).toEqual({ success: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { DEMO_ACCOUNT_EMAILS, isDemoAccountEmail } from './demo-account';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
describe('isDemoAccountEmail', () => {
|
||||
it('knows the current and the pre-rename demo address', () => {
|
||||
expect(DEMO_ACCOUNT_EMAILS).toEqual(['demo@trek.app', 'demo@nomad.app']);
|
||||
expect(isDemoAccountEmail('demo@trek.app')).toBe(true);
|
||||
expect(isDemoAccountEmail('demo@nomad.app')).toBe(true);
|
||||
});
|
||||
|
||||
it('is false for every other address and for none at all', () => {
|
||||
expect(isDemoAccountEmail('admin@trek.app')).toBe(false);
|
||||
expect(isDemoAccountEmail('DEMO@trek.app')).toBe(false);
|
||||
expect(isDemoAccountEmail('')).toBe(false);
|
||||
expect(isDemoAccountEmail(null)).toBe(false);
|
||||
expect(isDemoAccountEmail(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* The shared account every visitor of a demo instance signs in as.
|
||||
*
|
||||
* Both sides ask the same question: the server refuses the writes this
|
||||
* account may not make, and the client shows the same sections under a demo
|
||||
* lock. `demo@nomad.app` is the address the account had before the rename, so
|
||||
* an instance upgraded in place still treats it as the demo account.
|
||||
*/
|
||||
export const DEMO_ACCOUNT_EMAILS: readonly string[] = ['demo@trek.app', 'demo@nomad.app'];
|
||||
|
||||
/** Whether this address is the demo account's. Instance demo mode is asked apart. */
|
||||
export function isDemoAccountEmail(email: string | null | undefined): boolean {
|
||||
return !!email && DEMO_ACCOUNT_EMAILS.includes(email);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'عرض تجريبي مباشر',
|
||||
'demo.welcome.title': 'مرحبًا بك في TREK',
|
||||
'demo.welcome.tagline':
|
||||
'مخطط الرحلات الذي تتشاركه: خططوا معًا على الخريطة في الوقت الفعلي، من أول فكرة حتى ألبوم الصور بعد الرحلة.',
|
||||
'demo.welcome.resetTitle': 'يُعاد ضبط كل شيء كل ساعة',
|
||||
'demo.welcome.resetBody':
|
||||
'استكشف وعدّل وأنشئ كما تشاء. عند بداية كل ساعة يعود العرض التجريبي إلى حالته الأولى للزائر التالي.',
|
||||
'demo.welcome.resetIn': 'إعادة الضبط التالية بعد',
|
||||
'demo.welcome.tryTitle': 'جرّبه الآن',
|
||||
'demo.feature.planner.title': 'مخطط الرحلات',
|
||||
'demo.feature.planner.body':
|
||||
'الأيام والأماكن والمسارات على الخريطة، مع الرحلات الجوية والفنادق والمطاعم والحجوزات والتكاليف المشتركة.',
|
||||
'demo.feature.tours.title': 'الجولات والمشي',
|
||||
'demo.feature.tours.body': 'خطط لمسار مشي على شبكة الدروب وتابعه فوق تضاريس ثلاثية الأبعاد مع مخطط الارتفاع.',
|
||||
'demo.feature.studio.title': 'ألبومات الصور في TREK Studio',
|
||||
'demo.feature.studio.body': 'حوّل رحلتك إلى ألبوم صور مصمم، صفحة بعد صفحة.',
|
||||
'demo.feature.collab.title': 'خططوا معًا مباشرة',
|
||||
'demo.feature.collab.body': 'يرى كل من في الرحلة كل تغيير لحظة حدوثه، مع الدردشة والملاحظات والاستطلاعات.',
|
||||
'demo.feature.journey.title': 'مذكرات السفر Journey',
|
||||
'demo.feature.journey.body': 'تدوينات وصور ومزاج وإيجابيات وسلبيات لكل يوم على الطريق.',
|
||||
'demo.feature.atlas.title': 'خريطة العالم Atlas',
|
||||
'demo.feature.atlas.body': 'الدول والمناطق التي زرتها، مع إحصاءات سفرك.',
|
||||
'demo.feature.vacay.title': 'مخطط الإجازات Vacay',
|
||||
'demo.feature.vacay.body': 'خطط لأيام إجازتك حول العطل الرسمية واعرف متى يكون الجميع متفرغين.',
|
||||
'demo.feature.collections.title': 'المجموعات',
|
||||
'demo.feature.collections.body': 'احفظ أماكن من رحلات مختلفة في قوائمك الخاصة وانسخها إلى أي خطة.',
|
||||
'demo.feature.offline.title': 'يعمل دون اتصال',
|
||||
'demo.feature.offline.body': 'ثبّت TREK كتطبيق واحتفظ بخططك وخرائطك وقوائمك دون اتصال.',
|
||||
'demo.welcome.limitsTitle': 'غير متاح في العرض التجريبي',
|
||||
'demo.limit.uploads': 'رفع الصور والمستندات وصور الغلاف',
|
||||
'demo.limit.account': 'تغيير الحساب أو كلمة المرور أو 2FA',
|
||||
'demo.limit.apiKeys': 'إنشاء مفاتيح API',
|
||||
'demo.limit.mcp': 'ربط عملاء MCP',
|
||||
'demo.limit.ai': 'ربط نموذج ذكاء اصطناعي خاص بك',
|
||||
'demo.welcome.fullTitle': 'تضيف النسخة الكاملة',
|
||||
'demo.full.sso': 'تسجيل الدخول عبر OIDC و SSO',
|
||||
'demo.full.backups': 'نسخ احتياطية تلقائية',
|
||||
'demo.full.plugins': 'إضافات لميزات جديدة',
|
||||
'demo.full.assistant': 'مساعد الذكاء الاصطناعي بنموذجك الخاص',
|
||||
'demo.full.admin': 'لوحة إدارة للمستخدمين والإضافات والصلاحيات',
|
||||
'demo.welcome.repo': 'الشيفرة المصدرية على GitHub',
|
||||
'demo.welcome.start': 'ابدأ الاستكشاف',
|
||||
'demo.welcome.credits': 'حقوق الصور',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'حقوق الصور',
|
||||
'demo.credits.intro':
|
||||
'صور هذا العرض التجريبي من ويكيميديا كومنز، ولكل صورة الترخيص المذكور معها. ينطبق هذا الترخيص على الصورة وحدها، لا على TREK ولا على بقية العرض التجريبي.',
|
||||
'demo.credits.by': 'تصوير {author}',
|
||||
'demo.credits.publicDomain': 'ملكية عامة',
|
||||
'demo.credits.back': 'رجوع',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'الوضع التجريبي',
|
||||
'demo.lock.apiKeys': 'لا يمكن إنشاء مفاتيح API في العرض التجريبي.',
|
||||
'demo.lock.mcp': 'لا يمكن ربط عملاء MCP في العرض التجريبي.',
|
||||
'demo.lock.ai': 'لا يمكن إضافة اتصال ذكاء اصطناعي خاص بك في العرض التجريبي.',
|
||||
'demo.lock.aiTitle': 'اربط نموذج ذكاء اصطناعي خاصًا بك',
|
||||
'demo.lock.account': 'لا يمكن تغيير حساب العرض التجريبي المشترك.',
|
||||
'demo.lock.language': 'يعمل العرض التجريبي دائمًا بالإنجليزية.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -47,5 +47,8 @@ const map: TranslationStrings = {
|
||||
'map.overview.dayUnrouted.few': 'تعذّر حساب {count} مراحل من هذا اليوم',
|
||||
'map.overview.dayUnrouted.many': 'تعذّر حساب {count} مرحلة من هذا اليوم',
|
||||
'map.baseLayer.topo': 'طبوغرافية',
|
||||
'map.webgl2.fallback':
|
||||
'لا يوفّر هذا المتصفح WebGL2 الذي تحتاجه خرائط GL، لذا تُعرض الخرائط باستخدام Leaflet بدلًا منها.',
|
||||
'map.webgl2.terrain': 'يحتاج العرض ثلاثي الأبعاد إلى WebGL2، وهذا المتصفح لا يوفّره. تبقى الخريطة ثنائية الأبعاد.',
|
||||
};
|
||||
export default map;
|
||||
|
||||
@@ -302,7 +302,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': 'تعطيل 2FA',
|
||||
'settings.mfa.toastEnabled': 'تم تفعيل المصادقة الثنائية',
|
||||
'settings.mfa.toastDisabled': 'تم تعطيل المصادقة الثنائية',
|
||||
'settings.mfa.demoBlocked': 'غير متاح في الوضع التجريبي',
|
||||
'settings.tabs.offline': 'دون اتصال',
|
||||
'settings.notificationPreferences.email': 'البريد الإلكتروني',
|
||||
'settings.notificationPreferences.webhook': 'Webhook',
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Canlı demo',
|
||||
'demo.welcome.title': 'TREK-ə xoş gəlmisiniz',
|
||||
'demo.welcome.tagline':
|
||||
'Paylaşılan səyahət planlayıcısı: xəritədə real vaxtda birlikdə planlayın, ilk ideyadan səfərdən sonrakı foto kitaba qədər.',
|
||||
'demo.welcome.resetTitle': 'Hər saat hər şey sıfırlanır',
|
||||
'demo.welcome.resetBody':
|
||||
'İstədiyiniz qədər baxın, redaktə edin və yaradın. Hər tam saatda demo növbəti ziyarətçi üçün ilkin vəziyyətinə qayıdır.',
|
||||
'demo.welcome.resetIn': 'Növbəti sıfırlamaya qalıb',
|
||||
'demo.welcome.tryTitle': 'İndi sınayın',
|
||||
'demo.feature.planner.title': 'Səyahət planlayıcısı',
|
||||
'demo.feature.planner.body':
|
||||
'Xəritədə günlər, yerlər və marşrutlar; uçuşlar, otellər, restoranlar, rezervasiyalar və ortaq xərclərlə.',
|
||||
'demo.feature.tours.title': 'Turlar və gəzintilər',
|
||||
'demo.feature.tours.body': 'Cığır şəbəkəsində gəzinti planlayın və hündürlük profili ilə 3D relyefdə izləyin.',
|
||||
'demo.feature.studio.title': 'TREK Studio ilə foto kitablar',
|
||||
'demo.feature.studio.body': 'Səyahəti səhifə səhifə tərtib olunmuş foto kitaba çevirin.',
|
||||
'demo.feature.collab.title': 'Canlı birlikdə planlayın',
|
||||
'demo.feature.collab.body': 'Səfərdəki hər kəs hər dəyişikliyi dərhal görür; söhbət, qeydlər və sorğularla.',
|
||||
'demo.feature.journey.title': 'Journey səyahət gündəliyi',
|
||||
'demo.feature.journey.body': 'Yolda keçən hər gün üçün qeydlər, fotolar, əhval, üstünlüklər və çatışmazlıqlar.',
|
||||
'demo.feature.atlas.title': 'Atlas dünya xəritəsi',
|
||||
'demo.feature.atlas.body': 'Səyahət statistikanızla birlikdə ziyarət etdiyiniz ölkələr və bölgələr.',
|
||||
'demo.feature.vacay.title': 'Vacay məzuniyyət planlayıcısı',
|
||||
'demo.feature.vacay.body': 'İstirahət günlərinizi bayramlara görə planlayın və hamının nə vaxt boş olduğunu görün.',
|
||||
'demo.feature.collections.title': 'Kolleksiyalar',
|
||||
'demo.feature.collections.body':
|
||||
'Müxtəlif səfərlərdən yerləri öz siyahılarınıza yadda saxlayın və istənilən plana köçürün.',
|
||||
'demo.feature.offline.title': 'Oflayn işləyir',
|
||||
'demo.feature.offline.body':
|
||||
'TREK-i tətbiq kimi quraşdırın; planlar, xəritələr və siyahılar bağlantı olmadan da əlinizdə olsun.',
|
||||
'demo.welcome.limitsTitle': 'Demoya daxil deyil',
|
||||
'demo.limit.uploads': 'Foto, sənəd və üz qabığı yükləmək',
|
||||
'demo.limit.account': 'Hesabı, parolu və ya 2FA ayarını dəyişmək',
|
||||
'demo.limit.apiKeys': 'API açarları yaratmaq',
|
||||
'demo.limit.mcp': 'MCP müştərilərini qoşmaq',
|
||||
'demo.limit.ai': 'Öz süni intellekt modelinizi qoşmaq',
|
||||
'demo.welcome.fullTitle': 'Tam versiyada əlavə olaraq',
|
||||
'demo.full.sso': 'OIDC və SSO ilə giriş',
|
||||
'demo.full.backups': 'Avtomatik ehtiyat nüsxələr',
|
||||
'demo.full.plugins': 'Yeni imkanlar üçün plaginlər',
|
||||
'demo.full.assistant': 'Öz modelinizlə süni intellekt köməkçisi',
|
||||
'demo.full.admin': 'İstifadəçilər, əlavələr və icazələr üçün idarəetmə paneli',
|
||||
'demo.welcome.repo': 'GitHub-da mənbə kodu',
|
||||
'demo.welcome.start': 'Kəşf etməyə başlayın',
|
||||
'demo.welcome.credits': 'Foto müəllifləri',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Foto müəllifləri',
|
||||
'demo.credits.intro':
|
||||
'Bu demodakı fotolar Wikimedia Commons-dan götürülüb və hər biri yanında göstərilən lisenziya ilə yayımlanır. Bu lisenziya yalnız fotoya aiddir, TREK-ə və ya demonun qalan hissəsinə aid deyil.',
|
||||
'demo.credits.by': 'Müəllif: {author}',
|
||||
'demo.credits.publicDomain': 'İctimai mülkiyyət',
|
||||
'demo.credits.back': 'Geri',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Demo rejimi',
|
||||
'demo.lock.apiKeys': 'Demoda API açarları yaratmaq olmur.',
|
||||
'demo.lock.mcp': 'Demoda MCP müştərilərini qoşmaq olmur.',
|
||||
'demo.lock.ai': 'Demoda öz süni intellekt bağlantınızı əlavə etmək olmur.',
|
||||
'demo.lock.aiTitle': 'Öz süni intellekt modelinizi qoşun',
|
||||
'demo.lock.account': 'Ortaq demo hesabı dəyişdirilə bilməz.',
|
||||
'demo.lock.language': 'Demo həmişə ingilis dilində işləyir.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
@@ -27,6 +27,7 @@ import dashboard from './dashboard';
|
||||
import dawarich from './dawarich';
|
||||
import day from './day';
|
||||
import dayplan from './dayplan';
|
||||
import demo from './demo';
|
||||
import docsync from './docsync';
|
||||
import ferratas from './ferratas';
|
||||
import files from './files';
|
||||
@@ -118,6 +119,7 @@ const locale = {
|
||||
...pdf,
|
||||
...planner,
|
||||
...day,
|
||||
...demo,
|
||||
...memories,
|
||||
...collab,
|
||||
...perm,
|
||||
|
||||
@@ -45,6 +45,9 @@ const map: TranslationStrings = {
|
||||
'map.overview.dayUnrouted': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
|
||||
'map.overview.dayUnrouted.one': 'Bu günün {count} marşrut hissəsini hesablamaq mümkün olmadı',
|
||||
'map.baseLayer.topo': 'Topoqrafik',
|
||||
'map.webgl2.fallback':
|
||||
'Bu brauzer GL xəritələrinin ehtiyac duyduğu WebGL2-ni təqdim etmir, ona görə xəritələr Leaflet ilə göstərilir.',
|
||||
'map.webgl2.terrain': '3D üçün WebGL2 lazımdır, bu brauzer isə onu təqdim etmir. Xəritə 2D olaraq qalır.',
|
||||
};
|
||||
|
||||
export default map;
|
||||
|
||||
@@ -396,7 +396,6 @@ const settings: TranslationStrings = {
|
||||
'settings.mfa.disable': '2FA-nı deaktiv et',
|
||||
'settings.mfa.toastEnabled': 'İki mərhələli autentifikasiya aktivləşdirildi',
|
||||
'settings.mfa.toastDisabled': 'İki mərhələli autentifikasiya deaktiv edildi',
|
||||
'settings.mfa.demoBlocked': 'Demo rejimində əlçatan deyil',
|
||||
|
||||
'settings.currency': 'Göstərilən valyuta',
|
||||
'settings.currencyHint':
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { TranslationStrings } from '../types';
|
||||
|
||||
const demo: TranslationStrings = {
|
||||
// The welcome dialog a demo instance opens with (components/Demo/DemoWelcome).
|
||||
'demo.welcome.eyebrow': 'Demo ao vivo',
|
||||
'demo.welcome.title': 'Boas vindas ao TREK',
|
||||
'demo.welcome.tagline':
|
||||
'O planejador de viagens para compartilhar: planejem juntos no mapa em tempo real, da primeira ideia ao fotolivro depois da viagem.',
|
||||
'demo.welcome.resetTitle': 'Tudo é redefinido a cada hora',
|
||||
'demo.welcome.resetBody':
|
||||
'Explore, edite e crie o quanto quiser. A cada hora cheia a demo volta ao estado inicial para o próximo visitante.',
|
||||
'demo.welcome.resetIn': 'Próxima redefinição em',
|
||||
'demo.welcome.tryTitle': 'Experimente agora mesmo',
|
||||
'demo.feature.planner.title': 'Planejador de viagens',
|
||||
'demo.feature.planner.body':
|
||||
'Dias, lugares e rotas no mapa, com voos, hotéis, restaurantes, reservas e custos divididos.',
|
||||
'demo.feature.tours.title': 'Passeios e trilhas',
|
||||
'demo.feature.tours.body':
|
||||
'Planeje uma trilha na rede de caminhos e siga a rota sobre o relevo em 3D com o perfil de altitude.',
|
||||
'demo.feature.studio.title': 'Fotolivros com o TREK Studio',
|
||||
'demo.feature.studio.body': 'Transforme uma viagem em um fotolivro diagramado, página por página.',
|
||||
'demo.feature.collab.title': 'Planejar juntos ao vivo',
|
||||
'demo.feature.collab.body': 'Todos na viagem veem cada mudança na hora, com chat, notas e enquetes.',
|
||||
'demo.feature.journey.title': 'Diário de viagem Journey',
|
||||
'demo.feature.journey.body': 'Registros, fotos, humores, prós e contras de cada dia na estrada.',
|
||||
'demo.feature.atlas.title': 'Mapa mundi Atlas',
|
||||
'demo.feature.atlas.body': 'Os países e regiões que você visitou, com suas estatísticas de viagem.',
|
||||
'demo.feature.vacay.title': 'Planejador de férias Vacay',
|
||||
'demo.feature.vacay.body': 'Planeje suas folgas em torno dos feriados e veja quando todos estão livres.',
|
||||
'demo.feature.collections.title': 'Coleções',
|
||||
'demo.feature.collections.body': 'Salve lugares de várias viagens em listas suas e copie para qualquer plano.',
|
||||
'demo.feature.offline.title': 'Funciona offline',
|
||||
'demo.feature.offline.body': 'Instale o TREK como app e mantenha planos, mapas e listas sem conexão.',
|
||||
'demo.welcome.limitsTitle': 'Fora da demo',
|
||||
'demo.limit.uploads': 'Enviar fotos, documentos e capas',
|
||||
'demo.limit.account': 'Alterar a conta, a senha ou a 2FA',
|
||||
'demo.limit.apiKeys': 'Criar chaves de API',
|
||||
'demo.limit.mcp': 'Conectar clientes MCP',
|
||||
'demo.limit.ai': 'Conectar seu próprio modelo de IA',
|
||||
'demo.welcome.fullTitle': 'A versão completa inclui',
|
||||
'demo.full.sso': 'Login com OIDC e SSO',
|
||||
'demo.full.backups': 'Backups automáticos',
|
||||
'demo.full.plugins': 'Plugins para novos recursos',
|
||||
'demo.full.assistant': 'O assistente de IA com seu próprio modelo',
|
||||
'demo.full.admin': 'Um painel de administração para usuários, complementos e permissões',
|
||||
'demo.welcome.repo': 'Código fonte no GitHub',
|
||||
'demo.welcome.start': 'Começar a explorar',
|
||||
'demo.welcome.credits': 'Créditos das fotos',
|
||||
// The photo credits, the welcome dialog's second view (components/Demo/DemoCredits).
|
||||
'demo.credits.title': 'Créditos das fotos',
|
||||
'demo.credits.intro':
|
||||
'As fotos desta demo vêm do Wikimedia Commons, cada uma sob a licença indicada junto a ela. Essa licença vale apenas para a foto, não para o TREK nem para o resto da demo.',
|
||||
'demo.credits.by': 'por {author}',
|
||||
'demo.credits.publicDomain': 'Domínio público',
|
||||
'demo.credits.back': 'Voltar',
|
||||
// The lock over a section the shared demo account may not change (components/Demo/DemoLock).
|
||||
'demo.lock.badge': 'Modo demonstração',
|
||||
'demo.lock.apiKeys': 'Não é possível criar chaves de API na demo.',
|
||||
'demo.lock.mcp': 'Não é possível conectar clientes MCP na demo.',
|
||||
'demo.lock.ai': 'Não é possível adicionar uma conexão de IA própria na demo.',
|
||||
'demo.lock.aiTitle': 'Conectar seu próprio modelo de IA',
|
||||
'demo.lock.account': 'A conta de demonstração compartilhada não pode ser alterada.',
|
||||
'demo.lock.language': 'A demo sempre funciona em inglês.',
|
||||
};
|
||||
|
||||
export default demo;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user