diff --git a/apps/react/package.json b/apps/react/package.json index 5fe6378..a5284bf 100644 --- a/apps/react/package.json +++ b/apps/react/package.json @@ -10,16 +10,20 @@ "preview": "vite preview" }, "dependencies": { + "@spec/relay": "workspace:*", + "@spec/schemas": "workspace:*", "@tanstack/react-query": "5", "@tanstack/react-router": "1", "@valkyr/db": "1", "@valkyr/event-emitter": "npm:@jsr/valkyr__event-emitter@1", "fast-equals": "5", "react": "19", - "react-dom": "19" + "react-dom": "19", + "zod": "4" }, "devDependencies": { "@eslint/js": "9", + "@tanstack/react-router-devtools": "1", "@types/react": "19", "@types/react-dom": "19", "@vitejs/plugin-react": "4", diff --git a/apps/react/src/App.css b/apps/react/src/App.css deleted file mode 100644 index b9d355d..0000000 --- a/apps/react/src/App.css +++ /dev/null @@ -1,42 +0,0 @@ -#root { - max-width: 1280px; - margin: 0 auto; - padding: 2rem; - text-align: center; -} - -.logo { - height: 6em; - padding: 1.5em; - will-change: filter; - transition: filter 300ms; -} -.logo:hover { - filter: drop-shadow(0 0 2em #646cffaa); -} -.logo.react:hover { - filter: drop-shadow(0 0 2em #61dafbaa); -} - -@keyframes logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: no-preference) { - a:nth-of-type(2) .logo { - animation: logo-spin infinite 20s linear; - } -} - -.card { - padding: 2em; -} - -.read-the-docs { - color: #888; -} diff --git a/apps/react/src/App.tsx b/apps/react/src/App.tsx deleted file mode 100644 index cfff5c9..0000000 --- a/apps/react/src/App.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import "./App.css"; - -import { useState } from "react"; - -import viteLogo from "/vite.svg"; - -import reactLogo from "./assets/react.svg"; -import { Session } from "./components/Session.tsx"; - -function App() { - const [count, setCount] = useState(0); - - return ( - <> -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

Click on the Vite and React logos to learn more

- - - ); -} - -export default App; diff --git a/apps/react/src/index.css b/apps/react/src/index.css deleted file mode 100644 index 08a3ac9..0000000 --- a/apps/react/src/index.css +++ /dev/null @@ -1,68 +0,0 @@ -:root { - font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; - line-height: 1.5; - font-weight: 400; - - color-scheme: light dark; - color: rgba(255, 255, 255, 0.87); - background-color: #242424; - - font-synthesis: none; - text-rendering: optimizeLegibility; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; -} - -a { - font-weight: 500; - color: #646cff; - text-decoration: inherit; -} -a:hover { - color: #535bf2; -} - -body { - margin: 0; - display: flex; - place-items: center; - min-width: 320px; - min-height: 100vh; -} - -h1 { - font-size: 3.2em; - line-height: 1.1; -} - -button { - border-radius: 8px; - border: 1px solid transparent; - padding: 0.6em 1.2em; - font-size: 1em; - font-weight: 500; - font-family: inherit; - background-color: #1a1a1a; - cursor: pointer; - transition: border-color 0.25s; -} -button:hover { - border-color: #646cff; -} -button:focus, -button:focus-visible { - outline: 4px auto -webkit-focus-ring-color; -} - -@media (prefers-color-scheme: light) { - :root { - color: #213547; - background-color: #ffffff; - } - a:hover { - color: #747bff; - } - button { - background-color: #f9f9f9; - } -} diff --git a/apps/react/src/libraries/form.ts b/apps/react/src/libraries/form.ts new file mode 100644 index 0000000..665f595 --- /dev/null +++ b/apps/react/src/libraries/form.ts @@ -0,0 +1,269 @@ +import z, { type ZodObject, type ZodRawShape } from "zod"; + +export class Form>> { + readonly schema: ZodObject; + + readonly inputs: Partial = {}; + + #debounce: FormDebounce = { + validate: {}, + }; + + #defaults: Partial; + #errors: FormErrors = {}; + #elements: Record = {}; + + #onChange?: OnChangeCallback; + #onProcessing?: OnProcessingCallback; + #onError?: OnErrorCallback; + #onSubmit?: OnSubmitCallback; + #onResponse?: OnResponseCallback; + + /* + |-------------------------------------------------------------------------------- + | Constructor + |-------------------------------------------------------------------------------- + */ + + constructor(schema: TSchema, defaults: Partial = {}) { + this.schema = z.object(schema); + this.#defaults = defaults; + this.#bindMethods(); + this.#setDefaults(); + this.#setSubmit(); + } + + #bindMethods() { + this.register = this.register.bind(this); + this.set = this.set.bind(this); + this.get = this.get.bind(this); + this.validate = this.validate.bind(this); + this.submit = this.submit.bind(this); + } + + #setDefaults() { + for (const key in this.#defaults) { + this.inputs[key] = this.#defaults[key] ?? ("" as any); + } + } + + #setSubmit() { + if ((this.constructor as any).submit !== undefined) { + this.onSubmit((this.constructor as any).submit); + } + } + + /* + |-------------------------------------------------------------------------------- + | Accessors + |-------------------------------------------------------------------------------- + */ + + get isValid(): boolean { + return Object.keys(this.#getFormErrors()).length === 0; + } + + get hasError() { + return Object.keys(this.errors).length !== 0; + } + + get errors(): FormErrors { + return this.#errors; + } + + set errors(value: FormErrors) { + this.#errors = value; + this.#onError?.(value); + } + + /** + * Register a input element with the form. This registers form related methods and a + * reference to the element itself that can be utilized by the form. + * + * @param name - Name of the input field. + */ + register(name: TKey) { + return { + name, + ref: (element: HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | null) => { + if (element !== null) { + this.#elements[name as string] = element; + } + }, + defaultValue: this.get(name), + onChange: ({ target: { value } }: any) => { + this.set(name, value); + }, + }; + } + + /* + |-------------------------------------------------------------------------------- + | Registrars + |-------------------------------------------------------------------------------- + */ + + onChange(callback: OnChangeCallback): this { + this.#onChange = callback; + return this; + } + + onProcessing(callback: OnProcessingCallback): this { + this.#onProcessing = callback; + return this; + } + + onError(callback: OnErrorCallback): this { + this.#onError = callback; + return this; + } + + onSubmit(callback: OnSubmitCallback): this { + this.#onSubmit = callback; + return this; + } + + onResponse(callback: OnResponseCallback): this { + this.#onResponse = callback; + return this; + } + + /* + |-------------------------------------------------------------------------------- + | Data + |-------------------------------------------------------------------------------- + */ + + /** + * Set the value of an input field. + * + * @param name - Name of the input field. + * @param value - Value to set. + */ + set(name: TKey, value: TInputs[TKey]): void { + this.inputs[name] = value; + this.#onChange?.(name, value); + clearTimeout(this.#debounce.validate[name]); + this.#debounce.validate[name] = setTimeout(() => { + this.validate(name); + }, 200); + } + + /** + * Get the current input values or a specific input value. + * + * @param name - Name of the input field. _(Optional)_ + */ + get(): Partial; + get(name: TKey): TInputs[TKey] | undefined; + get(name?: TKey): Partial | TInputs[TKey] | undefined { + if (name === undefined) { + return { ...this.inputs }; + } + return this.inputs[name]; + } + + /** + * Reset form back to its default values. + */ + reset() { + for (const key in this.inputs) { + const value = this.#defaults[key] ?? ""; + (this.inputs as any)[key] = value; + if (this.#elements[key] !== undefined) { + (this.#elements as any)[key].value = value; + } + } + } + + /* + |-------------------------------------------------------------------------------- + | Submission + |-------------------------------------------------------------------------------- + */ + + async submit(event: any) { + event.preventDefault?.(); + this.#onProcessing?.(true); + this.validate(); + if (this.hasError === false) { + try { + const response = await this.#onSubmit?.(this.schema.parse(this.inputs) as TInputs); + this.#onResponse?.(undefined, response); + } catch (error) { + this.#onResponse?.(error, undefined as any); + } + } + this.#onProcessing?.(false); + this.reset(); + } + + validate(name?: keyof TInputs) { + if (name !== undefined) { + this.#validateInput(name); + } else { + this.#validateForm(); + } + } + + #validateForm(): void { + this.errors = this.#getFormErrors(); + } + + #validateInput(name: keyof TInputs): void { + const errors = this.#getFormErrors(); + let hasChanges = false; + if (errors[name] === undefined && this.errors[name] !== undefined) { + delete this.errors[name]; + hasChanges = true; + } + if (errors[name] !== undefined && this.errors[name] !== errors[name]) { + this.errors[name] = errors[name]; + hasChanges = true; + } + if (hasChanges === true) { + this.#onError?.({ ...this.errors }); + } + } + + #getFormErrors(): FormErrors { + const result = this.schema.safeParse(this.inputs); + if (result.success === false) { + throw result.error.flatten; + // return result.error.details.reduce>( + // (error, next) => ({ + // ...error, + // [next.path[0]]: next.message, + // }), + // {}, + // ); + } + return {}; + } +} + +/* + |-------------------------------------------------------------------------------- + | Types + |-------------------------------------------------------------------------------- + */ + +type OnChangeCallback = (name: TKey, value: TInputs[TKey]) => void; + +type OnProcessingCallback = (value: boolean) => void; + +type OnErrorCallback = (errors: FormErrors) => void; + +type OnSubmitCallback = (inputs: TInputs) => Promise; + +type OnResponseCallback = (err: Error, res: Response) => void; + +type FormDebounce = { + validate: { + [TKey in keyof TInputs]?: any; + }; +}; + +type FormErrors = { + [TKey in keyof TInputs]?: string; +}; diff --git a/apps/react/src/main.tsx b/apps/react/src/main.tsx index 33bb9dd..e927e5c 100644 --- a/apps/react/src/main.tsx +++ b/apps/react/src/main.tsx @@ -1,14 +1,9 @@ -import "./index.css"; - -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { createRouter, RouterProvider } from "@tanstack/react-router"; import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; import { routeTree } from "./routes.tsx"; -const queryClient = new QueryClient(); - const router = createRouter({ routeTree }); declare module "@tanstack/react-router" { @@ -19,8 +14,6 @@ declare module "@tanstack/react-router" { createRoot(document.getElementById("root")!).render( - - - + , ); diff --git a/apps/react/src/routes.tsx b/apps/react/src/routes.tsx index a3356be..830fe4b 100644 --- a/apps/react/src/routes.tsx +++ b/apps/react/src/routes.tsx @@ -1,9 +1,29 @@ -import { createRootRoute } from "@tanstack/react-router"; +import { createRootRoute, createRoute, Outlet } from "@tanstack/react-router"; +import { TanStackRouterDevtools } from "@tanstack/react-router-devtools"; -import App from "./App.tsx"; +import { CreateAccountView } from "./views/account/create.view.tsx"; const rootRoute = createRootRoute({ - component: App, + component: () => ( + <> + + + + ), }); -export const routeTree = rootRoute.addChildren([]); +const homeRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/", + component: function Index() { + return

Welcome Home!

; + }, +}); + +const createAccountRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/accounts", + component: CreateAccountView, +}); + +export const routeTree = rootRoute.addChildren([homeRoute, createAccountRoute]); diff --git a/apps/react/src/services/api.ts b/apps/react/src/services/api.ts index 24d9bed..2f23a83 100644 --- a/apps/react/src/services/api.ts +++ b/apps/react/src/services/api.ts @@ -9,7 +9,7 @@ export const api = makeClient( }), }, { - account: (await import("@spec/modules/account/mod.ts")).routes, - auth: (await import("@spec/modules/auth/mod.ts")).routes, + account: (await import("@spec/schemas/account/routes.ts")).routes, + auth: (await import("@spec/schemas/auth/routes.ts")).routes, }, ); diff --git a/apps/react/src/views/account/create.controller.ts b/apps/react/src/views/account/create.controller.ts new file mode 100644 index 0000000..0f082b4 --- /dev/null +++ b/apps/react/src/views/account/create.controller.ts @@ -0,0 +1,36 @@ +import z from "zod"; + +import { Controller } from "../../libraries/controller.ts"; +import { Form } from "../../libraries/form.ts"; +import { api } from "../../services/api.ts"; + +const inputs = { + givenName: z.string(), + familyName: z.string(), + email: z.string(), +}; + +export class CreateController extends Controller<{ + form: Form; +}> { + async onInit() { + return { + form: new Form(inputs).onSubmit(async ({ givenName, familyName, email }) => { + const response = await api.account.create({ + body: { + name: { + given: givenName, + family: familyName, + }, + email, + }, + }); + if ("error" in response) { + console.log(response.error); + } else { + console.log(response.data); + } + }), + }; + } +} diff --git a/apps/react/src/views/account/create.view.tsx b/apps/react/src/views/account/create.view.tsx new file mode 100644 index 0000000..fcae7ea --- /dev/null +++ b/apps/react/src/views/account/create.view.tsx @@ -0,0 +1,13 @@ +import { makeControllerView } from "../../libraries/view.ts"; +import { CreateController } from "./create.controller.ts"; + +export const CreateAccountView = makeControllerView(CreateController, ({ state: { form } }) => { + return ( +
+ + + + +
+ ); +}); diff --git a/deno.lock b/deno.lock index 45a7c2e..d6730d8 100644 --- a/deno.lock +++ b/deno.lock @@ -14,11 +14,13 @@ "npm:@jsr/valkyr__event-store@2.0.0-beta.6": "2.0.0-beta.6", "npm:@jsr/valkyr__inverse@1": "1.0.1", "npm:@tanstack/react-query@5": "5.84.2_react@19.1.1", + "npm:@tanstack/react-router-devtools@1": "1.131.7_@tanstack+react-router@1.131.5__react@19.1.1__react-dom@19.1.1___react@19.1.1_react@19.1.1_react-dom@19.1.1__react@19.1.1", "npm:@tanstack/react-router@1": "1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1", + "npm:@types/node@*": "22.15.15", "npm:@types/react-dom@19": "19.1.7_@types+react@19.1.9", "npm:@types/react@19": "19.1.9", "npm:@valkyr/db@1": "1.0.1", - "npm:@vitejs/plugin-react@4": "4.7.0_vite@7.1.2__picomatch@4.0.3_@babel+core@7.28.0", + "npm:@vitejs/plugin-react@4": "4.7.0_vite@7.1.2__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15", "npm:cookie@1": "1.0.2", "npm:eslint-plugin-react-hooks@5": "5.2.0_eslint@9.33.0", "npm:eslint-plugin-react-refresh@0.4": "0.4.20_eslint@9.33.0", @@ -33,7 +35,8 @@ "npm:react@19": "19.1.1", "npm:typescript-eslint@8": "8.39.1_eslint@9.33.0_typescript@5.9.2_@typescript-eslint+parser@8.39.1__eslint@9.33.0__typescript@5.9.2", "npm:typescript@5": "5.9.2", - "npm:vite@7": "7.1.2_picomatch@4.0.3", + "npm:vite@7": "7.1.2_picomatch@4.0.3_@types+node@22.15.15", + "npm:vite@7.1.2": "7.1.2_picomatch@4.0.3_@types+node@22.15.15", "npm:zod@4": "4.0.17" }, "npm": { @@ -670,12 +673,21 @@ "react" ] }, + "@tanstack/react-router-devtools@1.131.7_@tanstack+react-router@1.131.5__react@19.1.1__react-dom@19.1.1___react@19.1.1_react@19.1.1_react-dom@19.1.1__react@19.1.1": { + "integrity": "sha512-RLxjwsD8A9iavGtMA1RhQ+j/gfAdQcEf9pygGk9RZuWV7XJ4RXZeeKQHDKyJ/Rry5NkYbO+eJzeToq/szuQbuw==", + "dependencies": [ + "@tanstack/react-router", + "@tanstack/router-devtools-core", + "react", + "react-dom" + ] + }, "@tanstack/react-router@1.131.5_react@19.1.1_react-dom@19.1.1__react@19.1.1": { "integrity": "sha512-71suJGuCmrHN9PLLRUDB3CGnW5RNcEEfgfX616TOpKamHs977H8P4/75BgWPRWcLHCga/1kkA6c7bddCwZ35Fw==", "dependencies": [ "@tanstack/history", "@tanstack/react-store", - "@tanstack/router-core", + "@tanstack/router-core@1.131.5_seroval@1.3.2", "isbot", "react", "react-dom", @@ -704,6 +716,28 @@ "tiny-warning" ] }, + "@tanstack/router-core@1.131.7_seroval@1.3.2": { + "integrity": "sha512-NpFfAG1muv4abrCij6sEtRrVzlU+xYpY30NAgquHNhMMMNIiN7djzsaGV+vCJdR4u5mi13+f0c3f+f9MdekY5A==", + "dependencies": [ + "@tanstack/history", + "@tanstack/store", + "cookie-es", + "seroval", + "seroval-plugins", + "tiny-invariant", + "tiny-warning" + ] + }, + "@tanstack/router-devtools-core@1.131.7_@tanstack+router-core@1.131.7__seroval@1.3.2_solid-js@1.9.9__seroval@1.3.2_tiny-invariant@1.3.3": { + "integrity": "sha512-1GHWILJr69Ej/c8UUMhT7Srx392FbsDqRrPhCWWtrjmYOv6Fdx3HdKDJt/YdJGBc8z6x+V7EE41j+LZggD+70Q==", + "dependencies": [ + "@tanstack/router-core@1.131.7_seroval@1.3.2", + "clsx", + "goober", + "solid-js", + "tiny-invariant" + ] + }, "@tanstack/store@0.7.2": { "integrity": "sha512-RP80Z30BYiPX2Pyo0Nyw4s1SJFH2jyM6f9i3HfX4pA+gm5jsnYryscdq2aIQLnL4TaGuQMO+zXmN9nh1Qck+Pg==" }, @@ -742,6 +776,12 @@ "@types/json-schema@7.0.15": { "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, + "@types/node@22.15.15": { + "integrity": "sha512-R5muMcZob3/Jjchn5LcO8jdKwSCbzqmPB6ruBxMcf9kbxtniZHP327s6C37iOfuw8mbKK3cAQa7sEl7afLrQ8A==", + "dependencies": [ + "undici-types" + ] + }, "@types/react-dom@19.1.7_@types+react@19.1.9": { "integrity": "sha512-i5ZzwYpqjmrKenzkoLM2Ibzt6mAsM7pxB6BCIouEVVmgiqaMj1TjaK7hnA36hbW5aZv20kx7Lw6hWzPWg0Rurw==", "dependencies": [ @@ -884,7 +924,19 @@ "@rolldown/pluginutils", "@types/babel__core", "react-refresh", - "vite" + "vite@7.1.2_picomatch@4.0.3" + ] + }, + "@vitejs/plugin-react@4.7.0_vite@7.1.2__picomatch@4.0.3_@babel+core@7.28.0_@types+node@22.15.15": { + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dependencies": [ + "@babel/core", + "@babel/plugin-transform-react-jsx-self", + "@babel/plugin-transform-react-jsx-source", + "@rolldown/pluginutils", + "@types/babel__core", + "react-refresh", + "vite@7.1.2_picomatch@4.0.3_@types+node@22.15.15" ] }, "acorn-jsx@5.3.2_acorn@8.15.0": { @@ -963,6 +1015,9 @@ "supports-color" ] }, + "clsx@2.1.1": { + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==" + }, "color-convert@2.0.1": { "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dependencies": [ @@ -1248,6 +1303,12 @@ "globals@16.3.0": { "integrity": "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ==" }, + "goober@2.1.16_csstype@3.1.3": { + "integrity": "sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==", + "dependencies": [ + "csstype" + ] + }, "graphemer@1.4.0": { "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==" }, @@ -1573,6 +1634,14 @@ "shebang-regex@3.0.0": { "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==" }, + "solid-js@1.9.9_seroval@1.3.2": { + "integrity": "sha512-A0ZBPJQldAeGCTW0YRYJmt7RCeh5rbFfPZ2aOttgYnctHE7HgKeHCBB/PVc2P7eOfmNXqMFFFoYYdm3S4dcbkA==", + "dependencies": [ + "csstype", + "seroval", + "seroval-plugins" + ] + }, "source-map-js@1.2.1": { "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==" }, @@ -1649,6 +1718,9 @@ "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", "bin": true }, + "undici-types@6.21.0": { + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==" + }, "update-browserslist-db@1.1.3_browserslist@4.25.2": { "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", "dependencies": [ @@ -1685,6 +1757,25 @@ ], "bin": true }, + "vite@7.1.2_picomatch@4.0.3_@types+node@22.15.15": { + "integrity": "sha512-J0SQBPlQiEXAF7tajiH+rUooJPo0l8KQgyg4/aMunNtrOa7bwuZJsJbDWzeljqQpgftxuq5yNJxQ91O9ts29UQ==", + "dependencies": [ + "@types/node", + "esbuild", + "fdir", + "picomatch@4.0.3", + "postcss", + "rollup", + "tinyglobby" + ], + "optionalDependencies": [ + "fsevents" + ], + "optionalPeers": [ + "@types/node" + ], + "bin": true + }, "webidl-conversions@7.0.0": { "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==" }, @@ -1753,6 +1844,7 @@ "npm:@eslint/js@9", "npm:@jsr/valkyr__event-emitter@1", "npm:@tanstack/react-query@5", + "npm:@tanstack/react-router-devtools@1", "npm:@tanstack/react-router@1", "npm:@types/react-dom@19", "npm:@types/react@19", @@ -1767,7 +1859,8 @@ "npm:react@19", "npm:typescript-eslint@8", "npm:typescript@5", - "npm:vite@7" + "npm:vite@7", + "npm:zod@4" ] } }, diff --git a/spec/schemas/account/errors.ts b/spec/schemas/account/errors.ts index 445f722..96419fe 100644 --- a/spec/schemas/account/errors.ts +++ b/spec/schemas/account/errors.ts @@ -1,4 +1,4 @@ -import { ConflictError } from "@spec/relay/mod.ts"; +import { ConflictError } from "@spec/relay"; export class AccountEmailClaimedError extends ConflictError { constructor(email: string) {