Template
1
0

feat: add initial account creation form

This commit is contained in:
2025-08-13 00:30:02 +02:00
parent 82d7a0d9cd
commit 0b0ecbcb79
12 changed files with 449 additions and 167 deletions

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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 (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>count is {count}</button>
<p>
Edit <code>src/App.tsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">Click on the Vite and React logos to learn more</p>
<Session />
</>
);
}
export default App;

View File

@@ -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;
}
}

View File

@@ -0,0 +1,269 @@
import z, { type ZodObject, type ZodRawShape } from "zod";
export class Form<TSchema extends ZodRawShape, TInputs = z.infer<ZodObject<TSchema>>> {
readonly schema: ZodObject<TSchema>;
readonly inputs: Partial<TInputs> = {};
#debounce: FormDebounce<TInputs> = {
validate: {},
};
#defaults: Partial<TInputs>;
#errors: FormErrors<TInputs> = {};
#elements: Record<string, HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement> = {};
#onChange?: OnChangeCallback<TInputs>;
#onProcessing?: OnProcessingCallback;
#onError?: OnErrorCallback<TInputs>;
#onSubmit?: OnSubmitCallback<TInputs>;
#onResponse?: OnResponseCallback<any, any>;
/*
|--------------------------------------------------------------------------------
| Constructor
|--------------------------------------------------------------------------------
*/
constructor(schema: TSchema, defaults: Partial<TInputs> = {}) {
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<TInputs> {
return this.#errors;
}
set errors(value: FormErrors<TInputs>) {
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<TKey extends keyof TInputs>(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<TInputs>): this {
this.#onChange = callback;
return this;
}
onProcessing(callback: OnProcessingCallback): this {
this.#onProcessing = callback;
return this;
}
onError(callback: OnErrorCallback<TInputs>): this {
this.#onError = callback;
return this;
}
onSubmit(callback: OnSubmitCallback<TInputs>): this {
this.#onSubmit = callback;
return this;
}
onResponse<E, R>(callback: OnResponseCallback<E, R>): 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<TKey extends keyof TInputs>(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<TInputs>;
get<TKey extends keyof TInputs>(name: TKey): TInputs[TKey] | undefined;
get<TKey extends keyof TInputs>(name?: TKey): Partial<TInputs> | 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<TInputs> {
const result = this.schema.safeParse(this.inputs);
if (result.success === false) {
throw result.error.flatten;
// return result.error.details.reduce<Partial<TInputs>>(
// (error, next) => ({
// ...error,
// [next.path[0]]: next.message,
// }),
// {},
// );
}
return {};
}
}
/*
|--------------------------------------------------------------------------------
| Types
|--------------------------------------------------------------------------------
*/
type OnChangeCallback<TInputs, TKey extends keyof TInputs = keyof TInputs> = (name: TKey, value: TInputs[TKey]) => void;
type OnProcessingCallback = (value: boolean) => void;
type OnErrorCallback<TInputs> = (errors: FormErrors<TInputs>) => void;
type OnSubmitCallback<TInputs> = (inputs: TInputs) => Promise<any>;
type OnResponseCallback<Error, Response> = (err: Error, res: Response) => void;
type FormDebounce<TInputs> = {
validate: {
[TKey in keyof TInputs]?: any;
};
};
type FormErrors<TInputs> = {
[TKey in keyof TInputs]?: string;
};

View File

@@ -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(
<StrictMode>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
<RouterProvider router={router} />
</StrictMode>,
);

View File

@@ -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: () => (
<>
<Outlet />
<TanStackRouterDevtools />
</>
),
});
export const routeTree = rootRoute.addChildren([]);
const homeRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/",
component: function Index() {
return <h3>Welcome Home!</h3>;
},
});
const createAccountRoute = createRoute({
getParentRoute: () => rootRoute,
path: "/accounts",
component: CreateAccountView,
});
export const routeTree = rootRoute.addChildren([homeRoute, createAccountRoute]);

View File

@@ -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,
},
);

View File

@@ -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<typeof inputs>;
}> {
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);
}
}),
};
}
}

View File

@@ -0,0 +1,13 @@
import { makeControllerView } from "../../libraries/view.ts";
import { CreateController } from "./create.controller.ts";
export const CreateAccountView = makeControllerView(CreateController, ({ state: { form } }) => {
return (
<form onSubmit={form.submit}>
<input placeholder="Given Name" {...form.register("givenName")} />
<input placeholder="Family Name" {...form.register("familyName")} />
<input placeholder="Email" {...form.register("email")} />
<button type="submit">Create</button>
</form>
);
});

103
deno.lock generated
View File

@@ -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"
]
}
},

View File

@@ -1,4 +1,4 @@
import { ConflictError } from "@spec/relay/mod.ts";
import { ConflictError } from "@spec/relay";
export class AccountEmailClaimedError extends ConflictError {
constructor(email: string) {