feat: add factory sample machines and robot pricing conversations
This commit is contained in:
@@ -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} />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,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,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,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,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 }) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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,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,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 }) {
|
||||
|
||||
Reference in New Issue
Block a user