feat: initialize ingest-api with Koa and telemetry handling
- Create ingest-api with Koa framework for handling device telemetry. - Implement telemetry ingestion with MongoDB, Redis, and PostgreSQL. - Add API endpoints for telemetry and health checks. - Set up testing with Vitest for unit and end-to-end tests. - Configure Prettier and ESLint for code formatting and linting. - Create Docker Compose setup for PostgreSQL, MongoDB, and Redis services. - Establish contracts package for shared Zod schemas and TypeScript types. - Implement React frontend with device status and greeting functionalities. - Add VS Code setup documentation for onboarding developers.
This commit is contained in:
1 parent
534fa6dc51
commit
7923ba1b8d
70 files changed
+3085
-3434
No files matched your search
@@ -10,13 +10,18 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^6.3.4",
|
||||
"antd": "^6.6.2",
|
||||
"@reduxjs/toolkit": "^2.12.0",
|
||||
"axios": "^1.20.0",
|
||||
"contracts": "workspace:*",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8"
|
||||
"react-dom": "^19.2.8",
|
||||
"react-redux": "^9.3.0",
|
||||
"react-router-dom": "^7.18.3",
|
||||
"zod": "^3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@tailwindcss/vite": "^4",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
@@ -25,6 +30,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.1.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.4",
|
||||
"globals": "^17.11.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "~6.0.2",
|
||||
"typescript-eslint": "^8.67.0",
|
||||
"vite": "^8.2.2"
|
||||
|
||||
+29
-116
@@ -1,122 +1,35 @@
|
||||
import { useState } from 'react'
|
||||
import heroImg from './assets/hero.png'
|
||||
import reactLogo from './assets/react.svg'
|
||||
import viteLogo from './assets/vite.svg'
|
||||
import './App.css'
|
||||
import { NavLink, Navigate, Route, Routes } from 'react-router-dom'
|
||||
import { DevicesPage } from './pages/DevicesPage'
|
||||
import { GreetingPage } from './pages/GreetingPage'
|
||||
|
||||
function App() {
|
||||
const [count, setCount] = useState(0)
|
||||
const linkClass = ({ isActive }: { isActive: boolean }) =>
|
||||
`rounded-md px-3 py-1.5 text-sm ${
|
||||
isActive ? 'bg-slate-900 text-white' : 'text-slate-600 hover:bg-slate-200'
|
||||
}`
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<>
|
||||
<section id="center">
|
||||
<div className="hero">
|
||||
<img src={heroImg} className="base" width="170" height="179" alt="" />
|
||||
<img src={reactLogo} className="framework" alt="React logo" />
|
||||
<img src={viteLogo} className="vite" alt="Vite logo" />
|
||||
<div className="min-h-screen bg-slate-100">
|
||||
<header className="border-b border-slate-200 bg-white">
|
||||
<div className="mx-auto flex max-w-3xl items-center justify-between px-4 py-3">
|
||||
<span className="font-semibold text-slate-900">marco-trest-test</span>
|
||||
<nav className="flex gap-2">
|
||||
<NavLink to="/greeting" className={linkClass}>
|
||||
Greeting
|
||||
</NavLink>
|
||||
<NavLink to="/devices" className={linkClass}>
|
||||
Devices
|
||||
</NavLink>
|
||||
</nav>
|
||||
</div>
|
||||
<div>
|
||||
<h1>Get started</h1>
|
||||
<p>
|
||||
Edit <code>src/App.tsx</code> and save to test <code>HMR</code>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="counter"
|
||||
onClick={() => setCount((count) => count + 1)}
|
||||
>
|
||||
Count is {count}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
|
||||
<section id="next-steps">
|
||||
<div id="docs">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#documentation-icon"></use>
|
||||
</svg>
|
||||
<h2>Documentation</h2>
|
||||
<p>Your questions, answered</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://vite.dev/" target="_blank">
|
||||
<img className="logo" src={viteLogo} alt="" />
|
||||
Explore Vite
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://react.dev/" target="_blank">
|
||||
<img className="button-icon" src={reactLogo} alt="" />
|
||||
Learn more
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="social">
|
||||
<svg className="icon" role="presentation" aria-hidden="true">
|
||||
<use href="/icons.svg#social-icon"></use>
|
||||
</svg>
|
||||
<h2>Connect with us</h2>
|
||||
<p>Join the Vite community</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://github.com/vitejs/vite" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#github-icon"></use>
|
||||
</svg>
|
||||
GitHub
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://chat.vite.dev/" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#discord-icon"></use>
|
||||
</svg>
|
||||
Discord
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://x.com/vite_js" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#x-icon"></use>
|
||||
</svg>
|
||||
X.com
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://bsky.app/profile/vite.dev" target="_blank">
|
||||
<svg
|
||||
className="button-icon"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<use href="/icons.svg#bluesky-icon"></use>
|
||||
</svg>
|
||||
Bluesky
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="ticks"></div>
|
||||
<section id="spacer"></section>
|
||||
</>
|
||||
</header>
|
||||
<main className="mx-auto max-w-3xl px-4 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<Navigate to="/devices" replace />} />
|
||||
<Route path="/greeting" element={<GreetingPage />} />
|
||||
<Route path="/devices" element={<DevicesPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -0,0 +1,17 @@
|
||||
import axios from 'axios'
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? 'http://127.0.0.1:3000',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
/** Demo only — production UI must not call ingest-api. */
|
||||
export const ingestApi = axios.create({
|
||||
baseURL: import.meta.env.VITE_INGEST_BASE_URL ?? 'http://127.0.0.1:3001',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
+1
-111
@@ -1,111 +1 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
}
|
||||
|
||||
#root {
|
||||
width: 1126px;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
@import 'tailwindcss';
|
||||
@@ -1,10 +1,17 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import { Provider } from 'react-redux'
|
||||
import { BrowserRouter } from 'react-router-dom'
|
||||
import App from './App.tsx'
|
||||
import { store } from './store'
|
||||
import './index.css'
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useEffect } from 'react'
|
||||
import type { DeviceStatus } from 'contracts'
|
||||
import { PRESENCE_TTL_SECONDS } from 'contracts'
|
||||
import { fetchDeviceStatus, pingDevice } from '../store/device-slice'
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks'
|
||||
|
||||
function StatusCard({ title, status }: { title: string; status: DeviceStatus | undefined }) {
|
||||
if (!status) {
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
||||
<p className="mt-2 text-sm text-slate-500">No data yet.</p>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="rounded-xl border border-slate-200 bg-white p-4">
|
||||
<h2 className="font-semibold text-slate-900">{title}</h2>
|
||||
<dl className="mt-3 space-y-1 text-sm text-slate-700">
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>online</dt>
|
||||
<dd className={status.online ? 'font-medium text-emerald-700' : 'text-slate-500'}>
|
||||
{status.online ? 'yes' : 'no'}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>source</dt>
|
||||
<dd>{status.source}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>age</dt>
|
||||
<dd>{status.ageMs === null ? '—' : `${status.ageMs} ms`}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between gap-4">
|
||||
<dt>lastSeen</dt>
|
||||
<dd className="truncate">{status.lastSeen ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>payload</dt>
|
||||
<dd className="mt-1 overflow-auto rounded bg-slate-50 p-2 font-mono text-xs">
|
||||
{JSON.stringify(status.payload)}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
export function DevicesPage() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { deviceId, pair, pinging, error } = useAppSelector((state) => state.device)
|
||||
|
||||
useEffect(() => {
|
||||
void dispatch(fetchDeviceStatus(deviceId))
|
||||
const timer = window.setInterval(() => {
|
||||
void dispatch(fetchDeviceStatus(deviceId))
|
||||
}, 1000)
|
||||
return () => window.clearInterval(timer)
|
||||
}, [dispatch, deviceId])
|
||||
|
||||
return (
|
||||
<section className="space-y-6">
|
||||
<div className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Device status</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
Product UI reads Express → Postgres. Redis is only a TTL presence copy. After{' '}
|
||||
{PRESENCE_TTL_SECONDS}s without a ping, Redis goes empty; Postgres keeps the last row.
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium uppercase tracking-wide text-amber-700">
|
||||
Ping device talks to ingest-api — demo only. Production UI never calls the device API.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
disabled={pinging}
|
||||
onClick={() => void dispatch(pingDevice(deviceId))}
|
||||
className="mt-4 rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{pinging ? 'Pinging…' : `Ping ${deviceId}`}
|
||||
</button>
|
||||
{error ? (
|
||||
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-xl border border-slate-200 bg-slate-50 p-4 text-sm text-slate-700">
|
||||
<p className="mb-2 font-medium text-slate-900">Data flow</p>
|
||||
<pre className="font-mono text-xs leading-6">{`Device ping → Koa ingest :3001
|
||||
→ Mongo (raw event)
|
||||
→ Redis SET device:id EX ${PRESENCE_TTL_SECONDS}s
|
||||
→ Postgres UPSERT device_current_state
|
||||
|
||||
React poll 1s → Express crud :3000
|
||||
→ SELECT postgres snapshot ← near-real-time product path
|
||||
→ GET redis key ← optional presence`}</pre>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<StatusCard title="Postgres (Express product path)" status={pair?.postgres} />
|
||||
<StatusCard title="Redis (TTL presence)" status={pair?.redis} />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { fetchGreeting, setName } from '../store/greeting-slice'
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks'
|
||||
|
||||
export function GreetingPage() {
|
||||
const dispatch = useAppDispatch()
|
||||
const { name, message, status, error } = useAppSelector((state) => state.greeting)
|
||||
const loading = status === 'loading'
|
||||
|
||||
return (
|
||||
<section className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm">
|
||||
<h1 className="text-xl font-semibold text-slate-900">Greeting</h1>
|
||||
<p className="mt-2 text-sm text-slate-600">
|
||||
User lane: axios → Express GET /greeting on crud-api. Schemas come from the contracts
|
||||
package.
|
||||
</p>
|
||||
<form
|
||||
className="mt-6 space-y-4"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
void dispatch(fetchGreeting(name))
|
||||
}}
|
||||
>
|
||||
<label className="block text-sm font-medium text-slate-700">
|
||||
Name
|
||||
<input
|
||||
className="mt-1 w-full rounded-md border border-slate-300 px-3 py-2"
|
||||
value={name}
|
||||
onChange={(event) => dispatch(setName(event.target.value))}
|
||||
autoComplete="nickname"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded-md bg-slate-900 px-4 py-2 text-sm font-medium text-white disabled:opacity-60"
|
||||
>
|
||||
{loading ? 'Fetching…' : 'Fetch greeting'}
|
||||
</button>
|
||||
</form>
|
||||
{error ? (
|
||||
<p className="mt-4 rounded-md bg-red-50 px-3 py-2 text-sm text-red-700">{error}</p>
|
||||
) : null}
|
||||
{message ? (
|
||||
<p className="mt-4 rounded-md bg-emerald-50 px-3 py-2 text-sm text-emerald-800">{message}</p>
|
||||
) : null}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'
|
||||
import {
|
||||
DEMO_DEVICE_ID,
|
||||
deviceStatusPairSchema,
|
||||
telemetryIngestSchema,
|
||||
type DeviceStatusPair,
|
||||
} from 'contracts'
|
||||
import { api, ingestApi } from '../api/client'
|
||||
import { toErrorMessage } from './greeting-slice'
|
||||
|
||||
export type DeviceState = {
|
||||
deviceId: string
|
||||
pair: DeviceStatusPair | null
|
||||
status: 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||
pinging: boolean
|
||||
error: string | null
|
||||
lastPingAt: string | null
|
||||
}
|
||||
|
||||
const initialState: DeviceState = {
|
||||
deviceId: DEMO_DEVICE_ID,
|
||||
pair: null,
|
||||
status: 'idle',
|
||||
pinging: false,
|
||||
error: null,
|
||||
lastPingAt: null,
|
||||
}
|
||||
|
||||
export const fetchDeviceStatus = createAsyncThunk<
|
||||
DeviceStatusPair,
|
||||
string,
|
||||
{ rejectValue: string }
|
||||
>('device/fetchStatus', async (deviceId, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await api.get(`/devices/${deviceId}/status`)
|
||||
return deviceStatusPairSchema.parse(response.data)
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
})
|
||||
|
||||
export const pingDevice = createAsyncThunk<void, string, { rejectValue: string }>(
|
||||
'device/ping',
|
||||
async (deviceId, { rejectWithValue, dispatch }) => {
|
||||
try {
|
||||
const body = telemetryIngestSchema.parse({
|
||||
deviceId,
|
||||
sentAt: new Date().toISOString(),
|
||||
payload: { temp: 20 + Math.round(Math.random() * 5), demo: true },
|
||||
})
|
||||
await ingestApi.post('/telemetry', body)
|
||||
await dispatch(fetchDeviceStatus(deviceId)).unwrap()
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const deviceSlice = createSlice({
|
||||
name: 'device',
|
||||
initialState,
|
||||
reducers: {},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchDeviceStatus.pending, (state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchDeviceStatus.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded'
|
||||
state.pair = action.payload
|
||||
})
|
||||
.addCase(fetchDeviceStatus.rejected, (state, action) => {
|
||||
state.status = 'failed'
|
||||
state.error = action.payload ?? action.error.message ?? 'Request failed'
|
||||
})
|
||||
.addCase(pingDevice.pending, (state) => {
|
||||
state.pinging = true
|
||||
state.error = null
|
||||
})
|
||||
.addCase(pingDevice.fulfilled, (state) => {
|
||||
state.pinging = false
|
||||
state.lastPingAt = new Date().toISOString()
|
||||
})
|
||||
.addCase(pingDevice.rejected, (state, action) => {
|
||||
state.pinging = false
|
||||
state.error = action.payload ?? action.error.message ?? 'Ping failed'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export default deviceSlice.reducer
|
||||
@@ -0,0 +1,92 @@
|
||||
import { createAsyncThunk, createSlice, type PayloadAction } from '@reduxjs/toolkit'
|
||||
import { greetingOutputSchema, greetingQuerySchema, type GreetingOutput } from 'contracts'
|
||||
import { isAxiosError } from 'axios'
|
||||
import { ZodError } from 'zod'
|
||||
import { api } from '../api/client'
|
||||
|
||||
export type GreetingStatus = 'idle' | 'loading' | 'succeeded' | 'failed'
|
||||
|
||||
export type GreetingState = {
|
||||
name: string
|
||||
message: string | null
|
||||
status: GreetingStatus
|
||||
error: string | null
|
||||
}
|
||||
|
||||
const initialState: GreetingState = {
|
||||
name: 'Marco',
|
||||
message: null,
|
||||
status: 'idle',
|
||||
error: null,
|
||||
}
|
||||
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof ZodError) {
|
||||
return error.issues.map((issue) => issue.message).join(', ')
|
||||
}
|
||||
|
||||
if (isAxiosError(error)) {
|
||||
const data = error.response?.data
|
||||
if (typeof data === 'string' && data.length > 0) {
|
||||
return data
|
||||
}
|
||||
|
||||
if (data && typeof data === 'object' && 'message' in data) {
|
||||
const message = (data as { message: unknown }).message
|
||||
if (typeof message === 'string' && message.length > 0) {
|
||||
return message
|
||||
}
|
||||
}
|
||||
|
||||
return error.message
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
|
||||
return 'Request failed'
|
||||
}
|
||||
|
||||
export const fetchGreeting = createAsyncThunk<GreetingOutput, string, { rejectValue: string }>(
|
||||
'greeting/fetch',
|
||||
async (name, { rejectWithValue }) => {
|
||||
try {
|
||||
const query = greetingQuerySchema.parse({ name })
|
||||
const response = await api.get('/greeting', { params: query })
|
||||
return greetingOutputSchema.parse(response.data)
|
||||
} catch (error) {
|
||||
return rejectWithValue(toErrorMessage(error))
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
const greetingSlice = createSlice({
|
||||
name: 'greeting',
|
||||
initialState,
|
||||
reducers: {
|
||||
setName(state, action: PayloadAction<string>) {
|
||||
state.name = action.payload
|
||||
},
|
||||
},
|
||||
extraReducers: (builder) => {
|
||||
builder
|
||||
.addCase(fetchGreeting.pending, (state) => {
|
||||
state.status = 'loading'
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchGreeting.fulfilled, (state, action) => {
|
||||
state.status = 'succeeded'
|
||||
state.message = action.payload.message
|
||||
state.error = null
|
||||
})
|
||||
.addCase(fetchGreeting.rejected, (state, action) => {
|
||||
state.status = 'failed'
|
||||
state.message = null
|
||||
state.error = action.payload ?? action.error.message ?? 'Request failed'
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
export const { setName } = greetingSlice.actions
|
||||
export default greetingSlice.reducer
|
||||
@@ -0,0 +1,5 @@
|
||||
import { useDispatch, useSelector } from 'react-redux'
|
||||
import type { AppDispatch, RootState } from './index'
|
||||
|
||||
export const useAppDispatch = useDispatch.withTypes<AppDispatch>()
|
||||
export const useAppSelector = useSelector.withTypes<RootState>()
|
||||
@@ -0,0 +1,13 @@
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
import deviceReducer from './device-slice'
|
||||
import greetingReducer from './greeting-slice'
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
greeting: greetingReducer,
|
||||
device: deviceReducer,
|
||||
},
|
||||
})
|
||||
|
||||
export type RootState = ReturnType<typeof store.getState>
|
||||
export type AppDispatch = typeof store.dispatch
|
||||
@@ -1,7 +1,12 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
fs: {
|
||||
allow: ['..'],
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user