feat: add factory sample machines and robot pricing conversations

This commit is contained in:
2026-10-28 12:53:07 +03:30
parent d0da416923
commit 55d4123fe5
71 changed files with 8179 additions and 3978 deletions
+2
View File
@@ -1,3 +1,4 @@
import { ShowroomDialog } from './ShowroomDialog.jsx';
import { PaymentPanel } from './PaymentPanel.jsx';
import { MapDialog } from './MapDialog.jsx';
import { ShopDialog } from './ShopDialog.jsx';
@@ -9,6 +10,7 @@ import { EndDialog } from './EndDialog.jsx';
export function GameDialogs(props) {
return (
<>
<ShowroomDialog {...props} />
<PaymentPanel {...props} />
<MapDialog {...props} />
<ShopDialog {...props} />
+5 -1
View File
@@ -18,6 +18,7 @@ export function GamePage() {
const nodes = useAppSelector(selectNodes);
const { error } = useGameEngine(root, bridge);
useEffect(() => {
bridge.activate();
const dispose = setupPwa(bridge);
return () => {
dispose();
@@ -32,7 +33,10 @@ export function GamePage() {
for (const name of Object.keys(classes)) document.body.classList.remove(name);
};
}, [nodes.body]);
const props = { nodes, actions: bridge };
const props = {
nodes,
actions: bridge,
};
return (
<main ref={root}>
<GameSurfaces {...props} />
+10 -1
View File
@@ -1,10 +1,19 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} GameToolbarProps */
/** Presents the GameToolbar using the current interface snapshot. */
export function GameToolbar({ nodes, actions }) {
return (
<GameNode as="div" id="toolbar" model={nodes['toolbar']}>
<GameNode
as="button"
id="showroom-open"
type="button"
hidden
model={nodes['showroom-open']}
onAction={() => actions.emit('showroom-open')}
>
مدل‌های کارخانه · Q
</GameNode>
<GameNode
as="button"
id="gps-toggle"
-1
View File
@@ -1,5 +1,4 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} LocationStatusProps */
/** Presents the LocationStatus using the current interface snapshot. */
export function LocationStatus({ nodes }) {
-1
View File
@@ -1,5 +1,4 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MachineDetailsProps */
/** Presents the MachineDetails using the current interface snapshot. */
export function MachineDetails({ nodes, actions }) {
-1
View File
@@ -1,7 +1,6 @@
import { MachineDetails } from './MachineDetails.jsx';
import { MachineList } from './MachineList.jsx';
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} MapDialogProps */
/** Presents the MapDialog view using plain models and semantic native elements.
* @param {MapDialogProps} props - Interface snapshots and callbacks.
-1
View File
@@ -1,5 +1,4 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} RankProgressProps */
/** Presents the RankProgress using the current interface snapshot. */
export function RankProgress({ nodes }) {
+103
View File
@@ -0,0 +1,103 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { SHOWROOM_CATALOG } from '@/constants/showroomCatalog.js';
import { showroomModelById, formatRial } from '@/utils/showroomAnswers.js';
import { ShowroomImage } from './ShowroomImage.jsx';
/** @typedef {{nodes:object,actions:object}} ShowroomDialogProps */
/** Presents the factory sample catalog and questions answered by the robot host.
* @param {ShowroomDialogProps} props - Showroom state and game commands.
* @returns {import('react').ReactNode} Sample dialog, or null before a sample is selected.
*/
export function ShowroomDialog({ nodes, actions }) {
const model = showroomModelById(nodes.showroom?.modelId);
if (!model) return null;
return (
<GameNode
as="dialog"
id="showroom"
model={nodes.showroom}
onClose={() => actions.close('showroom')}
aria-labelledby="showroom-title"
className="w-[min(92vw,52rem)] max-h-[90dvh] overflow-y-auto rounded-xl border border-white/20 bg-surface p-6 text-hud"
>
<header className="mb-5 flex items-center justify-between gap-4">
<h2 id="showroom-title" className="text-xl font-bold">
نمایشگاه کارخانه · {model.name}
</h2>
<button
type="button"
aria-label="بستن معرفی دستگاه"
onClick={() => actions.close('showroom')}
>
✕
</button>
</header>
<nav aria-label="مدل دستگاه" className="mb-5 flex flex-wrap gap-3">
{SHOWROOM_CATALOG.map((item) => (
<button
key={item.id}
className="rounded border border-teal px-4 py-2"
type="button"
aria-pressed={item.id === model.id}
onClick={() => actions.emit('showroom-select', item.id)}
>
{item.shortLabel}
</button>
))}
</nav>
<div className="grid gap-6 md:grid-cols-2">
<ShowroomImage key={model.id} model={model} />
<section>
<h3 className="mb-3 text-lg font-semibold">{model.name}</h3>
<p className="mb-3 text-gold">قیمت خود دستگاه: {formatRial(model.priceNumeric)}</p>
<p>{model.description}</p>
<ul className="my-4 list-inside list-disc">
{model.highlights.map((item) => (
<li key={item}>{item}</li>
))}
</ul>
<p className="text-sm opacity-75">
این بخش معرفی محصول است؛ خرید واقعی انجام نمی‌شود و بودجهٔ هتومان تغییر نمی‌کند.
</p>
</section>
</div>
<div className="mt-5 flex flex-wrap gap-3">
<button
className="rounded bg-teal px-4 py-3 font-bold text-surface"
type="button"
onClick={() =>
actions.emit('showroom-question', {
modelId: model.id,
topic: 'price',
})
}
>
روبات، قیمتش چنده؟
</button>
<button
className="rounded border border-teal px-4 py-3"
type="button"
onClick={() =>
actions.emit('showroom-question', {
modelId: model.id,
topic: 'features',
})
}
>
روبات، امکاناتش چیه؟
</button>
</div>
<div className="mt-4 flex items-start gap-3 border-s-2 border-gold ps-4">
<img
className="h-16 w-16 shrink-0 object-contain"
src="./robot/happy.webp"
alt="روبات راهنمای نمایشگاه"
/>
<p role="status">
{nodes['showroom-response']?.text ??
'روبات: سلام! دربارهٔ قیمت و امکانات این مدل از من بپرس.'}
</p>
</div>
</GameNode>
);
}
+20
View File
@@ -0,0 +1,20 @@
import React from 'react';
import { describe, it, expect, vi, afterEach } from 'vitest';
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
import { ShowroomDialog } from './ShowroomDialog.jsx';
afterEach(cleanup);
describe('showroom robot questions', () => {
it('asks about the selected model and displays catalog data in rial', () => {
const actions = { emit: vi.fn(), close: vi.fn() },
nodes = { showroom: { modelId: 'rm-48', open: false } };
render(<ShowroomDialog nodes={nodes} actions={actions} />);
fireEvent.click(screen.getByRole('button', { name: 'روبات، قیمتش چنده؟', hidden: true }));
expect(actions.emit).toHaveBeenCalledWith('showroom-question', {
modelId: 'rm-48',
topic: 'price',
});
expect(screen.getByText(/۵٬۴۸۰٬۰۰۰٬۰۰۰ ریال/)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '۶۰ کانال', hidden: true }));
expect(actions.emit).toHaveBeenCalledWith('showroom-select', 'rm-60');
});
});
+55
View File
@@ -0,0 +1,55 @@
import { useState } from 'react';
/** @typedef {{model:object}} ShowroomImageProps */
/** Shows supplied catalog assets, with an explicit fallback while files are unavailable.
* @param {ShowroomImageProps} props - Selected catalog model.
* @returns {import('react').ReactElement} Product preview and variant selection.
*/
export function ShowroomImage({ model }) {
const [variant, setVariant] = useState('image');
const [failed, setFailed] = useState({});
const path = model[variant];
const unavailable = failed[path];
const src = new URL(`.${path}`, document.baseURI).href;
return (
<div className="grid gap-3">
{unavailable ? (
<div
className="grid min-h-40 place-content-center rounded border border-white/20 p-4 text-center text-hud"
role="status"
>
نمای سه‌بعدی این مدل داخل کارخانه موجود است؛ فایل تصویر هنوز اضافه نشده.
</div>
) : (
<img
className="mx-auto max-h-64 max-w-full object-contain"
src={src}
alt={`${model.name} · ${variant === 'imageAlt' ? 'رنگ سفید' : variant === 'gif' ? 'نمای متحرک' : 'نمای اصلی'}`}
onError={() =>
setFailed((previous) => ({
...previous,
[path]: true,
}))
}
/>
)}
<div className="flex flex-wrap gap-2">
{[
['image', 'نمای اصلی'],
['imageAlt', 'رنگ سفید'],
['gif', 'نمای متحرک'],
].map(([key, label]) => (
<button
className="rounded border border-white/20 px-3 py-2"
key={key}
type="button"
aria-pressed={variant === key}
onClick={() => setVariant(key)}
>
{label}
</button>
))}
</div>
</div>
);
}
-1
View File
@@ -1,5 +1,4 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} StatusBarProps */
/** Presents the StatusBar using the current interface snapshot. */
export function StatusBar({ nodes }) {
-1
View File
@@ -1,5 +1,4 @@
import { GameNode } from '@/components/shared/GameNode.jsx';
import { ViewLists } from './ViewLists.jsx';
/** @typedef {{nodes:object,actions:object}} TouchControlsProps */
/** Presents the TouchControls using the current interface snapshot. */
export function TouchControls({ nodes, actions }) {