diff --git a/eslint.config.js b/eslint.config.js index d37f4e0353c..1395388242a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,7 @@ export default tseslint.config( './crates/bindings-typescript/test-app/tsconfig.json', './templates/react-ts/tsconfig.json', './templates/chat-react-ts/tsconfig.json', + './templates/money-exchange-react-ts/tsconfig.json', './templates/basic-ts/tsconfig.json', './templates/angular-ts/tsconfig.app.json', './docs/tsconfig.json', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fd210edad05..ef3b1b0ef0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -548,6 +548,70 @@ importers: specifier: ^5.9.3 version: 5.9.3 + templates/money-exchange-react-ts: + dependencies: + react: + specifier: ^18.3.1 + version: 18.3.1 + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + spacetimedb: + specifier: workspace:* + version: link:../../crates/bindings-typescript + devDependencies: + '@eslint/js': + specifier: ^9.17.0 + version: 9.33.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.7.0 + '@testing-library/react': + specifier: ^16.2.0 + version: 16.3.0(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.6.1 + version: 14.6.1(@testing-library/dom@10.4.1) + '@types/react': + specifier: ^18.3.18 + version: 18.3.23 + '@types/react-dom': + specifier: ^18.3.5 + version: 18.3.7(@types/react@18.3.23) + '@vitejs/plugin-react': + specifier: ^5.0.2 + version: 5.0.2(vite@7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2)) + eslint: + specifier: ^9.17.0 + version: 9.33.0(jiti@2.6.1) + eslint-plugin-react-hooks: + specifier: ^5.0.0 + version: 5.2.0(eslint@9.33.0(jiti@2.6.1)) + eslint-plugin-react-refresh: + specifier: ^0.4.16 + version: 0.4.20(eslint@9.33.0(jiti@2.6.1)) + globals: + specifier: ^15.14.0 + version: 15.15.0 + jsdom: + specifier: ^26.0.0 + version: 26.1.0 + prettier: + specifier: ^3.3.3 + version: 3.6.2 + typescript: + specifier: ~5.6.2 + version: 5.6.3 + typescript-eslint: + specifier: ^8.18.2 + version: 8.40.0(eslint@9.33.0(jiti@2.6.1))(typescript@5.6.3) + vite: + specifier: ^7.1.5 + version: 7.1.5(@types/node@24.3.0)(jiti@2.6.1)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + vitest: + specifier: 3.2.4 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.3.0)(jiti@2.6.1)(jsdom@26.1.0)(sass@1.97.1)(terser@5.43.1)(tsx@4.21.0)(yaml@2.8.2) + templates/nuxt-ts: dependencies: nuxt: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 8cf1969dbb3..41ad0496331 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - 'crates/bindings-typescript/test-app' - 'crates/bindings-typescript/case-conversion-test-client' - 'templates/chat-react-ts' + - 'templates/money-exchange-react-ts' - 'templates/react-ts' - 'templates/basic-ts' - 'templates/vue-ts' diff --git a/templates/money-exchange-react-ts/.gitignore b/templates/money-exchange-react-ts/.gitignore new file mode 100644 index 00000000000..63a103aa772 --- /dev/null +++ b/templates/money-exchange-react-ts/.gitignore @@ -0,0 +1,7 @@ +node_modules +dist +*.log + +.DS_Store + +spacetime.local.json diff --git a/templates/money-exchange-react-ts/.template.json b/templates/money-exchange-react-ts/.template.json new file mode 100644 index 00000000000..6f1e9b667b2 --- /dev/null +++ b/templates/money-exchange-react-ts/.template.json @@ -0,0 +1,24 @@ +{ + "description": "Private account money exchange demo with React and TypeScript server", + "client_framework": "React", + "client_lang": "typescript", + "server_lang": "typescript", + "tags": ["Launchpad"], + "builtWith": [ + "react", + "react-dom", + "eslint", + "testing-library", + "vitejs", + "eslint-plugin-react-hooks", + "eslint-plugin-react-refresh", + "globals", + "jsdom", + "prettier", + "typescript", + "typescript-eslint", + "vite", + "vitest", + "spacetimedb" + ] +} diff --git a/templates/money-exchange-react-ts/LICENSE b/templates/money-exchange-react-ts/LICENSE new file mode 120000 index 00000000000..039e117dde2 --- /dev/null +++ b/templates/money-exchange-react-ts/LICENSE @@ -0,0 +1 @@ +../../licenses/apache2.txt \ No newline at end of file diff --git a/templates/money-exchange-react-ts/README.md b/templates/money-exchange-react-ts/README.md new file mode 100644 index 00000000000..fb3361dfc13 --- /dev/null +++ b/templates/money-exchange-react-ts/README.md @@ -0,0 +1,50 @@ +# Money Exchange + +A small React and TypeScript demo for a hackathon: every participant receives a +private account, claims a public nickname, and transfers money to other named +participants in real time. + +The example demonstrates: + +- Private identity-owned accounts and an automatic starter balance +- Atomic transfers implemented as a SpacetimeDB reducer +- Private account changes represented as credit and debit entries +- A public recipient directory without exposing other users' balances + +## Run The Template + +Create and run the app with the SpacetimeDB CLI: + +```bash +spacetime dev --template money-exchange-react-ts +``` + +Open [http://localhost:5173](http://localhost:5173), then open a second +private browser window to create another identity and send payments between +the two users. + +## Explore The Code + +The server module is in `spacetimedb/src/index.ts`. On first connection it +creates a private account containing `$100.00`. Users must claim a unique +nickname before they appear in the recipient directory. + +The `transfer` reducer accepts a recipient identity and a cent amount. It +validates ownership and available funds, debits the sender, credits the +recipient, and writes a `Debit` account change for the sender and a `Credit` +account change for the recipient in one transaction. Errors abort the whole +transaction, so a failed payment never partially changes balances or history. + +The `account` and `account_change` tables are private. The `my_account` and +`my_account_changes` views let each connected identity subscribe only to its +own balance and change history. The public `directory` table contains names +and identities for choosing whom to pay. + +The React client is in `src/App.tsx`; generated type-safe bindings live in +`src/module_bindings`. + +## Extend It + +This example uses play money. Natural hackathon extensions include payment +memos, payment requests, shared wallets, an administrator faucet, or +authenticated user profiles. diff --git a/templates/money-exchange-react-ts/index.html b/templates/money-exchange-react-ts/index.html new file mode 100644 index 00000000000..41c520cfdae --- /dev/null +++ b/templates/money-exchange-react-ts/index.html @@ -0,0 +1,12 @@ + + + + + + Money Exchange + + +
+ + + diff --git a/templates/money-exchange-react-ts/package.json b/templates/money-exchange-react-ts/package.json new file mode 100644 index 00000000000..c42682d1744 --- /dev/null +++ b/templates/money-exchange-react-ts/package.json @@ -0,0 +1,42 @@ +{ + "name": "@clockworklabs/money-exchange-react-ts", + "private": true, + "version": "0.0.1", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "format": "prettier . --write --ignore-path ../../.prettierignore", + "lint": "eslint . && prettier . --check --ignore-path ../../.prettierignore", + "preview": "vite preview", + "test": "vitest run", + "generate": "cargo run -p gen-bindings -- --out-dir src/module_bindings --module-path spacetimedb && prettier --write src/module_bindings", + "spacetime:generate": "spacetime generate --lang typescript --out-dir src/module_bindings --module-path spacetimedb", + "spacetime:publish:local": "spacetime publish --module-path spacetimedb --server local", + "spacetime:publish": "spacetime publish --module-path spacetimedb --server maincloud" + }, + "dependencies": { + "spacetimedb": "workspace:*", + "react": "^18.3.1", + "react-dom": "^18.3.1" + }, + "devDependencies": { + "@eslint/js": "^9.17.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.2.0", + "@testing-library/user-event": "^14.6.1", + "@types/react": "^18.3.18", + "@types/react-dom": "^18.3.5", + "@vitejs/plugin-react": "^5.0.2", + "eslint": "^9.17.0", + "eslint-plugin-react-hooks": "^5.0.0", + "eslint-plugin-react-refresh": "^0.4.16", + "globals": "^15.14.0", + "jsdom": "^26.0.0", + "prettier": "^3.3.3", + "typescript": "~5.6.2", + "typescript-eslint": "^8.18.2", + "vite": "^7.1.5", + "vitest": "3.2.4" + } +} diff --git a/templates/money-exchange-react-ts/spacetimedb/package.json b/templates/money-exchange-react-ts/spacetimedb/package.json new file mode 100644 index 00000000000..2fadee5c338 --- /dev/null +++ b/templates/money-exchange-react-ts/spacetimedb/package.json @@ -0,0 +1,17 @@ +{ + "name": "money-exchange-module", + "version": "1.0.0", + "description": "", + "type": "module", + "scripts": { + "build": "spacetime build", + "publish": "spacetime publish" + }, + "license": "ISC", + "dependencies": { + "spacetimedb": "workspace:*" + }, + "devDependencies": { + "typescript": "~5.6.2" + } +} diff --git a/templates/money-exchange-react-ts/spacetimedb/src/index.ts b/templates/money-exchange-react-ts/spacetimedb/src/index.ts new file mode 100644 index 00000000000..982e1d62483 --- /dev/null +++ b/templates/money-exchange-react-ts/spacetimedb/src/index.ts @@ -0,0 +1,154 @@ +import { schema, SenderError, table, t } from 'spacetimedb/server'; + +const STARTING_BALANCE_CENTS = 10_000n; +const MAX_NAME_LENGTH = 20; +const MAX_U64 = (1n << 64n) - 1n; + +const directory = table( + { name: 'directory', public: true }, + { + identity: t.identity().primaryKey(), + name: t.string(), + nameKey: t.string().unique(), + } +); + +const account = table( + { name: 'account' }, + { + identity: t.identity().primaryKey(), + balanceCents: t.u64(), + } +); + +const changeDirection = t.enum('ChangeDirection', { + Credit: t.unit(), + Debit: t.unit(), +}); + +const accountChange = table( + { name: 'account_change' }, + { + id: t.u64().primaryKey().autoInc(), + accountIdentity: t.identity().index('btree'), + counterpartyIdentity: t.identity(), + direction: changeDirection, + amountCents: t.u64(), + createdAt: t.timestamp(), + } +); + +const spacetimedb = schema({ directory, account, accountChange }); +export default spacetimedb; + +export const onConnect = spacetimedb.clientConnected(ctx => { + if (!ctx.db.account.identity.find(ctx.sender)) { + ctx.db.account.insert({ + identity: ctx.sender, + balanceCents: STARTING_BALANCE_CENTS, + }); + } +}); + +export const my_account = spacetimedb.view( + { name: 'my_account', public: true }, + account.rowType.optional(), + ctx => ctx.db.account.identity.find(ctx.sender) ?? undefined +); + +export const my_account_changes = spacetimedb.view( + { name: 'my_account_changes', public: true }, + t.array(accountChange.rowType), + ctx => [...ctx.db.accountChange.accountIdentity.filter(ctx.sender)] +); + +export const set_name = spacetimedb.reducer( + { name: t.string() }, + (ctx, { name }) => { + if (!ctx.db.account.identity.find(ctx.sender)) { + throw new SenderError('Account is not ready yet'); + } + + const displayName = name.trim(); + if (displayName.length === 0 || displayName.length > MAX_NAME_LENGTH) { + throw new SenderError('Names must be between 1 and 20 characters'); + } + + const nameKey = displayName.toLowerCase(); + const owner = ctx.db.directory.nameKey.find(nameKey); + if (owner && !owner.identity.isEqual(ctx.sender)) { + throw new SenderError('That name is already in use'); + } + + const existing = ctx.db.directory.identity.find(ctx.sender); + if (existing) { + ctx.db.directory.identity.update({ + identity: ctx.sender, + name: displayName, + nameKey, + }); + } else { + ctx.db.directory.insert({ + identity: ctx.sender, + name: displayName, + nameKey, + }); + } + } +); + +export const transfer = spacetimedb.reducer( + { recipient: t.identity(), amountCents: t.u64() }, + (ctx, { recipient: recipientIdentity, amountCents }) => { + const sender = ctx.db.directory.identity.find(ctx.sender); + if (!sender) { + throw new SenderError('Choose a name before sending money'); + } + if (recipientIdentity.isEqual(ctx.sender)) { + throw new SenderError('You cannot send money to yourself'); + } + if (amountCents === 0n) { + throw new SenderError('Amount must be greater than zero'); + } + if (!ctx.db.directory.identity.find(recipientIdentity)) { + throw new SenderError('Recipient does not exist'); + } + + const fromAccount = ctx.db.account.identity.find(ctx.sender); + const toAccount = ctx.db.account.identity.find(recipientIdentity); + if (!fromAccount || !toAccount) { + throw new SenderError('Account does not exist'); + } + if (fromAccount.balanceCents < amountCents) { + throw new SenderError('Insufficient funds'); + } + if (toAccount.balanceCents > MAX_U64 - amountCents) { + throw new SenderError('Recipient balance is too large'); + } + + ctx.db.account.identity.update({ + ...fromAccount, + balanceCents: fromAccount.balanceCents - amountCents, + }); + ctx.db.account.identity.update({ + ...toAccount, + balanceCents: toAccount.balanceCents + amountCents, + }); + ctx.db.accountChange.insert({ + id: 0n, + accountIdentity: ctx.sender, + counterpartyIdentity: recipientIdentity, + direction: { tag: 'Debit' }, + amountCents, + createdAt: ctx.timestamp, + }); + ctx.db.accountChange.insert({ + id: 0n, + accountIdentity: recipientIdentity, + counterpartyIdentity: ctx.sender, + direction: { tag: 'Credit' }, + amountCents, + createdAt: ctx.timestamp, + }); + } +); diff --git a/templates/money-exchange-react-ts/spacetimedb/tsconfig.json b/templates/money-exchange-react-ts/spacetimedb/tsconfig.json new file mode 100644 index 00000000000..83fa7af84f5 --- /dev/null +++ b/templates/money-exchange-react-ts/spacetimedb/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "strict": true, + "declaration": false, + "emitDeclarationOnly": false, + "noEmit": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "allowImportingTsExtensions": true, + "noImplicitAny": true, + "moduleResolution": "bundler", + "isolatedDeclarations": false, + "esModuleInterop": false, + "allowSyntheticDefaultImports": false, + "useDefineForClassFields": true, + "isolatedModules": true + }, + "include": ["src/index.ts", "tests/**/*"], + "exclude": ["node_modules", "**/__tests__/*", "dist/**/*"] +} diff --git a/templates/money-exchange-react-ts/src/.gitattributes b/templates/money-exchange-react-ts/src/.gitattributes new file mode 100644 index 00000000000..f4d6534ab2c --- /dev/null +++ b/templates/money-exchange-react-ts/src/.gitattributes @@ -0,0 +1 @@ +/module_bindings/** linguist-generated=true diff --git a/templates/money-exchange-react-ts/src/App.css b/templates/money-exchange-react-ts/src/App.css new file mode 100644 index 00000000000..3f9f25e7597 --- /dev/null +++ b/templates/money-exchange-react-ts/src/App.css @@ -0,0 +1,230 @@ +.loading, +.welcome { + min-height: 100vh; + display: grid; + place-items: center; + text-align: center; + gap: 0.75rem; + padding: 1.5rem; +} + +.loading { + align-content: center; +} + +.welcome-card { + width: min(100%, 470px); + display: grid; + gap: 1.2rem; + padding: 2.5rem; + border: 1px solid #dce4d6; + border-radius: 1.35rem; + background: #fff; + box-shadow: 0 18px 50px rgb(21 37 24 / 7%); +} + +.eyebrow { + color: #4d7158; + font-size: 0.72rem; + font-weight: 750; + letter-spacing: 0.13em; + text-transform: uppercase; +} + +.app { + width: min(1100px, calc(100% - 2.5rem)); + margin: 0 auto; + padding: 2rem 0 3rem; + display: grid; + gap: 1.4rem; +} + +header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1.25rem; +} + +header h1 { + margin-top: 0.25rem; + font-size: clamp(1.8rem, 4vw, 2.4rem); +} + +.profile { + display: flex; + align-items: center; + gap: 0.8rem; + color: #344439; + font-weight: 650; +} + +.name-form { + display: grid; + gap: 0.55rem; + text-align: left; +} + +.name-form label, +.send-card label { + color: #526056; + font-size: 0.9rem; + font-weight: 600; +} + +.name-form div { + display: flex; + gap: 0.55rem; +} + +.name-form input { + min-width: 210px; +} + +.error { + border-radius: 0.7rem; + background: #fff1f0; + color: #9f2721; + padding: 0.75rem 0.9rem; +} + +.banner { + max-width: 100%; +} + +.dashboard { + display: grid; + grid-template-columns: minmax(240px, 0.95fr) minmax(300px, 1.2fr); + gap: 1.25rem; +} + +.balance-card, +.send-card, +.activity { + border: 1px solid #dce4d6; + border-radius: 1.15rem; + background: #fff; + padding: 1.45rem; +} + +.balance-card { + color: #516157; + display: grid; + align-content: center; + gap: 0.9rem; + background: #1c643e; + border-color: #1c643e; + color: #ddeddf; +} + +.balance-card strong { + color: #fff; + font-size: clamp(2.3rem, 5vw, 3rem); + letter-spacing: -0.05em; +} + +.balance-card small { + line-height: 1.45; +} + +.send-card { + display: grid; + gap: 0.65rem; +} + +.send-card h2 { + margin-bottom: 0.35rem; +} + +.amount { + position: relative; +} + +.amount span { + position: absolute; + left: 0.85rem; + top: 50%; + transform: translateY(-50%); + color: #59675f; +} + +.amount input { + padding-left: 1.7rem; +} + +.send-card button { + margin-top: 0.55rem; +} + +.activity { + display: grid; + gap: 1.1rem; +} + +.activity h2 { + margin-top: 0.3rem; +} + +.empty { + color: #66766a; + padding: 1rem 0; +} + +.activity-list { + display: grid; + gap: 0.2rem; + list-style: none; + margin: 0; + padding: 0; +} + +.activity-list li { + display: flex; + justify-content: space-between; + align-items: center; + gap: 1rem; + border-top: 1px solid #edf1ea; + padding: 0.9rem 0; +} + +.activity-list li div { + display: grid; + gap: 0.3rem; +} + +.activity-list time { + color: #65756a; + font-size: 0.85rem; +} + +.activity-list span { + font-size: 1.05rem; + font-weight: 700; +} + +.outgoing { + color: #333c36; +} + +.incoming { + color: #16733e; +} + +@media (max-width: 700px) { + .app { + width: min(100% - 1.5rem, 500px); + padding-top: 1.25rem; + } + + header { + display: grid; + } + + .dashboard { + grid-template-columns: 1fr; + } + + .welcome-card { + padding: 1.5rem; + } +} diff --git a/templates/money-exchange-react-ts/src/App.test.tsx b/templates/money-exchange-react-ts/src/App.test.tsx new file mode 100644 index 00000000000..2ec7fd0ffa3 --- /dev/null +++ b/templates/money-exchange-react-ts/src/App.test.tsx @@ -0,0 +1,64 @@ +import { render, screen } from '@testing-library/react'; +import { Identity, Timestamp } from 'spacetimedb'; +import { describe, expect, it } from 'vitest'; +import { ActivityFeed } from './App'; +import { formatMoney, parseMoney } from './money'; + +describe('money amounts', () => { + it('formats integer cents as dollars', () => { + expect(formatMoney(10_000n)).toBe('$100.00'); + expect(formatMoney(1_234n)).toBe('$12.34'); + }); + + it('parses valid positive dollar entry', () => { + expect(parseMoney('12.34')).toBe(1_234n); + expect(parseMoney('5')).toBe(500n); + expect(parseMoney('0')).toBeUndefined(); + expect(parseMoney('1.234')).toBeUndefined(); + }); + + it('rejects amounts that cannot be represented as u64 cents', () => { + expect(parseMoney('184467440737095516.15')).toBe( + 18_446_744_073_709_551_615n + ); + expect(parseMoney('184467440737095516.16')).toBeUndefined(); + expect(parseMoney('184467440737095517.16')).toBeUndefined(); + }); +}); + +describe('ActivityFeed', () => { + it('shows private debit and credit entries', () => { + const me = Identity.zero(); + const recipient = Identity.fromString( + '0000000000000000000000000000000000000000000000000000000000000001' + ); + render( + + ); + + expect(screen.getByText('Sent to Ada')).toBeInTheDocument(); + expect(screen.getByText('-$12.34')).toBeInTheDocument(); + expect(screen.getByText('Received from Ada')).toBeInTheDocument(); + expect(screen.getByText('+$5.00')).toBeInTheDocument(); + }); +}); diff --git a/templates/money-exchange-react-ts/src/App.tsx b/templates/money-exchange-react-ts/src/App.tsx new file mode 100644 index 00000000000..c45ee166e44 --- /dev/null +++ b/templates/money-exchange-react-ts/src/App.tsx @@ -0,0 +1,290 @@ +import { useMemo, useState, type FormEvent } from 'react'; +import { Identity, type Timestamp } from 'spacetimedb'; +import { useReducer, useSpacetimeDB, useTable } from 'spacetimedb/react'; +import './App.css'; +import { reducers, tables } from './module_bindings'; +import { formatMoney, parseMoney } from './money'; + +type DirectoryEntry = { + identity: Identity; + name: string; +}; + +type AccountChangeEntry = { + id: bigint; + accountIdentity: Identity; + counterpartyIdentity: Identity; + direction: { tag: 'Credit' } | { tag: 'Debit' }; + amountCents: bigint; + createdAt: Timestamp; +}; + +function errorMessage(error: unknown) { + return error instanceof Error ? error.message : 'The request failed.'; +} + +function shortIdentity(identity: Identity) { + return identity.toHexString().slice(0, 8); +} + +export function ActivityFeed({ + changes, + directory, +}: { + changes: readonly AccountChangeEntry[]; + directory: readonly DirectoryEntry[]; +}) { + const names = new Map( + directory.map(entry => [entry.identity.toHexString(), entry.name]) + ); + const sorted = [...changes].sort((left, right) => + Number( + right.createdAt.microsSinceUnixEpoch - left.createdAt.microsSinceUnixEpoch + ) + ); + + if (sorted.length === 0) { + return ( +

No account changes yet. Send your first payment.

+ ); + } + + return ( +
    + {sorted.map(change => { + const debit = change.direction.tag === 'Debit'; + const counterparty = change.counterpartyIdentity; + const name = + names.get(counterparty.toHexString()) ?? shortIdentity(counterparty); + return ( +
  1. +
    + + {debit ? `Sent to ${name}` : `Received from ${name}`} + + +
    + + {debit ? '-' : '+'} + {formatMoney(change.amountCents)} + +
  2. + ); + })} +
+ ); +} + +function NameForm({ + initialName = '', + onSubmit, +}: { + initialName?: string; + onSubmit: (name: string) => Promise; +}) { + const [name, setName] = useState(initialName); + const [saving, setSaving] = useState(false); + + const submit = async (event: FormEvent) => { + event.preventDefault(); + if (!name.trim()) return; + setSaving(true); + try { + await onSubmit(name); + } finally { + setSaving(false); + } + }; + + return ( +
+ +
+ setName(event.target.value)} + placeholder="Choose a unique name" + value={name} + /> + +
+
+ ); +} + +function App() { + const { identity, isActive: connected } = useSpacetimeDB(); + const [accounts] = useTable(tables.my_account); + const [directory] = useTable(tables.directory); + const [changes] = useTable(tables.my_account_changes); + const setName = useReducer(reducers.setName); + const sendTransfer = useReducer(reducers.transfer); + const [editingName, setEditingName] = useState(false); + const [recipientId, setRecipientId] = useState(''); + const [amount, setAmount] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(''); + + const me = directory.find(row => identity?.isEqual(row.identity)); + const account = accounts[0]; + const availableRecipients = useMemo( + () => + directory + .filter(row => !identity?.isEqual(row.identity)) + .sort((left, right) => left.name.localeCompare(right.name)), + [identity, directory] + ); + + const saveName = async (name: string) => { + setError(''); + try { + await setName({ name }); + setEditingName(false); + } catch (caught) { + setError(errorMessage(caught)); + } + }; + + const submitTransfer = async (event: FormEvent) => { + event.preventDefault(); + const amountCents = parseMoney(amount); + const destination = availableRecipients.find( + row => row.identity.toHexString() === recipientId + ); + if (!destination) { + setError('Choose a recipient.'); + return; + } + if (!amountCents) { + setError('Enter a positive amount with no more than two decimals.'); + return; + } + setError(''); + setSubmitting(true); + try { + await sendTransfer({ + recipient: destination.identity, + amountCents, + }); + setAmount(''); + } catch (caught) { + setError(errorMessage(caught)); + } finally { + setSubmitting(false); + } + }; + + if (!connected || !identity || !account) { + return ( +
+

SpacetimeDB sample

+

Money Exchange

+

Opening your private account...

+
+ ); + } + + if (!me) { + return ( +
+
+

SpacetimeDB sample

+

Money Exchange

+

+ You received {formatMoney(account.balanceCents)}. + Choose a unique nickname to start exchanging money. +

+ {error &&

{error}

} + +
+
+ ); + } + + return ( +
+
+
+

SpacetimeDB sample

+

Money Exchange

+
+ {editingName ? ( + + ) : ( +
+ {me.name} + +
+ )} +
+ + {error && ( +

+ {error} +

+ )} + +
+
+

Your private account balance

+ {formatMoney(account.balanceCents)} + Only you can see this balance and your activity. +
+ +
+

Send money

+ + + +
+ $ + setAmount(event.target.value)} + placeholder="0.00" + value={amount} + /> +
+ +
+
+ +
+
+

Private account history

+

Your balance changes

+
+ +
+
+ ); +} + +export default App; diff --git a/templates/money-exchange-react-ts/src/index.css b/templates/money-exchange-react-ts/src/index.css new file mode 100644 index 00000000000..03b9c00a63a --- /dev/null +++ b/templates/money-exchange-react-ts/src/index.css @@ -0,0 +1,76 @@ +*, +*::before, +*::after { + box-sizing: border-box; +} + +:root { + font-family: + Inter, + ui-sans-serif, + system-ui, + -apple-system, + sans-serif; + color: #162018; + background: #f3f6ee; + font-synthesis: none; + text-rendering: optimizeLegibility; +} + +body { + margin: 0; + min-width: 320px; +} + +button, +input, +select { + font: inherit; +} + +button { + border: 0; + border-radius: 0.65rem; + padding: 0.72rem 1rem; + background: #206c43; + color: white; + cursor: pointer; + font-weight: 650; +} + +button:hover:not(:disabled) { + background: #164b2f; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.55; +} + +button.secondary { + border: 1px solid #ccd7c7; + background: white; + color: #334137; +} + +input, +select { + width: 100%; + border: 1px solid #d3dccd; + border-radius: 0.65rem; + background: #fff; + color: inherit; + padding: 0.72rem 0.85rem; +} + +input:focus, +select:focus { + outline: 2px solid #65b381; + border-color: transparent; +} + +h1, +h2, +p { + margin: 0; +} diff --git a/templates/money-exchange-react-ts/src/main.tsx b/templates/money-exchange-react-ts/src/main.tsx new file mode 100644 index 00000000000..a964fbbec2f --- /dev/null +++ b/templates/money-exchange-react-ts/src/main.tsx @@ -0,0 +1,44 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { Identity } from 'spacetimedb'; +import { SpacetimeDBProvider } from 'spacetimedb/react'; +import App from './App.tsx'; +import './index.css'; +import { DbConnection, ErrorContext } from './module_bindings/index.ts'; + +const HOST = import.meta.env.VITE_SPACETIMEDB_HOST ?? 'ws://localhost:3000'; +const DB_NAME = + import.meta.env.VITE_SPACETIMEDB_DB_NAME ?? 'money-exchange-react-ts'; +const TOKEN_KEY = `${HOST}/${DB_NAME}/auth_token`; + +const onConnect = (_conn: DbConnection, identity: Identity, token: string) => { + localStorage.setItem(TOKEN_KEY, token); + console.log( + 'Connected to SpacetimeDB with identity:', + identity.toHexString() + ); +}; + +const onDisconnect = () => { + console.log('Disconnected from SpacetimeDB'); +}; + +const onConnectError = (_ctx: ErrorContext, err: Error) => { + console.log('Error connecting to SpacetimeDB:', err); +}; + +const connectionBuilder = DbConnection.builder() + .withUri(HOST) + .withDatabaseName(DB_NAME) + .withToken(localStorage.getItem(TOKEN_KEY) || undefined) + .onConnect(onConnect) + .onDisconnect(onDisconnect) + .onConnectError(onConnectError); + +createRoot(document.getElementById('root')!).render( + + + + + +); diff --git a/templates/money-exchange-react-ts/src/module_bindings/directory_table.ts b/templates/money-exchange-react-ts/src/module_bindings/directory_table.ts new file mode 100644 index 00000000000..ab20442094e --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/directory_table.ts @@ -0,0 +1,17 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.row({ + identity: __t.identity().primaryKey(), + name: __t.string(), + nameKey: __t.string().name('name_key'), +}); diff --git a/templates/money-exchange-react-ts/src/module_bindings/index.ts b/templates/money-exchange-react-ts/src/module_bindings/index.ts new file mode 100644 index 00000000000..b41cebe950a --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/index.ts @@ -0,0 +1,174 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +// This was generated using spacetimedb cli version 2.2.0 (commit d62295d89cb41be71b927b62522b8a405ae08a21). + +/* eslint-disable */ +/* tslint:disable */ +import { + DbConnectionBuilder as __DbConnectionBuilder, + DbConnectionImpl as __DbConnectionImpl, + SubscriptionBuilderImpl as __SubscriptionBuilderImpl, + TypeBuilder as __TypeBuilder, + Uuid as __Uuid, + convertToAccessorMap as __convertToAccessorMap, + makeQueryBuilder as __makeQueryBuilder, + procedureSchema as __procedureSchema, + procedures as __procedures, + reducerSchema as __reducerSchema, + reducers as __reducers, + schema as __schema, + t as __t, + table as __table, + type AlgebraicTypeType as __AlgebraicTypeType, + type DbConnectionConfig as __DbConnectionConfig, + type ErrorContextInterface as __ErrorContextInterface, + type Event as __Event, + type EventContextInterface as __EventContextInterface, + type Infer as __Infer, + type QueryBuilder as __QueryBuilder, + type ReducerEventContextInterface as __ReducerEventContextInterface, + type RemoteModule as __RemoteModule, + type SubscriptionEventContextInterface as __SubscriptionEventContextInterface, + type SubscriptionHandleImpl as __SubscriptionHandleImpl, +} from 'spacetimedb'; + +// Import all reducer arg schemas +import SetNameReducer from './set_name_reducer'; +import TransferReducer from './transfer_reducer'; + +// Import all procedure arg schemas + +// Import all table schema definitions +import DirectoryRow from './directory_table'; +import MyAccountRow from './my_account_table'; +import MyAccountChangesRow from './my_account_changes_table'; + +/** Type-only namespace exports for generated type groups. */ + +/** The schema information for all tables in this module. This is defined the same was as the tables would have been defined in the server. */ +const tablesSchema = __schema({ + directory: __table( + { + name: 'directory', + indexes: [ + { + accessor: 'identity', + name: 'directory_identity_idx_btree', + algorithm: 'btree', + columns: ['identity'], + }, + { + accessor: 'nameKey', + name: 'directory_name_key_idx_btree', + algorithm: 'btree', + columns: ['nameKey'], + }, + ], + constraints: [ + { + name: 'directory_identity_key', + constraint: 'unique', + columns: ['identity'], + }, + { + name: 'directory_name_key_key', + constraint: 'unique', + columns: ['nameKey'], + }, + ], + }, + DirectoryRow + ), + my_account: __table( + { + name: 'my_account', + indexes: [], + constraints: [], + }, + MyAccountRow + ), + my_account_changes: __table( + { + name: 'my_account_changes', + indexes: [], + constraints: [], + }, + MyAccountChangesRow + ), +}); + +/** The schema information for all reducers in this module. This is defined the same way as the reducers would have been defined in the server, except the body of the reducer is omitted in code generation. */ +const reducersSchema = __reducers( + __reducerSchema('set_name', SetNameReducer), + __reducerSchema('transfer', TransferReducer) +); + +/** The schema information for all procedures in this module. This is defined the same way as the procedures would have been defined in the server. */ +const proceduresSchema = __procedures(); + +/** The remote SpacetimeDB module schema, both runtime and type information. */ +const REMOTE_MODULE = { + versionInfo: { + cliVersion: '2.2.0' as const, + }, + tables: tablesSchema.schemaType.tables, + reducers: reducersSchema.reducersType.reducers, + ...proceduresSchema, +} satisfies __RemoteModule< + typeof tablesSchema.schemaType, + typeof reducersSchema.reducersType, + typeof proceduresSchema +>; + +/** The tables available in this remote SpacetimeDB module. Each table reference doubles as a query builder. */ +export const tables: __QueryBuilder = + __makeQueryBuilder(tablesSchema.schemaType); + +/** The reducers available in this remote SpacetimeDB module. */ +export const reducers = __convertToAccessorMap( + reducersSchema.reducersType.reducers +); + +/** The procedures available in this remote SpacetimeDB module. */ +export const procedures = __convertToAccessorMap(proceduresSchema.procedures); + +/** The context type returned in callbacks for all possible events. */ +export type EventContext = __EventContextInterface; +/** The context type returned in callbacks for reducer events. */ +export type ReducerEventContext = __ReducerEventContextInterface< + typeof REMOTE_MODULE +>; +/** The context type returned in callbacks for subscription events. */ +export type SubscriptionEventContext = __SubscriptionEventContextInterface< + typeof REMOTE_MODULE +>; +/** The context type returned in callbacks for error events. */ +export type ErrorContext = __ErrorContextInterface; +/** The subscription handle type to manage active subscriptions created from a {@link SubscriptionBuilder}. */ +export type SubscriptionHandle = __SubscriptionHandleImpl; + +/** Builder class to configure a new subscription to the remote SpacetimeDB instance. */ +export class SubscriptionBuilder extends __SubscriptionBuilderImpl< + typeof REMOTE_MODULE +> {} + +/** Builder class to configure a new database connection to the remote SpacetimeDB instance. */ +export class DbConnectionBuilder extends __DbConnectionBuilder {} + +/** The typed database connection to manage connections to the remote SpacetimeDB instance. This class has type information specific to the generated module. */ +export class DbConnection extends __DbConnectionImpl { + /** Creates a new {@link DbConnectionBuilder} to configure and connect to the remote SpacetimeDB instance. */ + static builder = (): DbConnectionBuilder => { + return new DbConnectionBuilder( + REMOTE_MODULE, + (config: __DbConnectionConfig) => + new DbConnection(config) + ); + }; + + /** Creates a new {@link SubscriptionBuilder} to configure a subscription to the remote SpacetimeDB instance. */ + override subscriptionBuilder = (): SubscriptionBuilder => { + return new SubscriptionBuilder(this); + }; +} diff --git a/templates/money-exchange-react-ts/src/module_bindings/my_account_changes_table.ts b/templates/money-exchange-react-ts/src/module_bindings/my_account_changes_table.ts new file mode 100644 index 00000000000..88ddcf70dc2 --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/my_account_changes_table.ts @@ -0,0 +1,23 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; +import { ChangeDirection } from './types'; + +export default __t.row({ + id: __t.u64(), + accountIdentity: __t.identity().name('account_identity'), + counterpartyIdentity: __t.identity().name('counterparty_identity'), + get direction() { + return ChangeDirection; + }, + amountCents: __t.u64().name('amount_cents'), + createdAt: __t.timestamp().name('created_at'), +}); diff --git a/templates/money-exchange-react-ts/src/module_bindings/my_account_table.ts b/templates/money-exchange-react-ts/src/module_bindings/my_account_table.ts new file mode 100644 index 00000000000..ce7e4e3cb5d --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/my_account_table.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default __t.row({ + identity: __t.identity(), + balanceCents: __t.u64().name('balance_cents'), +}); diff --git a/templates/money-exchange-react-ts/src/module_bindings/set_name_reducer.ts b/templates/money-exchange-react-ts/src/module_bindings/set_name_reducer.ts new file mode 100644 index 00000000000..85081559c7d --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/set_name_reducer.ts @@ -0,0 +1,15 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default { + name: __t.string(), +}; diff --git a/templates/money-exchange-react-ts/src/module_bindings/transfer_reducer.ts b/templates/money-exchange-react-ts/src/module_bindings/transfer_reducer.ts new file mode 100644 index 00000000000..71be4086fe1 --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/transfer_reducer.ts @@ -0,0 +1,16 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export default { + recipient: __t.identity(), + amountCents: __t.u64(), +}; diff --git a/templates/money-exchange-react-ts/src/module_bindings/types.ts b/templates/money-exchange-react-ts/src/module_bindings/types.ts new file mode 100644 index 00000000000..75ef06c04a4 --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/types.ts @@ -0,0 +1,49 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { + TypeBuilder as __TypeBuilder, + t as __t, + type AlgebraicTypeType as __AlgebraicTypeType, + type Infer as __Infer, +} from 'spacetimedb'; + +export const Account = __t.object('Account', { + identity: __t.identity(), + balanceCents: __t.u64(), +}); +export type Account = __Infer; + +export const AccountChange = __t.object('AccountChange', { + id: __t.u64(), + accountIdentity: __t.identity(), + counterpartyIdentity: __t.identity(), + get direction() { + return ChangeDirection; + }, + amountCents: __t.u64(), + createdAt: __t.timestamp(), +}); +export type AccountChange = __Infer; + +// The tagged union or sum type for the algebraic type `ChangeDirection`. +export const ChangeDirection = __t.enum('ChangeDirection', { + Credit: __t.unit(), + Debit: __t.unit(), +}); +export type ChangeDirection = __Infer; + +export const Directory = __t.object('Directory', { + identity: __t.identity(), + name: __t.string(), + nameKey: __t.string(), +}); +export type Directory = __Infer; + +export const MyAccount = __t.object('MyAccount', {}); +export type MyAccount = __Infer; + +export const MyAccountChanges = __t.object('MyAccountChanges', {}); +export type MyAccountChanges = __Infer; diff --git a/templates/money-exchange-react-ts/src/module_bindings/types/procedures.ts b/templates/money-exchange-react-ts/src/module_bindings/types/procedures.ts new file mode 100644 index 00000000000..b2102264f4d --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/types/procedures.ts @@ -0,0 +1,8 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from 'spacetimedb'; + +// Import all procedure arg schemas diff --git a/templates/money-exchange-react-ts/src/module_bindings/types/reducers.ts b/templates/money-exchange-react-ts/src/module_bindings/types/reducers.ts new file mode 100644 index 00000000000..54d40e483d4 --- /dev/null +++ b/templates/money-exchange-react-ts/src/module_bindings/types/reducers.ts @@ -0,0 +1,13 @@ +// THIS FILE IS AUTOMATICALLY GENERATED BY SPACETIMEDB. EDITS TO THIS FILE +// WILL NOT BE SAVED. MODIFY TABLES IN YOUR MODULE SOURCE CODE INSTEAD. + +/* eslint-disable */ +/* tslint:disable */ +import { type Infer as __Infer } from 'spacetimedb'; + +// Import all reducer arg schemas +import SetNameReducer from '../set_name_reducer'; +import TransferReducer from '../transfer_reducer'; + +export type SetNameParams = __Infer; +export type TransferParams = __Infer; diff --git a/templates/money-exchange-react-ts/src/money.ts b/templates/money-exchange-react-ts/src/money.ts new file mode 100644 index 00000000000..c2c532523fb --- /dev/null +++ b/templates/money-exchange-react-ts/src/money.ts @@ -0,0 +1,15 @@ +const MAX_AMOUNT_CENTS = (1n << 64n) - 1n; + +export function formatMoney(cents: bigint) { + const dollars = cents / 100n; + const remainder = (cents % 100n).toString().padStart(2, '0'); + return `$${dollars.toLocaleString()}.${remainder}`; +} + +export function parseMoney(input: string) { + const value = input.trim(); + if (!/^\d+(?:\.\d{1,2})?$/.test(value)) return undefined; + const [dollars, cents = ''] = value.split('.'); + const amount = BigInt(dollars) * 100n + BigInt(cents.padEnd(2, '0')); + return amount > 0n && amount <= MAX_AMOUNT_CENTS ? amount : undefined; +} diff --git a/templates/money-exchange-react-ts/src/setupTests.ts b/templates/money-exchange-react-ts/src/setupTests.ts new file mode 100644 index 00000000000..7b0828bfa80 --- /dev/null +++ b/templates/money-exchange-react-ts/src/setupTests.ts @@ -0,0 +1 @@ +import '@testing-library/jest-dom'; diff --git a/templates/money-exchange-react-ts/tsconfig.app.json b/templates/money-exchange-react-ts/tsconfig.app.json new file mode 100644 index 00000000000..4730a6c5f37 --- /dev/null +++ b/templates/money-exchange-react-ts/tsconfig.app.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "types": ["vite/client"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/templates/money-exchange-react-ts/tsconfig.json b/templates/money-exchange-react-ts/tsconfig.json new file mode 100644 index 00000000000..1ffef600d95 --- /dev/null +++ b/templates/money-exchange-react-ts/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/templates/money-exchange-react-ts/tsconfig.node.json b/templates/money-exchange-react-ts/tsconfig.node.json new file mode 100644 index 00000000000..26f831794da --- /dev/null +++ b/templates/money-exchange-react-ts/tsconfig.node.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2022", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts", "vitest.config.ts"] +} diff --git a/templates/money-exchange-react-ts/vite.config.ts b/templates/money-exchange-react-ts/vite.config.ts new file mode 100644 index 00000000000..0466183af6a --- /dev/null +++ b/templates/money-exchange-react-ts/vite.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], +}); diff --git a/templates/money-exchange-react-ts/vitest.config.ts b/templates/money-exchange-react-ts/vitest.config.ts new file mode 100644 index 00000000000..76e577fb930 --- /dev/null +++ b/templates/money-exchange-react-ts/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + test: { + globals: true, + environment: 'jsdom', + setupFiles: './src/setupTests.ts', + }, +});