+113
-4
package-lock.json
+113
-4
package-lock.json
···
25
25
"jszip": "^3.10.1",
26
26
"lucide-react": "^0.544.0",
27
27
"react": "^18.3.1",
28
-
"react-dom": "^18.3.1"
28
+
"react-dom": "^18.3.1",
29
+
"zod": "^4.2.1"
29
30
},
30
31
"devDependencies": {
31
32
"@types/jszip": "^3.4.0",
···
112
113
"zod": "^3.23.8"
113
114
}
114
115
},
116
+
"node_modules/@atproto-labs/did-resolver/node_modules/zod": {
117
+
"version": "3.25.76",
118
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
119
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
120
+
"license": "MIT",
121
+
"funding": {
122
+
"url": "https://github.com/sponsors/colinhacks"
123
+
}
124
+
},
115
125
"node_modules/@atproto-labs/fetch": {
116
126
"version": "0.2.3",
117
127
"resolved": "https://registry.npmjs.org/@atproto-labs/fetch/-/fetch-0.2.3.tgz",
···
162
172
"node": ">=18.7.0"
163
173
}
164
174
},
175
+
"node_modules/@atproto-labs/handle-resolver/node_modules/zod": {
176
+
"version": "3.25.76",
177
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
178
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
179
+
"license": "MIT",
180
+
"funding": {
181
+
"url": "https://github.com/sponsors/colinhacks"
182
+
}
183
+
},
165
184
"node_modules/@atproto-labs/identity-resolver": {
166
185
"version": "0.3.1",
167
186
"resolved": "https://registry.npmjs.org/@atproto-labs/identity-resolver/-/identity-resolver-0.3.1.tgz",
···
216
235
"zod": "^3.23.8"
217
236
}
218
237
},
238
+
"node_modules/@atproto/api/node_modules/zod": {
239
+
"version": "3.25.76",
240
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
241
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
242
+
"license": "MIT",
243
+
"funding": {
244
+
"url": "https://github.com/sponsors/colinhacks"
245
+
}
246
+
},
219
247
"node_modules/@atproto/common-web": {
220
248
"version": "0.4.3",
221
249
"resolved": "https://registry.npmjs.org/@atproto/common-web/-/common-web-0.4.3.tgz",
···
226
254
"multiformats": "^9.9.0",
227
255
"uint8arrays": "3.0.0",
228
256
"zod": "^3.23.8"
257
+
}
258
+
},
259
+
"node_modules/@atproto/common-web/node_modules/zod": {
260
+
"version": "3.25.76",
261
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
262
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
263
+
"license": "MIT",
264
+
"funding": {
265
+
"url": "https://github.com/sponsors/colinhacks"
229
266
}
230
267
},
231
268
"node_modules/@atproto/crypto": {
···
251
288
"zod": "^3.23.8"
252
289
}
253
290
},
291
+
"node_modules/@atproto/did/node_modules/zod": {
292
+
"version": "3.25.76",
293
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
294
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
295
+
"license": "MIT",
296
+
"funding": {
297
+
"url": "https://github.com/sponsors/colinhacks"
298
+
}
299
+
},
254
300
"node_modules/@atproto/identity": {
255
301
"version": "0.4.9",
256
302
"resolved": "https://registry.npmjs.org/@atproto/identity/-/identity-0.4.9.tgz",
···
304
350
"zod": "^3.23.8"
305
351
}
306
352
},
353
+
"node_modules/@atproto/jwk-webcrypto/node_modules/zod": {
354
+
"version": "3.25.76",
355
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
356
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
357
+
"license": "MIT",
358
+
"funding": {
359
+
"url": "https://github.com/sponsors/colinhacks"
360
+
}
361
+
},
362
+
"node_modules/@atproto/jwk/node_modules/zod": {
363
+
"version": "3.25.76",
364
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
365
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
366
+
"license": "MIT",
367
+
"funding": {
368
+
"url": "https://github.com/sponsors/colinhacks"
369
+
}
370
+
},
307
371
"node_modules/@atproto/lexicon": {
308
372
"version": "0.5.1",
309
373
"resolved": "https://registry.npmjs.org/@atproto/lexicon/-/lexicon-0.5.1.tgz",
···
315
379
"iso-datestring-validator": "^2.2.2",
316
380
"multiformats": "^9.9.0",
317
381
"zod": "^3.23.8"
382
+
}
383
+
},
384
+
"node_modules/@atproto/lexicon/node_modules/zod": {
385
+
"version": "3.25.76",
386
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
387
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
388
+
"license": "MIT",
389
+
"funding": {
390
+
"url": "https://github.com/sponsors/colinhacks"
318
391
}
319
392
},
320
393
"node_modules/@atproto/oauth-client": {
···
357
430
"node": ">=18.7.0"
358
431
}
359
432
},
433
+
"node_modules/@atproto/oauth-client/node_modules/zod": {
434
+
"version": "3.25.76",
435
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
436
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
437
+
"license": "MIT",
438
+
"funding": {
439
+
"url": "https://github.com/sponsors/colinhacks"
440
+
}
441
+
},
360
442
"node_modules/@atproto/oauth-types": {
361
443
"version": "0.4.1",
362
444
"resolved": "https://registry.npmjs.org/@atproto/oauth-types/-/oauth-types-0.4.1.tgz",
···
367
449
"zod": "^3.23.8"
368
450
}
369
451
},
452
+
"node_modules/@atproto/oauth-types/node_modules/zod": {
453
+
"version": "3.25.76",
454
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
455
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
456
+
"license": "MIT",
457
+
"funding": {
458
+
"url": "https://github.com/sponsors/colinhacks"
459
+
}
460
+
},
370
461
"node_modules/@atproto/syntax": {
371
462
"version": "0.4.1",
372
463
"resolved": "https://registry.npmjs.org/@atproto/syntax/-/syntax-0.4.1.tgz",
···
381
472
"dependencies": {
382
473
"@atproto/lexicon": "^0.5.1",
383
474
"zod": "^3.23.8"
475
+
}
476
+
},
477
+
"node_modules/@atproto/xrpc/node_modules/zod": {
478
+
"version": "3.25.76",
479
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
480
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
481
+
"license": "MIT",
482
+
"funding": {
483
+
"url": "https://github.com/sponsors/colinhacks"
384
484
}
385
485
},
386
486
"node_modules/@babel/code-frame": {
···
1996
2096
},
1997
2097
"engines": {
1998
2098
"node": ">=10"
2099
+
}
2100
+
},
2101
+
"node_modules/@netlify/zip-it-and-ship-it/node_modules/zod": {
2102
+
"version": "3.25.76",
2103
+
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
2104
+
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
2105
+
"license": "MIT",
2106
+
"funding": {
2107
+
"url": "https://github.com/sponsors/colinhacks"
1999
2108
}
2000
2109
},
2001
2110
"node_modules/@noble/curves": {
···
8164
8273
}
8165
8274
},
8166
8275
"node_modules/zod": {
8167
-
"version": "3.25.76",
8168
-
"resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
8169
-
"integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
8276
+
"version": "4.2.1",
8277
+
"resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz",
8278
+
"integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==",
8170
8279
"license": "MIT",
8171
8280
"funding": {
8172
8281
"url": "https://github.com/sponsors/colinhacks"
+2
-1
package.json
+2
-1
package.json
+65
-91
src/lib/validation.ts
+65
-91
src/lib/validation.ts
···
1
1
/**
2
-
* Validation utilities for forms
2
+
* Validation utilities using Zod schemas
3
3
*/
4
+
import { z } from "zod";
4
5
5
6
export interface ValidationResult {
6
7
isValid: boolean;
···
8
9
}
9
10
10
11
/**
11
-
* Validate AT Protocol handle
12
+
* Helper to convert Zod validation to ValidationResult
12
13
*/
13
-
export function validateHandle(handle: string): ValidationResult {
14
-
const trimmed = handle.trim();
15
-
16
-
if (!trimmed) {
17
-
return {
18
-
isValid: false,
19
-
error: "Please enter your handle",
20
-
};
21
-
}
22
-
23
-
// Remove @ if user included it
24
-
const cleanHandle = trimmed.startsWith("@") ? trimmed.slice(1) : trimmed;
25
-
26
-
// Basic format validation
27
-
if (cleanHandle.length < 3) {
28
-
return {
29
-
isValid: false,
30
-
error: "Handle is too short",
31
-
};
32
-
}
33
-
34
-
// Check for valid characters (alphanumeric, dots, hyphens)
35
-
const validFormat = /^[a-zA-Z0-9.-]+$/;
36
-
if (!validFormat.test(cleanHandle)) {
37
-
return {
38
-
isValid: false,
39
-
error: "Handle contains invalid characters",
40
-
};
14
+
function validateWithZod<T>(
15
+
schema: z.ZodSchema<T>,
16
+
value: unknown,
17
+
): ValidationResult {
18
+
const result = schema.safeParse(value);
19
+
if (result.success) {
20
+
return { isValid: true };
41
21
}
22
+
return {
23
+
isValid: false,
24
+
error: result.error.errors[0]?.message || "Validation failed",
25
+
};
26
+
}
42
27
43
-
// Must contain at least one dot (domain required)
44
-
if (!cleanHandle.includes(".")) {
45
-
return {
46
-
isValid: false,
47
-
error: "Handle must include a domain (e.g., username.bsky.social)",
48
-
};
49
-
}
28
+
/**
29
+
* Zod Schemas
30
+
*/
31
+
const handleSchema = z
32
+
.string()
33
+
.trim()
34
+
.min(1, "Please enter your handle")
35
+
.transform((val) => (val.startsWith("@") ? val.slice(1) : val))
36
+
.pipe(
37
+
z
38
+
.string()
39
+
.min(3, "Handle is too short")
40
+
.regex(/^[a-zA-Z0-9.-]+$/, "Handle contains invalid characters")
41
+
.refine((val) => val.includes("."), {
42
+
message: "Handle must include a domain (e.g., username.bsky.social)",
43
+
})
44
+
.refine((val) => !/^[.-]|[.-]$/.test(val), {
45
+
message: "Handle cannot start or end with . or -",
46
+
}),
47
+
);
50
48
51
-
// Can't start or end with dot or hyphen
52
-
if (/^[.-]|[.-]$/.test(cleanHandle)) {
53
-
return {
54
-
isValid: false,
55
-
error: "Handle cannot start or end with . or -",
56
-
};
57
-
}
49
+
const emailSchema = z
50
+
.string()
51
+
.trim()
52
+
.min(1, "Please enter your email")
53
+
.email("Please enter a valid email address");
58
54
59
-
return { isValid: true };
55
+
/**
56
+
* Validate AT Protocol handle
57
+
*/
58
+
export function validateHandle(handle: string): ValidationResult {
59
+
return validateWithZod(handleSchema, handle);
60
60
}
61
61
62
62
/**
63
63
* Validate email format
64
64
*/
65
65
export function validateEmail(email: string): ValidationResult {
66
-
const trimmed = email.trim();
67
-
68
-
if (!trimmed) {
69
-
return {
70
-
isValid: false,
71
-
error: "Please enter your email",
72
-
};
73
-
}
74
-
75
-
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
76
-
if (!emailRegex.test(trimmed)) {
77
-
return {
78
-
isValid: false,
79
-
error: "Please enter a valid email address",
80
-
};
81
-
}
82
-
83
-
return { isValid: true };
66
+
return validateWithZod(emailSchema, email);
84
67
}
85
68
86
69
/**
···
90
73
value: string,
91
74
fieldName: string = "This field",
92
75
): ValidationResult {
93
-
const trimmed = value.trim();
94
-
95
-
if (!trimmed) {
96
-
return {
97
-
isValid: false,
98
-
error: `${fieldName} is required`,
99
-
};
100
-
}
101
-
102
-
return { isValid: true };
76
+
const schema = z.string().trim().min(1, `${fieldName} is required`);
77
+
return validateWithZod(schema, value);
103
78
}
104
79
105
80
/**
···
110
85
minLength: number,
111
86
fieldName: string = "This field",
112
87
): ValidationResult {
113
-
const trimmed = value.trim();
114
-
115
-
if (trimmed.length < minLength) {
116
-
return {
117
-
isValid: false,
118
-
error: `${fieldName} must be at least ${minLength} characters`,
119
-
};
120
-
}
121
-
122
-
return { isValid: true };
88
+
const schema = z
89
+
.string()
90
+
.trim()
91
+
.min(minLength, `${fieldName} must be at least ${minLength} characters`);
92
+
return validateWithZod(schema, value);
123
93
}
124
94
125
95
/**
···
130
100
maxLength: number,
131
101
fieldName: string = "This field",
132
102
): ValidationResult {
133
-
if (value.length > maxLength) {
134
-
return {
135
-
isValid: false,
136
-
error: `${fieldName} must be ${maxLength} characters or less`,
137
-
};
138
-
}
103
+
const schema = z
104
+
.string()
105
+
.max(maxLength, `${fieldName} must be ${maxLength} characters or less`);
106
+
return validateWithZod(schema, value);
107
+
}
139
108
140
-
return { isValid: true };
141
-
}
109
+
/**
110
+
* Export schemas for advanced usage
111
+
*/
112
+
export const schemas = {
113
+
handle: handleSchema,
114
+
email: emailSchema,
115
+
};