dink-pets-shared 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,343 @@
1
+ // =============================================================================
2
+ // SHARED THEME CONFIGURATION
3
+ // =============================================================================
4
+ // Single source of truth for colors across mobile and web.
5
+ //
6
+ // How it works:
7
+ // 1. This file defines all colors as hex values
8
+ // 2. Build script generates mobile/global.css with CSS variables
9
+ // 3. Web layout.tsx generates CSS variables dynamically
10
+ // 4. Components use `tw` tokens - never raw color names
11
+ // =============================================================================
12
+ import { activityTypes } from "./enums.js";
13
+ // =============================================================================
14
+ // TAILWIND COLOR PALETTE
15
+ // =============================================================================
16
+ // Subset of Tailwind's default colors that we use.
17
+ // https://tailwindcss.com/docs/customizing-colors
18
+ export const palette = {
19
+ white: "#ffffff",
20
+ black: "#000000",
21
+ gray: {
22
+ 100: "#f3f4f6",
23
+ 200: "#e5e7eb",
24
+ 400: "#9ca3af",
25
+ 500: "#6b7280",
26
+ 600: "#4b5563",
27
+ 700: "#374151",
28
+ 800: "#1f2937",
29
+ 900: "#111827",
30
+ },
31
+ purple: {
32
+ 100: "#f3e8ff",
33
+ 300: "#d8b4fe",
34
+ 400: "#a78bfa",
35
+ 500: "#8b5cf6",
36
+ 600: "#7c3aed",
37
+ 900: "#4c1d95",
38
+ },
39
+ pink: {
40
+ 300: "#f9a8d4",
41
+ 500: "#ec4899",
42
+ 900: "#831843",
43
+ },
44
+ orange: {
45
+ 300: "#fdba74",
46
+ 500: "#f97316",
47
+ },
48
+ green: {
49
+ 500: "#22c55e",
50
+ },
51
+ red: {
52
+ 500: "#ef4444",
53
+ },
54
+ yellow: {
55
+ 400: "#facc15",
56
+ 500: "#eab308",
57
+ },
58
+ blue: {
59
+ 500: "#3b82f6",
60
+ },
61
+ amber: {
62
+ 500: "#f59e0b",
63
+ },
64
+ cyan: {
65
+ 500: "#06b6d4",
66
+ },
67
+ teal: {
68
+ 500: "#14b8a6",
69
+ },
70
+ indigo: {
71
+ 500: "#6366f1",
72
+ },
73
+ };
74
+ // =============================================================================
75
+ // SEMANTIC COLORS
76
+ // =============================================================================
77
+ // Map UI concepts to palette colors for light and dark modes.
78
+ export const semantic = {
79
+ light: {
80
+ // Backgrounds
81
+ background: palette.white,
82
+ backgroundSubtle: palette.gray[100],
83
+ card: palette.white,
84
+ // Gradients
85
+ gradientFrom: palette.purple[400],
86
+ gradientVia: palette.pink[300],
87
+ gradientTo: palette.orange[300],
88
+ // Text
89
+ textPrimary: palette.gray[800],
90
+ textSecondary: palette.gray[500],
91
+ textMuted: palette.gray[400],
92
+ textInverse: palette.white,
93
+ textOnGradient: palette.white,
94
+ // Accents
95
+ accentPrimary: palette.purple[600],
96
+ accentSecondary: palette.pink[500],
97
+ // Semantic states
98
+ success: palette.green[500],
99
+ destructive: palette.red[500],
100
+ destructiveSubtle: "#fef2f2", // red-50
101
+ // Borders
102
+ border: palette.gray[200],
103
+ borderStrong: palette.gray[400],
104
+ // Interactive
105
+ buttonPrimary: palette.purple[600],
106
+ buttonPrimaryText: palette.white,
107
+ buttonSecondary: palette.white,
108
+ buttonSecondaryText: palette.purple[600],
109
+ // Pressed/active states (10% opacity overlays)
110
+ pressedOverlay: "rgba(0,0,0,0.1)",
111
+ // Avatar fallback gradient (subtle purple to pink)
112
+ avatarGradientFrom: "rgba(167, 139, 250, 0.25)", // purple-400/25
113
+ avatarGradientTo: "rgba(249, 168, 212, 0.25)", // pink-300/25
114
+ },
115
+ dark: {
116
+ // Backgrounds
117
+ background: palette.gray[900],
118
+ backgroundSubtle: palette.gray[800],
119
+ card: "#252033", // Purple-tinted dark that complements the gradient
120
+ // Gradients
121
+ gradientFrom: palette.gray[900],
122
+ gradientVia: palette.purple[900],
123
+ gradientTo: palette.pink[900],
124
+ // Text
125
+ textPrimary: palette.gray[200],
126
+ textSecondary: palette.gray[400],
127
+ textMuted: palette.gray[500],
128
+ textInverse: palette.gray[900],
129
+ textOnGradient: palette.gray[200],
130
+ // Accents
131
+ accentPrimary: palette.purple[400],
132
+ accentSecondary: palette.pink[500],
133
+ // Semantic states
134
+ success: palette.green[500],
135
+ destructive: palette.red[500],
136
+ destructiveSubtle: "#450a0a", // red-950
137
+ // Borders
138
+ border: palette.gray[600],
139
+ borderStrong: palette.gray[400],
140
+ // Interactive
141
+ buttonPrimary: palette.purple[500],
142
+ buttonPrimaryText: palette.white,
143
+ buttonSecondary: palette.gray[800],
144
+ buttonSecondaryText: palette.purple[400],
145
+ // Pressed/active states (10% opacity overlays)
146
+ pressedOverlay: "rgba(255,255,255,0.1)",
147
+ // Avatar fallback gradient (subtle purple to pink)
148
+ avatarGradientFrom: "rgba(139, 92, 246, 0.15)", // purple-500/15
149
+ avatarGradientTo: "rgba(236, 72, 153, 0.15)", // pink-500/15
150
+ },
151
+ };
152
+ // =============================================================================
153
+ // ACTIVITY TYPE COLORS
154
+ // =============================================================================
155
+ // Colors for each ActivityType enum value. These don't change between light/dark.
156
+ export const activityTypeColors = {
157
+ WALK: palette.green[500],
158
+ MEAL: palette.orange[500],
159
+ BATHROOM: palette.amber[500],
160
+ TREAT: palette.yellow[400],
161
+ PLAYTIME: palette.cyan[500],
162
+ MEDICATION: palette.indigo[500],
163
+ VET_APPOINTMENT: palette.red[500],
164
+ GROOMING: palette.pink[500],
165
+ BATH: palette.teal[500],
166
+ NAIL_TRIM: palette.purple[300],
167
+ };
168
+ // =============================================================================
169
+ // APP SECTION COLORS
170
+ // =============================================================================
171
+ // Colors for app UI sections (not activity types).
172
+ export const appSections = ["achievements", "photos", "schedule"];
173
+ export const appSectionColors = {
174
+ achievements: palette.yellow[500],
175
+ photos: palette.purple[500],
176
+ schedule: palette.blue[500],
177
+ };
178
+ // =============================================================================
179
+ // CSS VARIABLES
180
+ // =============================================================================
181
+ // Used to generate CSS for both platforms.
182
+ // Keys become CSS variable names (e.g., --color-card).
183
+ export const cssVariables = {
184
+ light: {
185
+ "--color-background": semantic.light.background,
186
+ "--color-background-subtle": semantic.light.backgroundSubtle,
187
+ "--color-card": semantic.light.card,
188
+ "--color-gradient-from": semantic.light.gradientFrom,
189
+ "--color-gradient-via": semantic.light.gradientVia,
190
+ "--color-gradient-to": semantic.light.gradientTo,
191
+ "--color-text-primary": semantic.light.textPrimary,
192
+ "--color-text-secondary": semantic.light.textSecondary,
193
+ "--color-text-muted": semantic.light.textMuted,
194
+ "--color-text-inverse": semantic.light.textInverse,
195
+ "--color-text-on-gradient": semantic.light.textOnGradient,
196
+ "--color-accent-primary": semantic.light.accentPrimary,
197
+ "--color-accent-secondary": semantic.light.accentSecondary,
198
+ "--color-destructive": semantic.light.destructive,
199
+ "--color-destructive-subtle": semantic.light.destructiveSubtle,
200
+ "--color-border": semantic.light.border,
201
+ "--color-border-strong": semantic.light.borderStrong,
202
+ "--color-button-primary": semantic.light.buttonPrimary,
203
+ "--color-button-primary-text": semantic.light.buttonPrimaryText,
204
+ "--color-button-secondary": semantic.light.buttonSecondary,
205
+ "--color-button-secondary-text": semantic.light.buttonSecondaryText,
206
+ },
207
+ dark: {
208
+ "--color-background": semantic.dark.background,
209
+ "--color-background-subtle": semantic.dark.backgroundSubtle,
210
+ "--color-card": semantic.dark.card,
211
+ "--color-gradient-from": semantic.dark.gradientFrom,
212
+ "--color-gradient-via": semantic.dark.gradientVia,
213
+ "--color-gradient-to": semantic.dark.gradientTo,
214
+ "--color-text-primary": semantic.dark.textPrimary,
215
+ "--color-text-secondary": semantic.dark.textSecondary,
216
+ "--color-text-muted": semantic.dark.textMuted,
217
+ "--color-text-inverse": semantic.dark.textInverse,
218
+ "--color-text-on-gradient": semantic.dark.textOnGradient,
219
+ "--color-accent-primary": semantic.dark.accentPrimary,
220
+ "--color-accent-secondary": semantic.dark.accentSecondary,
221
+ "--color-destructive": semantic.dark.destructive,
222
+ "--color-destructive-subtle": semantic.dark.destructiveSubtle,
223
+ "--color-border": semantic.dark.border,
224
+ "--color-border-strong": semantic.dark.borderStrong,
225
+ "--color-button-primary": semantic.dark.buttonPrimary,
226
+ "--color-button-primary-text": semantic.dark.buttonPrimaryText,
227
+ "--color-button-secondary": semantic.dark.buttonSecondary,
228
+ "--color-button-secondary-text": semantic.dark.buttonSecondaryText,
229
+ },
230
+ };
231
+ // =============================================================================
232
+ // TAILWIND EXTEND COLORS
233
+ // =============================================================================
234
+ // For Tailwind config - maps class names to CSS variables.
235
+ /**
236
+ * Build a Record with prefixed keys from a readonly array.
237
+ * TypeScript can't infer template literal key types from Object.fromEntries,
238
+ * so this utility provides proper generic constraints.
239
+ */
240
+ function buildPrefixedRecord(prefix, keys, valueFn) {
241
+ const result = {};
242
+ for (const key of keys) {
243
+ result[`${prefix}${key}`] = valueFn(key);
244
+ }
245
+ return result;
246
+ }
247
+ // Generate activity type color entries: { "activity-WALK": "#hex", ... }
248
+ const activityColorEntries = buildPrefixedRecord("activity-", activityTypes, (type) => activityTypeColors[type]);
249
+ // Generate app section color entries: { "section-achievements": "#hex", ... }
250
+ const sectionColorEntries = buildPrefixedRecord("section-", appSections, (section) => appSectionColors[section]);
251
+ export const extendColors = {
252
+ // Semantic colors (use CSS variables for dark mode)
253
+ background: "var(--color-background)",
254
+ "background-subtle": "var(--color-background-subtle)",
255
+ card: "var(--color-card)",
256
+ "text-primary": "var(--color-text-primary)",
257
+ "text-secondary": "var(--color-text-secondary)",
258
+ "text-muted": "var(--color-text-muted)",
259
+ "text-inverse": "var(--color-text-inverse)",
260
+ "text-on-gradient": "var(--color-text-on-gradient)",
261
+ "accent-primary": "var(--color-accent-primary)",
262
+ "accent-secondary": "var(--color-accent-secondary)",
263
+ destructive: "var(--color-destructive)",
264
+ "destructive-subtle": "var(--color-destructive-subtle)",
265
+ border: "var(--color-border)",
266
+ "border-strong": "var(--color-border-strong)",
267
+ "button-primary": "var(--color-button-primary)",
268
+ "button-primary-text": "var(--color-button-primary-text)",
269
+ "button-secondary": "var(--color-button-secondary)",
270
+ "button-secondary-text": "var(--color-button-secondary-text)",
271
+ // Activity type colors (static, same in light/dark)
272
+ ...activityColorEntries,
273
+ // App section colors
274
+ ...sectionColorEntries,
275
+ };
276
+ // =============================================================================
277
+ // TW - COMPONENT CLASS TOKENS
278
+ // =============================================================================
279
+ // Import these in components instead of writing raw Tailwind classes.
280
+ // This ensures consistency and makes refactoring easy.
281
+ //
282
+ // Usage:
283
+ // import { tw } from "dink-pets-shared";
284
+ // <View className={tw.card}>
285
+ // <Text className={tw.textPrimary}>
286
+ // =============================================================================
287
+ // Helper: Convert SCREAMING_SNAKE to PascalCase (e.g., VET_APPOINTMENT -> VetAppointment)
288
+ const toPascalCase = (str) => str.toLowerCase().replace(/(^|_)(\w)/g, (_, __, c) => c.toUpperCase());
289
+ // Generate tw tokens for activity types: { activityWalk: "bg-...", textActivityWalk: "text-..." }
290
+ const twActivityColors = Object.fromEntries(activityTypes.flatMap((type) => {
291
+ const pascal = toPascalCase(type);
292
+ return [
293
+ [`activity${pascal}`, `bg-activity-${type}`],
294
+ [`textActivity${pascal}`, `text-activity-${type}`],
295
+ ];
296
+ }));
297
+ // Generate tw tokens for app sections: { sectionAchievements: "bg-...", textSectionAchievements: "text-..." }
298
+ const twSectionColors = Object.fromEntries(appSections.flatMap((section) => {
299
+ const pascal = section.charAt(0).toUpperCase() + section.slice(1);
300
+ return [
301
+ [`section${pascal}`, `bg-section-${section}`],
302
+ [`textSection${pascal}`, `text-section-${section}`],
303
+ ];
304
+ }));
305
+ export const tw = {
306
+ // Backgrounds
307
+ background: "bg-background",
308
+ backgroundSubtle: "bg-background-subtle",
309
+ card: "bg-card rounded-xl",
310
+ cardPadded: "bg-card rounded-xl p-4",
311
+ // Text
312
+ textPrimary: "text-text-primary",
313
+ textSecondary: "text-text-secondary",
314
+ textMuted: "text-text-muted",
315
+ textInverse: "text-text-inverse",
316
+ textOnGradient: "text-text-on-gradient",
317
+ // Headings
318
+ heading: "text-text-primary text-2xl font-bold",
319
+ subheading: "text-text-primary text-xl font-semibold",
320
+ title: "text-text-primary text-3xl font-bold",
321
+ // Accents
322
+ accentPrimary: "text-accent-primary",
323
+ accentSecondary: "text-accent-secondary",
324
+ bgAccentPrimary: "bg-accent-primary",
325
+ bgAccentSecondary: "bg-accent-secondary",
326
+ // Buttons
327
+ buttonPrimary: "bg-button-primary text-button-primary-text px-6 py-3 rounded-full font-semibold",
328
+ buttonSecondary: "bg-button-secondary text-button-secondary-text px-6 py-3 rounded-full font-semibold border border-accent-primary",
329
+ // Gradient background (for main screens)
330
+ gradientBg: "bg-gradient-to-br from-[var(--color-gradient-from)] via-[var(--color-gradient-via)] to-[var(--color-gradient-to)]",
331
+ // Activity type background and text colors (generated from activityTypes)
332
+ ...twActivityColors,
333
+ // App section background and text colors (generated from appSections)
334
+ ...twSectionColors,
335
+ // Layout utilities
336
+ screenCenter: "flex-1 items-center justify-center",
337
+ container: "flex-1 p-6",
338
+ // Web-specific layout
339
+ pageCenter: "flex min-h-screen flex-col items-center justify-center",
340
+ pageContent: "flex min-h-screen flex-col items-center justify-center gap-8 p-8",
341
+ // Store download buttons
342
+ storeButton: "rounded-lg bg-accent-primary px-6 py-3 text-text-inverse hover:opacity-90",
343
+ };
@@ -0,0 +1,53 @@
1
+ import { z } from "zod";
2
+ import { PetId, UserId } from "./ids.js";
3
+ export declare const UpdatePayloadSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
4
+ type: z.ZodLiteral<"PET_ACCESS_GRANTED">;
5
+ data: z.ZodObject<{
6
+ petId: z.ZodPipe<z.ZodString, z.ZodCustom<PetId, PetId>>;
7
+ petName: z.ZodString;
8
+ addedById: z.ZodPipe<z.ZodString, z.ZodCustom<UserId, UserId>>;
9
+ role: z.ZodEnum<{
10
+ OWNER: "OWNER";
11
+ COOWNER: "COOWNER";
12
+ CARETAKER: "CARETAKER";
13
+ }>;
14
+ }, z.core.$strip>;
15
+ }, z.core.$strict>, z.ZodObject<{
16
+ type: z.ZodLiteral<"ACHIEVEMENT_UNLOCKED">;
17
+ data: z.ZodObject<{
18
+ petId: z.ZodPipe<z.ZodString, z.ZodCustom<PetId, PetId>>;
19
+ petName: z.ZodString;
20
+ achievementType: z.ZodEnum<{
21
+ FIRST_WALK: "FIRST_WALK";
22
+ FIRST_PHOTO: "FIRST_PHOTO";
23
+ DAYS_TOGETHER_30: "DAYS_TOGETHER_30";
24
+ DAYS_TOGETHER_100: "DAYS_TOGETHER_100";
25
+ DAYS_TOGETHER_365: "DAYS_TOGETHER_365";
26
+ WALKS_STREAK_7: "WALKS_STREAK_7";
27
+ WALKS_STREAK_30: "WALKS_STREAK_30";
28
+ MEALS_ON_TIME_7: "MEALS_ON_TIME_7";
29
+ WALKS_100: "WALKS_100";
30
+ WALKS_500: "WALKS_500";
31
+ PHOTOS_50: "PHOTOS_50";
32
+ }>;
33
+ }, z.core.$strip>;
34
+ }, z.core.$strict>, z.ZodObject<{
35
+ type: z.ZodLiteral<"REFERRAL_ACCEPTED">;
36
+ data: z.ZodObject<{
37
+ referredUserId: z.ZodPipe<z.ZodString, z.ZodCustom<UserId, UserId>>;
38
+ referredUserName: z.ZodOptional<z.ZodString>;
39
+ }, z.core.$strip>;
40
+ }, z.core.$strict>], "type">;
41
+ export type UpdatePayload = z.infer<typeof UpdatePayloadSchema>;
42
+ /**
43
+ * Type-safe update payload builder.
44
+ *
45
+ * Provides compile-time safety (TypeScript enforces correct data shape per type)
46
+ * and runtime validation (Zod parses and validates).
47
+ *
48
+ * @example
49
+ * createUpdatePayload("PET_ACCESS_GRANTED", { petId, petName, addedById, role })
50
+ */
51
+ export declare function createUpdatePayload<T extends UpdatePayload["type"]>(type: T, data: Extract<UpdatePayload, {
52
+ type: T;
53
+ }>["data"]): UpdatePayload;
@@ -0,0 +1,58 @@
1
+ import { z } from "zod";
2
+ import { AchievementType, PetRole, UpdateType } from "./enums.js";
3
+ import { PetId, UserId } from "./ids.js";
4
+ import { Name } from "./schemas.js";
5
+ // Update data schemas - one per UpdateType enum value in Prisma.
6
+ // These define the shape of the JSON stored in Update.data column.
7
+ const PetAccessGrantedUpdate = z
8
+ .object({
9
+ type: z.literal(UpdateType.enum.PET_ACCESS_GRANTED),
10
+ data: z.object({
11
+ petId: PetId,
12
+ petName: Name,
13
+ addedById: UserId,
14
+ role: PetRole,
15
+ }),
16
+ })
17
+ .strict();
18
+ const AchievementUnlockedUpdate = z
19
+ .object({
20
+ type: z.literal(UpdateType.enum.ACHIEVEMENT_UNLOCKED),
21
+ data: z.object({
22
+ petId: PetId,
23
+ petName: Name,
24
+ achievementType: AchievementType,
25
+ }),
26
+ })
27
+ .strict();
28
+ const ReferralAcceptedUpdate = z
29
+ .object({
30
+ type: z.literal(UpdateType.enum.REFERRAL_ACCEPTED),
31
+ data: z.object({
32
+ referredUserId: UserId,
33
+ referredUserName: Name.optional(),
34
+ }),
35
+ })
36
+ .strict();
37
+ // =============================================================================
38
+ // UPDATE PAYLOAD SCHEMA
39
+ // =============================================================================
40
+ // Discriminated union of all update types. Ensures type field matches data shape.
41
+ // Used for validation on both server (creation) and client (consumption).
42
+ export const UpdatePayloadSchema = z.discriminatedUnion("type", [
43
+ PetAccessGrantedUpdate,
44
+ AchievementUnlockedUpdate,
45
+ ReferralAcceptedUpdate,
46
+ ]);
47
+ /**
48
+ * Type-safe update payload builder.
49
+ *
50
+ * Provides compile-time safety (TypeScript enforces correct data shape per type)
51
+ * and runtime validation (Zod parses and validates).
52
+ *
53
+ * @example
54
+ * createUpdatePayload("PET_ACCESS_GRANTED", { petId, petName, addedById, role })
55
+ */
56
+ export function createUpdatePayload(type, data) {
57
+ return UpdatePayloadSchema.parse({ type, data });
58
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * File upload validation constants and utilities.
3
+ *
4
+ * These values must stay in sync with Supabase Storage bucket limits.
5
+ * Current bucket config (media): 5MB max, image/* MIME types only.
6
+ */
7
+ /** Maximum file size in MB for display purposes. */
8
+ export declare const MAX_UPLOAD_SIZE_MB = 5;
9
+ /** Maximum file size in bytes (5MB). Must match Supabase bucket limit. */
10
+ export declare const MAX_UPLOAD_SIZE_BYTES: number;
11
+ /** Allowed MIME types for image uploads. Must match Supabase bucket config. */
12
+ export declare const ALLOWED_IMAGE_MIME_TYPES: readonly ["image/jpeg", "image/png", "image/webp", "image/heic"];
13
+ export type AllowedImageMimeType = (typeof ALLOWED_IMAGE_MIME_TYPES)[number];
14
+ export type UploadValidationError = {
15
+ type: "FILE_TOO_LARGE";
16
+ maxBytes: number;
17
+ actualBytes: number;
18
+ } | {
19
+ type: "INVALID_MIME_TYPE";
20
+ allowed: readonly string[];
21
+ actual: string;
22
+ };
23
+ /**
24
+ * Validates file size is within allowed limit.
25
+ * @returns Error object if invalid, null if valid
26
+ */
27
+ export declare function validateFileSize(sizeBytes: number): UploadValidationError | null;
28
+ /**
29
+ * Validates MIME type is an allowed image type.
30
+ * @returns Error object if invalid, null if valid
31
+ */
32
+ export declare function validateImageMimeType(mimeType: string | null | undefined): UploadValidationError | null;
33
+ /**
34
+ * Validates both file size and MIME type.
35
+ * @returns First validation error found, or null if valid
36
+ */
37
+ export declare function validateImageUpload(sizeBytes: number, mimeType: string | null | undefined): UploadValidationError | null;
38
+ /**
39
+ * Formats a validation error into a user-friendly message.
40
+ */
41
+ export declare function formatUploadError(error: UploadValidationError): string;
@@ -0,0 +1,73 @@
1
+ /**
2
+ * File upload validation constants and utilities.
3
+ *
4
+ * These values must stay in sync with Supabase Storage bucket limits.
5
+ * Current bucket config (media): 5MB max, image/* MIME types only.
6
+ */
7
+ // =============================================================================
8
+ // CONSTANTS
9
+ // =============================================================================
10
+ /** Maximum file size in MB for display purposes. */
11
+ export const MAX_UPLOAD_SIZE_MB = 5;
12
+ /** Maximum file size in bytes (5MB). Must match Supabase bucket limit. */
13
+ export const MAX_UPLOAD_SIZE_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024;
14
+ /** Allowed MIME types for image uploads. Must match Supabase bucket config. */
15
+ export const ALLOWED_IMAGE_MIME_TYPES = [
16
+ "image/jpeg",
17
+ "image/png",
18
+ "image/webp",
19
+ "image/heic",
20
+ ];
21
+ /**
22
+ * Validates file size is within allowed limit.
23
+ * @returns Error object if invalid, null if valid
24
+ */
25
+ export function validateFileSize(sizeBytes) {
26
+ if (sizeBytes > MAX_UPLOAD_SIZE_BYTES) {
27
+ return {
28
+ type: "FILE_TOO_LARGE",
29
+ maxBytes: MAX_UPLOAD_SIZE_BYTES,
30
+ actualBytes: sizeBytes,
31
+ };
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * Validates MIME type is an allowed image type.
37
+ * @returns Error object if invalid, null if valid
38
+ */
39
+ export function validateImageMimeType(mimeType) {
40
+ if (!mimeType || !ALLOWED_IMAGE_MIME_TYPES.some((t) => t === mimeType)) {
41
+ return {
42
+ type: "INVALID_MIME_TYPE",
43
+ allowed: ALLOWED_IMAGE_MIME_TYPES,
44
+ actual: mimeType ?? "unknown",
45
+ };
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * Validates both file size and MIME type.
51
+ * @returns First validation error found, or null if valid
52
+ */
53
+ export function validateImageUpload(sizeBytes, mimeType) {
54
+ return validateFileSize(sizeBytes) ?? validateImageMimeType(mimeType);
55
+ }
56
+ /**
57
+ * Formats a validation error into a user-friendly message.
58
+ */
59
+ export function formatUploadError(error) {
60
+ switch (error.type) {
61
+ case "FILE_TOO_LARGE": {
62
+ const actualMB = (error.actualBytes / (1024 * 1024)).toFixed(1);
63
+ return `File too large. Yours is ${actualMB}MB, max is ${MAX_UPLOAD_SIZE_MB}MB.`;
64
+ }
65
+ case "INVALID_MIME_TYPE": {
66
+ // Show friendly type names instead of MIME types
67
+ const friendlyTypes = error.allowed
68
+ .map((t) => t.replace("image/", "").toUpperCase())
69
+ .join(", ");
70
+ return `Unsupported file type. Allowed: ${friendlyTypes}.`;
71
+ }
72
+ }
73
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Use in the default case of a switch statement to get a compile-time error
3
+ * if you forget to handle a case.
4
+ *
5
+ * How it works: If all cases are handled, the default branch is unreachable
6
+ * and the value is inferred as `never`. If you add a new case to the union
7
+ * but don't handle it, TypeScript errors because the value isn't `never`.
8
+ *
9
+ * @example
10
+ * type Event = "created" | "updated" | "deleted";
11
+ *
12
+ * switch (event) {
13
+ * case "created": ... break;
14
+ * case "updated": ... break;
15
+ * // Forgot "deleted" - TypeScript error at exhaustiveSwitch(event)
16
+ * default:
17
+ * exhaustiveSwitch(event);
18
+ * }
19
+ */
20
+ export declare function exhaustiveSwitch(value: never): never;
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Use in the default case of a switch statement to get a compile-time error
3
+ * if you forget to handle a case.
4
+ *
5
+ * How it works: If all cases are handled, the default branch is unreachable
6
+ * and the value is inferred as `never`. If you add a new case to the union
7
+ * but don't handle it, TypeScript errors because the value isn't `never`.
8
+ *
9
+ * @example
10
+ * type Event = "created" | "updated" | "deleted";
11
+ *
12
+ * switch (event) {
13
+ * case "created": ... break;
14
+ * case "updated": ... break;
15
+ * // Forgot "deleted" - TypeScript error at exhaustiveSwitch(event)
16
+ * default:
17
+ * exhaustiveSwitch(event);
18
+ * }
19
+ */
20
+ export function exhaustiveSwitch(value) {
21
+ throw new Error(`Unhandled switch case: ${value}`);
22
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Web routes and URLs - single source of truth for both mobile and web.
3
+ */
4
+ export declare const WEBAPP_BASE_URL = "https://app.dinkpets.app";
5
+ /** Path segments (without leading slash for flexibility) */
6
+ export declare const webPaths: {
7
+ readonly about: "about";
8
+ readonly help: "help";
9
+ readonly faq: "faq";
10
+ readonly terms: "terms";
11
+ readonly privacy: "privacy";
12
+ readonly download: "download";
13
+ };
14
+ /** Full URLs for external linking */
15
+ export declare const webUrls: {
16
+ readonly about: "https://app.dinkpets.app/about";
17
+ readonly help: "https://app.dinkpets.app/help";
18
+ readonly faq: "https://app.dinkpets.app/faq";
19
+ readonly terms: "https://app.dinkpets.app/terms";
20
+ readonly privacy: "https://app.dinkpets.app/privacy";
21
+ readonly download: "https://app.dinkpets.app/download";
22
+ };
23
+ /** Email addresses (raw, without display names) */
24
+ export declare const emails: {
25
+ readonly support: "support@dinkpets.app";
26
+ readonly noReply: "noreply@dinkpets.app";
27
+ readonly hello: "hello@dinkpets.app";
28
+ };
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Web routes and URLs - single source of truth for both mobile and web.
3
+ */
4
+ export const WEBAPP_BASE_URL = "https://app.dinkpets.app";
5
+ /** Path segments (without leading slash for flexibility) */
6
+ export const webPaths = {
7
+ about: "about",
8
+ help: "help",
9
+ faq: "faq",
10
+ terms: "terms",
11
+ privacy: "privacy",
12
+ download: "download",
13
+ };
14
+ /** Full URLs for external linking */
15
+ export const webUrls = {
16
+ about: `${WEBAPP_BASE_URL}/${webPaths.about}`,
17
+ help: `${WEBAPP_BASE_URL}/${webPaths.help}`,
18
+ faq: `${WEBAPP_BASE_URL}/${webPaths.faq}`,
19
+ terms: `${WEBAPP_BASE_URL}/${webPaths.terms}`,
20
+ privacy: `${WEBAPP_BASE_URL}/${webPaths.privacy}`,
21
+ download: `${WEBAPP_BASE_URL}/${webPaths.download}`,
22
+ };
23
+ /** Email addresses (raw, without display names) */
24
+ export const emails = {
25
+ support: "support@dinkpets.app",
26
+ noReply: "noreply@dinkpets.app",
27
+ hello: "hello@dinkpets.app",
28
+ };