feat: embeddable CompanyGame with loader, poster, no-WebGL fallback and SEO
- CompanyGame bundles the store, a branded loader with start-up progress, an optional light poster (Enter the game) and a company-info fallback when WebGL is missing. - startMode prop and ?mode= start straight in a mode; ?poster shows the poster first. - Pixel ratio capped at 1.5 on touch devices and 1.75 on desktop (constants/graphics.js). - Build writes the title, description, Organization JSON-LD and a hidden Persian summary into index.html from the company config. - README explains iframe and component embedding. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -237,6 +237,32 @@ node scripts/fetch-imagery.mjs
|
||||
|
||||
**مجوز:** استفادهٔ تجاری و میزبانی نسخهای از تصاویر Esri نیاز به مجوز مناسب از Esri دارد. شرایط را با Esri بررسی کنید. نام منبع در پایین صفحهٔ بازی نمایش داده میشود.
|
||||
|
||||
## جاسازی در سایت شرکت
|
||||
|
||||
بازی کل صفحه را میگیرد، پس بهترین راه جاسازی یک صفحهٔ جدا یا یک `iframe` است:
|
||||
|
||||
```html
|
||||
<iframe src="https://robotmarkets.ir/game/?poster&mode=about" title="بازی روبات مارکت"
|
||||
style="width:100%;height:100dvh;border:0" allow="fullscreen; geolocation; camera"></iframe>
|
||||
```
|
||||
|
||||
- `?poster`: اول یک پوستر سبک با دکمهٔ «ورود به بازی» نشان داده میشود و بازی سهبعدی فقط با کلیک بارگذاری میشود. این برای صفحهٔ اصلی سایت مناسب است.
|
||||
- `?mode=real|offline|about|products`: بازی بدون صفحهٔ شروع، مستقیم در همان حالت شروع میشود. حالت دنیای واقعی همچنان اول پیام ایمنی را نشان میدهد.
|
||||
|
||||
در یک پروژهٔ React همین کار با کامپوننت انجام میشود:
|
||||
|
||||
```jsx
|
||||
import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
|
||||
|
||||
<CompanyGame startMode="products" showPoster />;
|
||||
```
|
||||
|
||||
- `CompanyGame` استور Redux، صفحهٔ بارگذاری با نوار پیشرفت، پوستر و حالت بدون WebGL را خودش دارد.
|
||||
- اگر مرورگر WebGL نداشته باشد، بهجای بازی پیام کوتاه و اطلاعات شرکت (معرفی، تماس و آدرس) نشان داده میشود.
|
||||
- رنگها و اطلاعات برند از `src/config/company.js` میآیند، نه از props، چون مدلهای سهبعدی هم از همان رنگها ساخته میشوند.
|
||||
- عنوان صفحه، توضیح، دادهٔ ساختیافتهٔ Organization و یک خلاصهٔ فارسی پنهان (برای موتورهای جستوجو) هنگام ساخت از همان فایل در `index.html` نوشته میشوند (`src/utils/seoSummary.js`).
|
||||
- برای کارایی: صفحهٔ بازی و Three.js یک بستهٔ جدا هستند، تراکم پیکسل روی گوشی ۱٫۵ و روی دسکتاپ ۱٫۷۵ محدود است، و هنگام بستن بازی همهٔ هندسهها، متریالها، بافتها، تایمرها و حسگرها آزاد میشوند.
|
||||
|
||||
## ساختار
|
||||
|
||||
- `src/config/company.js`: همهٔ اطلاعات شرکت در یک جا: نام، شعار، رنگهای برند (رنگ اصلی `#00a693` و طیف آن)، آدرس، تلفن، ایمیل، شبکههای اجتماعی، دربارهما و فهرست محصولات. مقدارهای `TODO(company)` جایگزینشدنیاند.
|
||||
|
||||
+3
-5
@@ -4,11 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0b1614" />
|
||||
<title>عملیات مشهد | روبات مارکت</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="از کارخانهٔ روبات مارکت تا بوستان ملت: دستگاههای وندینگ را در مشهد پیدا کن و امتیاز بگیر."
|
||||
/>
|
||||
<!-- Title, description and structured data come from the company config (vite.config.js). -->
|
||||
<!--seo-head-->
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
@@ -18,6 +15,7 @@
|
||||
<link rel="apple-touch-icon" href="./icons/apple-touch-icon.png" />
|
||||
</head>
|
||||
<body>
|
||||
<!--seo-summary-->
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
// Device pixel ratio caps. Rendering at the full ratio of a 3× phone screen costs nine times the
|
||||
// pixels for little visible gain, so it is capped; the low setting renders at 1× for weak devices.
|
||||
export const PIXEL_RATIO_LOW = 1;
|
||||
export const PIXEL_RATIO_MOBILE = 1.5;
|
||||
export const PIXEL_RATIO_DESKTOP = 1.75;
|
||||
@@ -1,19 +1,24 @@
|
||||
import { bindRuntime, RUNTIME_INITIALIZERS } from './runtime/runtimeBindings.js';
|
||||
/** Starts isolated systems in dependency order and returns their shared lifecycle cleanup.
|
||||
* @param {{root:HTMLElement,ui:object,signal:AbortSignal}} options - React mount context.
|
||||
* Reports start-up progress (0–1) on the `loading` interface node as each system is ready.
|
||||
* @param {{root:HTMLElement,ui:object,signal:AbortSignal,startMode?:string}} options - React
|
||||
* mount context, and a start mode that skips the start screen.
|
||||
* @returns {Promise<function>} Disposes input, timers, sensors and graphics.
|
||||
*/
|
||||
export async function createGame({ root, ui, signal }) {
|
||||
export async function createGame({ root, ui, signal, startMode }) {
|
||||
const context = {
|
||||
root,
|
||||
ui,
|
||||
signal,
|
||||
startMode: null,
|
||||
requestedMode: startMode ?? null,
|
||||
};
|
||||
bindRuntime(context);
|
||||
try {
|
||||
for (const initialize of RUNTIME_INITIALIZERS) {
|
||||
for (const [index, initialize] of RUNTIME_INITIALIZERS.entries()) {
|
||||
if (signal.aborted) break;
|
||||
await initialize(context);
|
||||
ui.patch('loading', { progress: (index + 1) / RUNTIME_INITIALIZERS.length });
|
||||
}
|
||||
} catch (error) {
|
||||
context.renderer?.dispose();
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
/**
|
||||
* Whether this browser can draw the 3D world. Creates a throwaway canvas and releases its context
|
||||
* straight away, so the check costs nothing once the answer is known.
|
||||
* @returns {boolean} True when a WebGL context is available.
|
||||
*/
|
||||
export function supportsWebgl() {
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
const gl = canvas.getContext('webgl2') ?? canvas.getContext('webgl');
|
||||
gl?.getExtension('WEBGL_lose_context')?.loseContext();
|
||||
return Boolean(gl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,11 @@ import { formatDistance, toLocal } from '../../utils/geo.js';
|
||||
import { strings } from '../../i18n/fa.js';
|
||||
import { COMPANY } from '../../config/company.js';
|
||||
import { SHOWROOM_POINT_ID } from '../../constants/factoryPoints.js';
|
||||
import {
|
||||
PIXEL_RATIO_DESKTOP,
|
||||
PIXEL_RATIO_LOW,
|
||||
PIXEL_RATIO_MOBILE,
|
||||
} from '../../constants/graphics.js';
|
||||
import { createObjectiveArrow } from '../models/objectiveArrow.js';
|
||||
|
||||
// Brand colours for materials and canvas drawings.
|
||||
@@ -95,7 +100,10 @@ export async function initializeRenderer(context) {
|
||||
}
|
||||
})() ??
|
||||
(matchMedia('(pointer: coarse)').matches ? 'low' : 'high');
|
||||
context.renderer.setPixelRatio(context.quality === 'low' ? 1 : Math.min(devicePixelRatio, 1.75));
|
||||
const cap = matchMedia('(pointer: coarse)').matches ? PIXEL_RATIO_MOBILE : PIXEL_RATIO_DESKTOP;
|
||||
context.renderer.setPixelRatio(
|
||||
context.quality === 'low' ? PIXEL_RATIO_LOW : Math.min(devicePixelRatio, cap),
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Initializes scene in dependency order.
|
||||
@@ -1247,6 +1255,10 @@ export async function startGameLoop(context) {
|
||||
hidden: true,
|
||||
});
|
||||
context.frameId = requestAnimationFrame(context.frame);
|
||||
// An embedding page may ask for a mode directly (CompanyGame's startMode, or ?mode=). Real-world
|
||||
// play still shows its safety notice first, and asks for location from the tap on it.
|
||||
if (context.requestedMode === 'real') context.askRealMode(true);
|
||||
else if (context.requestedMode) context.startGame(context.requestedMode);
|
||||
}
|
||||
/**
|
||||
* Initializes dispose in dependency order.
|
||||
|
||||
@@ -3,9 +3,10 @@ import { useEffect, useRef, useState } from 'react';
|
||||
/** Mounts one game runtime and releases its GPU, input, sensor and timer resources.
|
||||
* @param {object} root - React ref containing all render surfaces.
|
||||
* @param {object} bridge - Interface publisher owned by this page.
|
||||
* @param {string} [startMode] - Mode to start in directly, skipping the start screen.
|
||||
* @returns {{error:string|null}} Startup failure for the accessible loading surface.
|
||||
*/
|
||||
export function useGameEngine(root, bridge) {
|
||||
export function useGameEngine(root, bridge, startMode) {
|
||||
const [error, setError] = useState(null);
|
||||
const disposeRef = useRef(null);
|
||||
useEffect(() => {
|
||||
@@ -23,6 +24,7 @@ export function useGameEngine(root, bridge) {
|
||||
root: root.current,
|
||||
ui: bridge,
|
||||
signal: controller.signal,
|
||||
startMode,
|
||||
});
|
||||
if (controller.signal.aborted) dispose();
|
||||
else disposeRef.current = dispose;
|
||||
@@ -40,7 +42,7 @@ export function useGameEngine(root, bridge) {
|
||||
disposeRef.current?.();
|
||||
disposeRef.current = null;
|
||||
};
|
||||
}, [root, bridge]);
|
||||
}, [root, bridge, startMode]);
|
||||
return {
|
||||
error,
|
||||
};
|
||||
|
||||
+16
-1
@@ -31,9 +31,24 @@ export const strings = {
|
||||
hetomanName: 'هتومان',
|
||||
speed: 'km/h',
|
||||
},
|
||||
// Search-engine text written into index.html at build time (utils/seoSummary.js).
|
||||
seo: {
|
||||
title: `${COMPANY.name} | بازی سهبعدی دستگاههای وندینگ`,
|
||||
description: `${COMPANY.tagline}. کارخانه، محصولات و دستگاههای نصبشدهٔ ${COMPANY.shortName} را در یک بازی سهبعدی در مشهد ببینید.`,
|
||||
summaryLabel: 'خلاصهٔ معرفی شرکت',
|
||||
productsHeading: 'محصولات',
|
||||
},
|
||||
// Poster shown before the 3D game loads, and instead of it when the browser cannot draw 3D.
|
||||
poster: {
|
||||
enter: 'ورود به بازی',
|
||||
noWebgl:
|
||||
'مرورگر یا دستگاه شما بازی سهبعدی را اجرا نمیکند. اطلاعات شرکت را اینجا ببینید یا با مرورگر دیگری امتحان کنید.',
|
||||
heroAlt: `نمای کارخانهٔ ${COMPANY.shortName}`,
|
||||
},
|
||||
app: {
|
||||
preparing: 'در حال آمادهسازی بازی…',
|
||||
buildingCity: 'در حال ساختن شهر مشهد…',
|
||||
/** @type {(percent: string) => string} */
|
||||
loadingPercent: (percent) => `${percent}٪`,
|
||||
/** @type {(error: string) => string} */
|
||||
failed: (error) => `بازی اجرا نشد: ${error}`,
|
||||
retry: 'تلاش دوباره',
|
||||
|
||||
+2
-7
@@ -1,13 +1,8 @@
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Provider } from 'react-redux';
|
||||
import { AppRoutes } from '@/routes/AppRoutes.jsx';
|
||||
import { store } from '@/store/index.js';
|
||||
import '@fontsource/vazirmatn/400.css';
|
||||
import '@fontsource/vazirmatn/600.css';
|
||||
import '@fontsource/vazirmatn/800.css';
|
||||
import '@/styles/game.css';
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<Provider store={store}>
|
||||
<AppRoutes />
|
||||
</Provider>,
|
||||
);
|
||||
// The store is provided inside CompanyGame, so the game can also be mounted on its own.
|
||||
createRoot(document.getElementById('root')).render(<AppRoutes />);
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Suspense, lazy, useState } from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { store } from '@/store/index.js';
|
||||
import { supportsWebgl } from '@/game/device/webgl.js';
|
||||
import { GamePoster } from './GamePoster.jsx';
|
||||
import { GameLoader } from './GameLoader.jsx';
|
||||
|
||||
// The game page (and Three.js with it) is a separate chunk, fetched only when the game starts.
|
||||
const GamePage = lazy(() =>
|
||||
import('./GamePage.jsx').then((module) => ({ default: module.GamePage })),
|
||||
);
|
||||
|
||||
/**
|
||||
* @typedef {object} CompanyGameProps
|
||||
* @property {'real' | 'offline' | 'about' | 'products'} [startMode] - Start straight in this mode
|
||||
* instead of showing the start screen.
|
||||
* @property {boolean} [showPoster] - Show a light poster with an "Enter the game" button first,
|
||||
* so the 3D game only loads when the visitor asks (good for landing pages). Default false.
|
||||
*/
|
||||
/**
|
||||
* The whole company game as one self-contained component: store, loader, poster, no-WebGL
|
||||
* fallback and the game itself. Mount it on a full-page route (or in an iframe); the game uses
|
||||
* the whole viewport.
|
||||
* @param {CompanyGameProps} props - Embedding options.
|
||||
* @returns {import('react').ReactElement} Game, poster or fallback.
|
||||
*/
|
||||
export function CompanyGame({ startMode, showPoster = false }) {
|
||||
const [canRender] = useState(supportsWebgl);
|
||||
const [entered, setEntered] = useState(!showPoster);
|
||||
if (!canRender || !entered)
|
||||
return <GamePoster canRender={canRender} onEnter={() => setEntered(true)} />;
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Suspense fallback={<GameLoader nodes={{}} />}>
|
||||
<GamePage startMode={startMode} />
|
||||
</Suspense>
|
||||
</Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { COMPANY } from '@/config/company.js';
|
||||
import { ASSETS } from '@/config/assets.js';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
|
||||
/** @typedef {{nodes: object}} GameLoaderProps */
|
||||
/**
|
||||
* Branded loading screen with a progress bar, shown while the 3D world is being built.
|
||||
* @param {GameLoaderProps} props - Interface snapshots; `loading` carries progress (0–1).
|
||||
* @returns {import('react').ReactElement} Loading screen.
|
||||
*/
|
||||
export function GameLoader({ nodes }) {
|
||||
const loading = nodes.loading ?? {};
|
||||
const percent = Math.round((loading.progress ?? 0) * 100);
|
||||
return (
|
||||
<section id="loading" hidden={loading.hidden} aria-busy={!loading.hidden}>
|
||||
<div className="flex w-64 flex-col items-center gap-4 text-center">
|
||||
<img
|
||||
src={ASSETS.brand.logo}
|
||||
alt={strings.landing.logoAlt}
|
||||
className="h-16 w-16 rounded-2xl"
|
||||
/>
|
||||
<p className="text-lg font-bold text-hud">{COMPANY.shortName}</p>
|
||||
<div
|
||||
role="progressbar"
|
||||
aria-label={strings.app.buildingCity}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={percent}
|
||||
className="h-2 w-full overflow-hidden rounded-full bg-white/10"
|
||||
>
|
||||
{/* Width is the live progress, so it is the one inline style here. */}
|
||||
<div className="h-full rounded-full bg-primary" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<p className="text-sm">
|
||||
{loading.text ?? strings.app.buildingCity}{' '}
|
||||
<span dir="ltr">{strings.app.loadingPercent(percent.toLocaleString('fa-IR'))}</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -6,18 +6,23 @@ import { createInterfaceBridge } from '@/game/interfaceBridge.js';
|
||||
import { useGameEngine } from '@/hooks/useGameEngine.js';
|
||||
import { setupPwa } from '@/game/device/pwa.js';
|
||||
import { GameSurfaces } from './GameSurfaces.jsx';
|
||||
import { GameLoader } from './GameLoader.jsx';
|
||||
import { StartScreen } from './startScreen/StartScreen.jsx';
|
||||
import { GameHud } from './GameHud.jsx';
|
||||
import { GameDialogs } from './GameDialogs.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
|
||||
/** Composes React views around the isolated Three.js runtime. */
|
||||
export function GamePage() {
|
||||
/**
|
||||
* Composes React views around the isolated Three.js runtime.
|
||||
* @param {{startMode?: string}} props - Mode to start in directly, skipping the start screen.
|
||||
* @returns {import('react').ReactElement} Game page.
|
||||
*/
|
||||
export function GamePage({ startMode }) {
|
||||
const root = useRef(null),
|
||||
store = useStore();
|
||||
const bridge = useMemo(() => createInterfaceBridge(store), [store]);
|
||||
const nodes = useAppSelector(selectNodes);
|
||||
const { error } = useGameEngine(root, bridge);
|
||||
const { error } = useGameEngine(root, bridge, startMode);
|
||||
useEffect(() => {
|
||||
bridge.activate();
|
||||
const dispose = setupPwa(bridge);
|
||||
@@ -41,6 +46,7 @@ export function GamePage() {
|
||||
return (
|
||||
<main ref={root}>
|
||||
<GameSurfaces {...props} />
|
||||
<GameLoader {...props} />
|
||||
<StartScreen {...props} />
|
||||
<GameHud {...props} />
|
||||
<GameDialogs {...props} />
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { COMPANY } from '@/config/company.js';
|
||||
import { ASSETS } from '@/config/assets.js';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
import { SECTION_BODIES } from './startScreen/InfoSectionContent.jsx';
|
||||
|
||||
// Company sections the poster shows when the 3D game cannot run.
|
||||
const FALLBACK_SECTIONS = ['about', 'contact', 'address'];
|
||||
|
||||
/** @typedef {{canRender: boolean, onEnter: function}} GamePosterProps */
|
||||
/**
|
||||
* Light poster in place of the 3D game: a hero image and an "Enter the game" button, so an
|
||||
* embedding page loads nothing heavy until the visitor asks. When the browser cannot draw 3D it
|
||||
* explains that and shows the company information instead.
|
||||
* @param {GamePosterProps} props - Whether WebGL works, and the callback that starts the game.
|
||||
* @returns {import('react').ReactElement} Poster.
|
||||
*/
|
||||
export function GamePoster({ canRender, onEnter }) {
|
||||
return (
|
||||
<main className="min-h-dvh bg-surface text-hud">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={ASSETS.brand.hero}
|
||||
alt={strings.poster.heroAlt}
|
||||
className="h-[45dvh] w-full object-cover opacity-60"
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 bg-gradient-to-t from-surface to-transparent p-6 pt-20">
|
||||
<div className="mx-auto max-w-2xl">
|
||||
<p className="text-2xl font-black md:text-4xl">{COMPANY.name}</p>
|
||||
<p className="mt-2 text-primary">{COMPANY.tagline}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mx-auto max-w-2xl p-6">
|
||||
<p className="opacity-80">{strings.landing.intro}</p>
|
||||
{canRender ? (
|
||||
<button
|
||||
type="button"
|
||||
className="start-option start-option-primary mt-6"
|
||||
onClick={onEnter}
|
||||
>
|
||||
{strings.poster.enter}
|
||||
</button>
|
||||
) : (
|
||||
<FallbackInfo />
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Company information for browsers without 3D support.
|
||||
* @returns {import('react').ReactElement} Notice and sections.
|
||||
*/
|
||||
function FallbackInfo() {
|
||||
return (
|
||||
<>
|
||||
<p role="alert" className="mt-6 rounded-xl border border-primary/40 p-4">
|
||||
{strings.poster.noWebgl}
|
||||
</p>
|
||||
{FALLBACK_SECTIONS.map((key) => {
|
||||
const Body = SECTION_BODIES[key];
|
||||
const title = key === 'about' ? strings.landing.history : strings.landing.sections[key];
|
||||
return (
|
||||
<section key={key} className="mt-8">
|
||||
<h2 className="mb-3 text-lg font-bold">{title}</h2>
|
||||
<Body />
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
import { COMPANY } from '@/config/company.js';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
import { GamePoster } from './GamePoster.jsx';
|
||||
import { GameLoader } from './GameLoader.jsx';
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe('GamePoster', () => {
|
||||
it('starts the game from the enter button when 3D works', () => {
|
||||
const onEnter = vi.fn();
|
||||
render(<GamePoster canRender onEnter={onEnter} />);
|
||||
expect(screen.getByText(COMPANY.name)).toBeInTheDocument();
|
||||
fireEvent.click(screen.getByRole('button', { name: strings.poster.enter }));
|
||||
expect(onEnter).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('explains missing 3D support and shows the company information instead', () => {
|
||||
render(<GamePoster canRender={false} onEnter={vi.fn()} />);
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(strings.poster.noWebgl);
|
||||
expect(screen.queryByRole('button', { name: strings.poster.enter })).not.toBeInTheDocument();
|
||||
expect(screen.getByText(COMPANY.about.history)).toBeInTheDocument();
|
||||
expect(screen.getByText(COMPANY.contact.email)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GameLoader', () => {
|
||||
it('shows start-up progress as a percentage', () => {
|
||||
render(<GameLoader nodes={{ loading: { progress: 0.42 } }} />);
|
||||
expect(screen.getByRole('progressbar')).toHaveAttribute('aria-valuenow', '42');
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
import { GameNode } from '@/components/shared/GameNode.jsx';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
/** @typedef {{nodes:object,actions:object}} GameSurfacesProps */
|
||||
/** Presents the GameSurfaces view using plain models and semantic native elements.
|
||||
* @param {GameSurfacesProps} props - Interface snapshots and callbacks.
|
||||
@@ -18,9 +17,6 @@ export function GameSurfaces({ nodes }) {
|
||||
model={nodes['camera']}
|
||||
></GameNode>
|
||||
<GameNode as="canvas" id="world" model={nodes['world']} />
|
||||
<GameNode as="div" id="loading" model={nodes['loading']}>
|
||||
{strings.app.buildingCity}
|
||||
</GameNode>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+19
-13
@@ -1,15 +1,21 @@
|
||||
import { Suspense, lazy } from 'react';
|
||||
import { strings } from '@/i18n/fa.js';
|
||||
const GamePage = lazy(() =>
|
||||
import('@/pages/game/GamePage.jsx').then((module) => ({
|
||||
default: module.GamePage,
|
||||
})),
|
||||
);
|
||||
/** Loads the one game page on demand while keeping a visible startup status. */
|
||||
import { CompanyGame } from '@/pages/game/CompanyGame.jsx';
|
||||
import { START_MODES } from '@/constants/startModes.js';
|
||||
|
||||
/**
|
||||
* Reads the embedding options from the address: `?mode=about` starts in a mode, `?poster` shows
|
||||
* the light poster first. An iframe on the company site can set them in its src.
|
||||
* @returns {{startMode?: string, showPoster: boolean}} CompanyGame props.
|
||||
*/
|
||||
function optionsFromUrl() {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const mode = params.get('mode');
|
||||
return {
|
||||
startMode: START_MODES.includes(mode) ? mode : undefined,
|
||||
showPoster: params.has('poster'),
|
||||
};
|
||||
}
|
||||
|
||||
/** The app has one page: the company game, configured from the address. */
|
||||
export function AppRoutes() {
|
||||
return (
|
||||
<Suspense fallback={<p role="status">{strings.app.preparing}</p>}>
|
||||
<GamePage />
|
||||
</Suspense>
|
||||
);
|
||||
return <CompanyGame {...optionsFromUrl()} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
import { COMPANY } from '../config/company.js';
|
||||
import { strings } from '../i18n/fa.js';
|
||||
|
||||
// Builds the search-engine content written into index.html at build time (see vite.config.js):
|
||||
// the page title and description, a visually hidden Persian summary of the company and its
|
||||
// products, and Organization structured data. Crawlers read it without running the 3D game.
|
||||
|
||||
/**
|
||||
* Escapes text for use inside HTML.
|
||||
* @param {string} text - Plain text.
|
||||
* @returns {string} HTML-safe text.
|
||||
*/
|
||||
const escapeHtml = (text) =>
|
||||
String(text).replace(
|
||||
/[&<>"']/g,
|
||||
(ch) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[ch],
|
||||
);
|
||||
|
||||
/**
|
||||
* Title and description tags.
|
||||
* @returns {string} HTML for the head.
|
||||
*/
|
||||
export function seoHead() {
|
||||
return [
|
||||
`<title>${escapeHtml(strings.seo.title)}</title>`,
|
||||
`<meta name="description" content="${escapeHtml(strings.seo.description)}" />`,
|
||||
].join('\n ');
|
||||
}
|
||||
|
||||
/**
|
||||
* Visually hidden summary of the company, its products and contact details.
|
||||
* @returns {string} HTML for the body.
|
||||
*/
|
||||
export function seoSummary() {
|
||||
const { contact, about, products } = COMPANY;
|
||||
const items = products
|
||||
.map((p) => `<li><h3>${escapeHtml(p.name)}</h3><p>${escapeHtml(p.description)}</p></li>`)
|
||||
.join('');
|
||||
const address = strings.landing.fullAddress(contact.address, contact.city);
|
||||
return `<section class="sr-only" aria-label="${escapeHtml(strings.seo.summaryLabel)}">
|
||||
<h1>${escapeHtml(COMPANY.name)}</h1>
|
||||
<p>${escapeHtml(COMPANY.tagline)}</p>
|
||||
<p>${escapeHtml(about.history)}</p>
|
||||
<h2>${escapeHtml(strings.seo.productsHeading)}</h2>
|
||||
<ul>${items}</ul>
|
||||
<h2>${escapeHtml(strings.landing.sections.contact)}</h2>
|
||||
<p>${escapeHtml(address)}</p>
|
||||
<p>${escapeHtml(`${strings.landing.phone}: ${contact.phone}`)}</p>
|
||||
<p>${escapeHtml(`${strings.landing.email}: ${contact.email}`)}</p>
|
||||
</section>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Organization structured data (schema.org JSON-LD).
|
||||
* @returns {string} Script tag.
|
||||
*/
|
||||
export function seoStructuredData() {
|
||||
const { contact } = COMPANY;
|
||||
const data = {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'Organization',
|
||||
name: COMPANY.name,
|
||||
description: COMPANY.tagline,
|
||||
url: contact.website,
|
||||
email: contact.email,
|
||||
telephone: contact.phone,
|
||||
address: {
|
||||
'@type': 'PostalAddress',
|
||||
streetAddress: contact.address,
|
||||
addressLocality: contact.city,
|
||||
addressCountry: 'IR',
|
||||
},
|
||||
sameAs: contact.socials.map((social) => social.url),
|
||||
};
|
||||
// "<" is escaped so no value can close the script tag early.
|
||||
const json = JSON.stringify(data).replace(/</g, '\\u003c');
|
||||
return `<script type="application/ld+json">${json}</script>`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { COMPANY } from '../src/config/company.js';
|
||||
import { seoHead, seoStructuredData, seoSummary } from '../src/utils/seoSummary.js';
|
||||
|
||||
test('The page head carries the company title and description', () => {
|
||||
const head = seoHead();
|
||||
assert.ok(head.includes(`<title>${COMPANY.name}`));
|
||||
assert.match(head, /<meta name="description" content="[^"]+"/);
|
||||
});
|
||||
|
||||
test('The hidden summary lists the company, every product and the contact details', () => {
|
||||
const summary = seoSummary();
|
||||
assert.ok(summary.startsWith('<section class="sr-only"'));
|
||||
for (const product of COMPANY.products) assert.ok(summary.includes(product.name));
|
||||
assert.ok(summary.includes(COMPANY.contact.email));
|
||||
});
|
||||
|
||||
test('Structured data is valid JSON-LD for the organization', () => {
|
||||
const json = seoStructuredData().replace(/^<script[^>]*>|<\/script>$/g, '');
|
||||
const data = JSON.parse(json);
|
||||
assert.equal(data['@type'], 'Organization');
|
||||
assert.equal(data.name, COMPANY.name);
|
||||
assert.equal(data.address.addressLocality, COMPANY.contact.city);
|
||||
});
|
||||
+14
-1
@@ -4,6 +4,7 @@ import { defineConfig } from 'vite';
|
||||
import { readdirSync, statSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { seoHead, seoStructuredData, seoSummary } from './src/utils/seoSummary.js';
|
||||
|
||||
const listFiles = (dir) =>
|
||||
readdirSync(dir).flatMap((name) => {
|
||||
@@ -62,11 +63,23 @@ self.addEventListener('fetch', event => {
|
||||
};
|
||||
}
|
||||
|
||||
// Writes the company's title, description, structured data and a hidden Persian summary into
|
||||
// index.html, so search engines and link previews see real content without running the game.
|
||||
function companySeo() {
|
||||
return {
|
||||
name: 'company-seo',
|
||||
transformIndexHtml: (html) =>
|
||||
html
|
||||
.replace('<!--seo-head-->', `${seoHead()}\n ${seoStructuredData()}`)
|
||||
.replace('<!--seo-summary-->', seoSummary()),
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
// Relative assets also load under a GitHub Pages repository path.
|
||||
base: './',
|
||||
resolve: { alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) } },
|
||||
plugins: [react(), offlineServiceWorker()],
|
||||
plugins: [react(), companySeo(), offlineServiceWorker()],
|
||||
// Top-level await loads the building and imagery files before the world is built.
|
||||
build: { target: 'es2022' },
|
||||
server: {
|
||||
|
||||
Reference in New Issue
Block a user