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.
- package/README.md +119 -0
- package/dist/mobile-global.css +69 -0
- package/dist/src/activity-details.d.ts +223 -0
- package/dist/src/activity-details.js +166 -0
- package/dist/src/admin.d.ts +28 -0
- package/dist/src/admin.js +52 -0
- package/dist/src/auth.d.ts +21 -0
- package/dist/src/auth.js +43 -0
- package/dist/src/constants.d.ts +13 -0
- package/dist/src/constants.js +41 -0
- package/dist/src/enums.d.ts +121 -0
- package/dist/src/enums.js +41 -0
- package/dist/src/feature-flags.d.ts +35 -0
- package/dist/src/feature-flags.js +41 -0
- package/dist/src/ids.d.ts +70 -0
- package/dist/src/ids.js +47 -0
- package/dist/src/index.d.ts +14 -0
- package/dist/src/index.js +14 -0
- package/dist/src/roles.d.ts +6 -0
- package/dist/src/roles.js +17 -0
- package/dist/src/schemas.d.ts +112 -0
- package/dist/src/schemas.js +166 -0
- package/dist/src/theme.d.ts +224 -0
- package/dist/src/theme.js +343 -0
- package/dist/src/updates.d.ts +53 -0
- package/dist/src/updates.js +58 -0
- package/dist/src/uploads.d.ts +41 -0
- package/dist/src/uploads.js +73 -0
- package/dist/src/utils.d.ts +20 -0
- package/dist/src/utils.js +22 -0
- package/dist/src/web-routes.d.ts +28 -0
- package/dist/src/web-routes.js +28 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +22 -0
|
@@ -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
|
+
};
|