admin: Superforms + Zod validation, three-address-defaults schema, profiles toast fix
- Add Superforms + Zod 4 for all admin form validation - Replace address isDefault with three separate boolean defaults (Mailing, Billing, Shipping) - Update profiles page with three-checkbox address form and toast improvements - Convert all admin page server actions to pass request directly to crud - Export superFormErrorsToFieldErrors helper for profiles server actions - Regenerate Prisma client for new schema fields
This commit is contained in:
@@ -37,12 +37,16 @@
|
||||
<h2 class="mb-4 text-lg font-medium text-gray-900 dark:text-gray-100">Profile</h2>
|
||||
|
||||
{#if form?.action === 'updateProfile' && form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
Profile updated successfully.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.action === 'updateProfile' && form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -115,12 +119,16 @@
|
||||
</div>
|
||||
|
||||
{#if form?.action === 'addAddress' && form?.success}
|
||||
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
Address added successfully.
|
||||
</div>
|
||||
{/if}
|
||||
{#if form?.action === 'addAddress' && form?.error}
|
||||
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||||
<div
|
||||
class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
>
|
||||
{form.error}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -133,7 +141,9 @@
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Street</label>
|
||||
<label for="street" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Street</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="street"
|
||||
@@ -144,7 +154,9 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300">City</label>
|
||||
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>City</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="city"
|
||||
@@ -154,7 +166,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300">State</label>
|
||||
<label for="state" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>State</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="state"
|
||||
@@ -166,7 +180,10 @@
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label for="postalCode" class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
<label
|
||||
for="postalCode"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
@@ -178,7 +195,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label>
|
||||
<label
|
||||
for="country"
|
||||
class="block text-sm font-medium text-gray-700 dark:text-gray-300">Country</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="country"
|
||||
@@ -204,10 +224,13 @@
|
||||
{#if data.user.profile?.addresses && data.user.profile.addresses.length > 0}
|
||||
<div class="flex flex-col gap-3">
|
||||
{#each data.user.profile.addresses as address}
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900">
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-sm text-gray-900 dark:text-gray-100">{address.street}</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
||||
{address.city}, {address.state} {address.postalCode}
|
||||
{address.city}, {address.state}
|
||||
{address.postalCode}
|
||||
</p>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">{address.country}</p>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +168,9 @@ describe('(protected)/account page server', () => {
|
||||
country: 'US'
|
||||
});
|
||||
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('addAddress', request, createLocals())
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(invokeAction('addAddress', request, createLocals())).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -201,9 +201,9 @@ describe('(protected)/account page server', () => {
|
||||
});
|
||||
|
||||
const locals = createLocals(mockUser() as any);
|
||||
const err = await Promise.resolve(
|
||||
invokeAction('addAddress', request, locals)
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(invokeAction('addAddress', request, locals)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 400)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
<!--
|
||||
There should be a form in the middle
|
||||
rounded corners
|
||||
@@ -44,17 +43,29 @@
|
||||
|
||||
<Window>
|
||||
<form method="POST" class="border p-6 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100">
|
||||
<p class="mx-auto text-center my-4 italic">Please finish setting up your profile</p>
|
||||
<p class="mx-auto my-4 text-center italic">Please finish setting up your profile</p>
|
||||
{#if data.user?.image}
|
||||
<img src={data.user?.image} class="rounded-full w-[8rem] mx-auto" alt="Profile" />
|
||||
<img src={data.user?.image} class="mx-auto w-[8rem] rounded-full" alt="Profile" />
|
||||
{:else}
|
||||
<div class="mx-auto aspect-square w-[8rem] rounded-full bg-slate-900"></div>
|
||||
{/if}
|
||||
<p class="mx-auto text-center my-4">{data.user?.email}</p>
|
||||
<p class="mx-auto my-4 text-center">{data.user?.email}</p>
|
||||
<div class="flex flex-col">
|
||||
<input type="text" name="userId" value={data.user?.id} class="hidden"/>
|
||||
<input type="text" name="firstName" placeholder="First Name" class="my-2" bind:value={firstName} />
|
||||
<input type="text" name="lastName" placeholder="Last Name" class="my-2" bind:value={lastName} />
|
||||
<input type="text" name="userId" value={data.user?.id} class="hidden" />
|
||||
<input
|
||||
type="text"
|
||||
name="firstName"
|
||||
placeholder="First Name"
|
||||
class="my-2"
|
||||
bind:value={firstName}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
name="lastName"
|
||||
placeholder="Last Name"
|
||||
class="my-2"
|
||||
bind:value={lastName}
|
||||
/>
|
||||
<input
|
||||
type="tel"
|
||||
name="phoneNumber"
|
||||
@@ -64,6 +75,8 @@
|
||||
oninput={handlePhoneInput}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" class="p-2 px-4 bg-blue-300 dark:bg-gray-700 dark:text-gray-100">Submit</button>
|
||||
<button type="submit" class="bg-blue-300 p-2 px-4 dark:bg-gray-700 dark:text-gray-100"
|
||||
>Submit</button
|
||||
>
|
||||
</form>
|
||||
</Window>
|
||||
|
||||
@@ -33,9 +33,9 @@ describe('account setup page server load', () => {
|
||||
});
|
||||
|
||||
it('throws 403 when user is not logged in', async () => {
|
||||
const err = await Promise.resolve(
|
||||
load({ locals: createLocals() } as any)
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 403)).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
</script>
|
||||
|
||||
<div class="flex h-full min-h-0 flex-col gap-6 p-6 dark:text-gray-100">
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">Welcome, {data.firstName}!</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
Welcome, {data.firstName}!
|
||||
</h1>
|
||||
|
||||
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<a
|
||||
@@ -22,7 +24,9 @@
|
||||
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<p class="text-sm font-medium text-gray-500 dark:text-gray-400">Proclamations</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">{data.proclamationCount}</p>
|
||||
<p class="mt-2 text-3xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{data.proclamationCount}
|
||||
</p>
|
||||
<p class="mt-1 text-sm text-gray-400 dark:text-gray-500">Browse all</p>
|
||||
</a>
|
||||
|
||||
|
||||
@@ -12,13 +12,19 @@
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Key
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Level
|
||||
</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
<th
|
||||
class="px-6 py-3 text-left text-xs font-medium tracking-wider text-gray-500 uppercase dark:text-gray-400"
|
||||
>
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
@@ -26,15 +32,17 @@
|
||||
<tbody class="divide-y divide-gray-200 bg-white dark:divide-gray-700 dark:bg-gray-900">
|
||||
{#each data.licenseKeys as key}
|
||||
<tr>
|
||||
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900 dark:text-gray-100">
|
||||
<td
|
||||
class="px-6 py-4 font-mono text-sm whitespace-nowrap text-gray-900 dark:text-gray-100"
|
||||
>
|
||||
{key.key}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700 dark:text-gray-300">
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap text-gray-700 dark:text-gray-300">
|
||||
{key.level}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-6 py-4 text-sm">
|
||||
<td class="px-6 py-4 text-sm whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
|
||||
class="inline-flex rounded-full px-2 text-xs leading-5 font-semibold {key.active
|
||||
? 'bg-green-100 text-green-800 dark:bg-gray-700 dark:text-gray-200'
|
||||
: 'bg-red-100 text-red-800 dark:bg-gray-700 dark:text-gray-200'}"
|
||||
>
|
||||
|
||||
@@ -23,7 +23,7 @@ export const load: PageServerLoad = async ({ url }) => {
|
||||
const [proclamations, categories] = await Promise.all([
|
||||
prisma.proclamation.findMany({
|
||||
where,
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } },
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } }, subscriptionLevel: true },
|
||||
orderBy: { name: 'asc' }
|
||||
}),
|
||||
prisma.proclamationCategory.findMany({ include: { parent: true }, orderBy: { name: 'asc' } })
|
||||
|
||||
@@ -40,7 +40,9 @@
|
||||
<!-- Filters -->
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Search</label>
|
||||
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Search</label
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
id="search"
|
||||
@@ -51,7 +53,9 @@
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Category</label>
|
||||
<label for="category" class="block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>Category</label
|
||||
>
|
||||
<select
|
||||
id="category"
|
||||
bind:value={categoryId}
|
||||
@@ -60,7 +64,7 @@
|
||||
>
|
||||
<option value={null}>All Categories</option>
|
||||
{#each data.categories as cat}
|
||||
<option value={cat.id}>{displayCategoryName(cat)}</option>
|
||||
<option value={cat.id}>{displayCategoryName(cat)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
@@ -91,14 +95,16 @@
|
||||
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
<h2 class="text-lg font-medium text-gray-900 dark:text-gray-100">{proclamation.name}</h2>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{displayCategoryName(proclamation.category)}</p>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{displayCategoryName(proclamation.category)}
|
||||
</p>
|
||||
<div class="mt-auto flex items-center justify-between pt-4">
|
||||
<span class="text-sm text-purple-600 dark:text-gray-400">
|
||||
{proclamation.solfeggioFrequency.value} Hz —
|
||||
{proclamation.solfeggioFrequency.name}
|
||||
</span>
|
||||
<span class="font-semibold text-gray-900 dark:text-gray-100">
|
||||
${proclamation.price.toFixed(2)}
|
||||
<span class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{proclamation.subscriptionLevel?.name ?? 'Standard'}
|
||||
</span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
@@ -14,7 +14,8 @@ export const load: PageServerLoad = async ({ params, parent }) => {
|
||||
where: { id: params.id },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -19,27 +19,35 @@
|
||||
</a>
|
||||
|
||||
<div>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">{data.proclamation.name}</h1>
|
||||
<h1 class="text-2xl font-semibold text-gray-900 dark:text-gray-100">
|
||||
{data.proclamation.name}
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{categoryBreadcrumb}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
<div class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800">
|
||||
<div
|
||||
class="rounded-lg border border-purple-200 bg-purple-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-xs font-medium text-purple-600 dark:text-gray-400">Frequency</p>
|
||||
<p class="text-sm font-semibold text-purple-900 dark:text-gray-100">
|
||||
{data.proclamation.solfeggioFrequency.value} Hz —
|
||||
{data.proclamation.solfeggioFrequency.name}
|
||||
</p>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800">
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Price</p>
|
||||
<div
|
||||
class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800"
|
||||
>
|
||||
<p class="text-xs font-medium text-gray-500 dark:text-gray-400">Level</p>
|
||||
<p class="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
${data.proclamation.price.toFixed(2)}
|
||||
{data.proclamation.subscriptionLevel?.name ?? 'Standard'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 dark:prose-invert">
|
||||
<div
|
||||
class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6 dark:border-gray-700 dark:bg-gray-900 dark:prose-invert"
|
||||
>
|
||||
{@html data.proclamation.content}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,9 +21,9 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
|
||||
it('throws 404 when proclamation is not found', async () => {
|
||||
findUnique.mockResolvedValue(null);
|
||||
|
||||
const err = await Promise.resolve(
|
||||
load({ params: { id: 'nonexistent' } } as any)
|
||||
).catch((e: unknown) => e);
|
||||
const err = await Promise.resolve(load({ params: { id: 'nonexistent' } } as any)).catch(
|
||||
(e: unknown) => e
|
||||
);
|
||||
expect(isHttpError(err, 404)).toBe(true);
|
||||
});
|
||||
|
||||
@@ -44,7 +44,8 @@ describe('(protected)/admin/proclamations/[id] page server load', () => {
|
||||
where: { id: 'proc-1' },
|
||||
include: {
|
||||
solfeggioFrequency: true,
|
||||
category: { include: { parent: true } }
|
||||
category: { include: { parent: true } },
|
||||
subscriptionLevel: true
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,7 +14,7 @@ import { load } from './+page.server';
|
||||
const proclamationFindMany = prisma.proclamation.findMany as ReturnType<typeof vi.fn>;
|
||||
const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<typeof vi.fn>;
|
||||
|
||||
describe('(protected)/admin/proclamations page server load', () => {
|
||||
describe('(protected)/admin/proclamations page server load', () => {
|
||||
beforeEach(() => {
|
||||
proclamationFindMany.mockReset();
|
||||
categoryFindMany.mockReset();
|
||||
@@ -29,10 +29,12 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
|
||||
const url = createUrl('/admin/proclamations');
|
||||
const result = (await load({ url } as any)) as any;
|
||||
expect(result.proclamations).toEqual(proclamations);
|
||||
expect(result.categories[0]).toEqual(expect.objectContaining({
|
||||
...categories[0],
|
||||
displayName: categories[0].name
|
||||
}));
|
||||
expect(result.categories[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
...categories[0],
|
||||
displayName: categories[0].name
|
||||
})
|
||||
);
|
||||
expect(result.search).toBe('');
|
||||
expect(result.categoryId).toBeNull();
|
||||
});
|
||||
@@ -109,7 +111,7 @@ const categoryFindMany = prisma.proclamationCategory.findMany as ReturnType<type
|
||||
|
||||
expect(proclamationFindMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
|
||||
include: { solfeggioFrequency: true, category: { include: { parent: true } }, subscriptionLevel: true }
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user