Build user pages

This commit is contained in:
NeilClack
2026-02-25 13:19:26 -06:00
parent 12190d01dc
commit cc51f40591
53 changed files with 5204 additions and 4817 deletions
+37 -19
View File
@@ -1,48 +1,66 @@
<!-- This layout is used for all pages where a user must be authenticated to visit.
Below the navigation bar, there should be two sections. A sidebar, containing links
to common "pages", and a window to the right of that sidebar, with it's contents expanded
to fill the entire area, to display the current "page".
This layout also acts as a sort of 'gatekeeper' in that it is also the authorization layer.
-->
<script lang="ts">
import { page } from '$app/state';
import { canAccessAdmin } from '$lib/svelte-admin/roles';
import { getAllModelConfigs } from '$lib/svelte-admin/registry';
import type { LayoutData } from './$types';
let { children, data } = $props<{ children: any; data: LayoutData }>();
let adminExpanded = $state(true);
const isAdmin = $derived(canAccessAdmin(data.userRoleId));
const adminModels = getAllModelConfigs();
const navLinks = [
{ href: '/dashboard', label: 'Dashboard' },
{ href: '/account', label: 'Account' },
{ href: '/proclamations', label: 'Proclamations' },
{ href: '/license-keys', label: 'License Keys' }
];
function isActive(href: string): boolean {
return page.url.pathname === href || page.url.pathname.startsWith(href + '/');
}
</script>
<!-- Main Area -->
<div class="flex min-h-0 flex-1">
<!-- Sidebar -->
<div class="mt-12 mr-12 flex flex-col gap-1">
<!-- Sidebar links -->
<a href="/dashboard" class="text-blue-600">Dashboard</a>
<nav class="mt-12 mr-12 flex flex-col gap-1">
{#each navLinks as link}
<a
href={link.href}
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive(link.href)
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'}"
>
{link.label}
</a>
{/each}
{#if isAdmin}
<div class="mt-2 flex flex-col">
<div class="mt-4 flex flex-col gap-1">
<a
href="/admin"
class="flex items-center gap-1 text-sm font-medium text-gray-700 hover:text-gray-900"
class="rounded px-3 py-1.5 text-sm font-medium transition-colors {isActive('/admin')
? 'bg-blue-50 text-blue-700'
: 'text-gray-700 hover:bg-gray-50 hover:text-gray-900'}"
>
Admin Panel
</a>
<div class="ml-5 flex flex-col">
<div class="ml-3 flex flex-col gap-0.5">
{#each adminModels as model}
<a href="/admin/{model.slug}" class="text-sm text-blue-600 hover:text-blue-800">
<a
href="/admin/{model.slug}"
class="rounded px-3 py-1 text-sm transition-colors {isActive(
'/admin/' + model.slug
)
? 'bg-blue-50 text-blue-700'
: 'text-blue-600 hover:bg-gray-50 hover:text-blue-800'}"
>
{model.displayName}
</a>
{/each}
</div>
</div>
{/if}
<!-- End Sidebar links -->
</div>
<!-- End Sidebar -->
</nav>
<!-- Page content -->
<div class="min-h-0 flex-1 overflow-auto">
{@render children()}
+85 -3
View File
@@ -1,14 +1,96 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
import { error, fail, type Actions } from '@sveltejs/kit';
import { serverFormatPhoneNumber } from '$lib/utils';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const user = await prisma.user.findUnique({
where: { id: locals.user?.id },
include: { profile: true }
where: { id: locals.user.id },
include: {
profile: {
include: { addresses: true }
}
}
});
if (!user) {
throw error(403, 'not authorized');
}
return { user };
};
export const actions: Actions = {
updateProfile: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const firstName = data.get('firstName') as string;
const lastName = data.get('lastName') as string;
const phoneNumber = serverFormatPhoneNumber(data.get('phoneNumber') as string);
if (!firstName || !lastName || !phoneNumber) {
return fail(400, { error: 'All fields are required.', action: 'updateProfile' });
}
try {
await prisma.profile.update({
where: { userId: locals.user.id },
data: { firstName, lastName, phoneNumber }
});
return { success: true, action: 'updateProfile' };
} catch (err) {
console.error('Profile update error:', err);
return fail(500, { error: 'Failed to update profile.', action: 'updateProfile' });
}
},
addAddress: async ({ request, locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const data = await request.formData();
const street = data.get('street') as string;
const city = data.get('city') as string;
const state = data.get('state') as string;
const postalCode = data.get('postalCode') as string;
const country = data.get('country') as string;
if (!street || !city || !state || !postalCode || !country) {
return fail(400, { error: 'All address fields are required.', action: 'addAddress' });
}
const profile = await prisma.profile.findUnique({
where: { userId: locals.user.id },
select: { id: true }
});
if (!profile) {
throw error(400, 'Profile not found');
}
try {
await prisma.address.create({
data: {
profileId: profile.id,
street,
city,
state,
postalCode,
country
}
});
return { success: true, action: 'addAddress' };
} catch (err) {
console.error('Address create error:', err);
return fail(500, { error: 'Failed to add address.', action: 'addAddress' });
}
}
};
+219 -4
View File
@@ -1,5 +1,220 @@
<script lang="ts">
import type {PageProps} from "./$types";
let {data}: PageProps = $props();
$inspect(data);
</script>
import { clientFormatPhoneNumber } from '$lib/utils';
import type { PageProps } from './$types';
let { data, form }: PageProps = $props();
let initialPhone = $derived(clientFormatPhoneNumber(data.user.profile?.phoneNumber ?? ''));
let phoneNumber = $state('');
let phoneInitialized = $state(false);
$effect(() => {
if (!phoneInitialized) {
phoneNumber = initialPhone;
phoneInitialized = true;
}
});
let showAddressForm = $state(false);
function handlePhoneInput(event: Event) {
const input = event.target as HTMLInputElement;
phoneNumber = clientFormatPhoneNumber(input.value);
}
</script>
<div class="flex h-full min-h-0 flex-col gap-8 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Account</h1>
<!-- Email (read-only) -->
<div>
<p class="text-sm font-medium text-gray-700">Email</p>
<p class="mt-1 text-gray-900">{data.user.email}</p>
</div>
<!-- Profile Form -->
<section>
<h2 class="mb-4 text-lg font-medium text-gray-900">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">
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">
{form.error}
</div>
{/if}
<form method="POST" action="?/updateProfile" class="flex max-w-lg flex-col gap-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label for="firstName" class="block text-sm font-medium text-gray-700">
First Name
</label>
<input
type="text"
id="firstName"
name="firstName"
value={data.user.profile?.firstName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="lastName" class="block text-sm font-medium text-gray-700">
Last Name
</label>
<input
type="text"
id="lastName"
name="lastName"
value={data.user.profile?.lastName ?? ''}
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div>
<label for="phoneNumber" class="block text-sm font-medium text-gray-700">
Phone Number
</label>
<input
type="tel"
id="phoneNumber"
name="phoneNumber"
bind:value={phoneNumber}
oninput={handlePhoneInput}
required
class="mt-1 w-full max-w-xs rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Save Profile
</button>
</div>
</form>
</section>
<!-- Addresses -->
<section>
<div class="mb-4 flex items-center justify-between">
<h2 class="text-lg font-medium text-gray-900">Addresses</h2>
<button
type="button"
class="rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showAddressForm = !showAddressForm)}
>
{showAddressForm ? 'Cancel' : 'Add Address'}
</button>
</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">
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">
{form.error}
</div>
{/if}
{#if showAddressForm}
<form
method="POST"
action="?/addAddress"
class="mb-6 max-w-lg rounded-lg border border-gray-200 bg-gray-50 p-4"
>
<div class="flex flex-col gap-3">
<div>
<label for="street" class="block text-sm font-medium text-gray-700">Street</label>
<input
type="text"
id="street"
name="street"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="city" class="block text-sm font-medium text-gray-700">City</label>
<input
type="text"
id="city"
name="city"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="state" class="block text-sm font-medium text-gray-700">State</label>
<input
type="text"
id="state"
name="state"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="postalCode" class="block text-sm font-medium text-gray-700">
Postal Code
</label>
<input
type="text"
id="postalCode"
name="postalCode"
required
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700">Country</label>
<input
type="text"
id="country"
name="country"
required
value="US"
class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
/>
</div>
</div>
<div>
<button
type="submit"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
>
Save Address
</button>
</div>
</div>
</form>
{/if}
{#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">
<p class="text-sm text-gray-900">{address.street}</p>
<p class="text-sm text-gray-600">
{address.city}, {address.state} {address.postalCode}
</p>
<p class="text-sm text-gray-600">{address.country}</p>
</div>
{/each}
</div>
{:else if !showAddressForm}
<p class="text-sm text-gray-500">No addresses on file.</p>
{/if}
</section>
</div>
@@ -1,46 +1,255 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, mockUserWithProfile } from '$lib/test-utils';
import { isHttpError, isActionFailure } from '@sveltejs/kit';
import {
createLocals,
createFormRequest,
mockUser,
mockUserWithProfileAndAddresses
} from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
user: {
findUnique: vi.fn()
}
user: { findUnique: vi.fn() },
profile: { update: vi.fn(), findUnique: vi.fn() },
address: { create: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
import { load, actions } from './+page.server';
const findUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const userFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
const profileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const addressCreate = prisma.address.create as ReturnType<typeof vi.fn>;
describe('(protected)/account page server load', () => {
const invokeAction = (name: string, request: Request, locals: App.Locals) =>
(actions as any)[name]({ request, locals } as any);
describe('(protected)/account page server', () => {
beforeEach(() => {
findUnique.mockReset();
userFindUnique.mockReset();
profileUpdate.mockReset();
profileFindUnique.mockReset();
addressCreate.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
findUnique.mockResolvedValue(null);
describe('load', () => {
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
it('throws 403 when user is not found in the database', async () => {
userFindUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns user with profile and addresses', async () => {
const user = mockUserWithProfileAndAddresses();
userFindUnique.mockResolvedValue(user);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.user).toEqual(user);
expect(result.user.profile.addresses).toHaveLength(1);
});
it('includes addresses in the query', async () => {
userFindUnique.mockResolvedValue(mockUserWithProfileAndAddresses());
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(userFindUnique).toHaveBeenCalledWith({
where: { id: 'user-1' },
include: { profile: { include: { addresses: true } } }
});
});
});
it('throws 403 when user is not found in the database', async () => {
findUnique.mockResolvedValue(null);
describe('updateProfile action', () => {
it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
const err = await Promise.resolve(
invokeAction('updateProfile', request, createLocals())
).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when firstName is missing', async () => {
const request = createFormRequest({
firstName: '',
lastName: 'Doe',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.error).toBe('All fields are required.');
});
it('returns fail(400) when lastName is missing', async () => {
const request = createFormRequest({
firstName: 'Jane',
lastName: '',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
});
it('returns fail(400) when phoneNumber is missing', async () => {
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: ''
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
});
it('updates profile with server-formatted phone number on success', async () => {
profileUpdate.mockResolvedValue({});
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Smith',
phoneNumber: '(817) 555-1234'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(result).toEqual({ success: true, action: 'updateProfile' });
expect(profileUpdate).toHaveBeenCalledWith({
where: { userId: 'user-1' },
data: { firstName: 'Jane', lastName: 'Smith', phoneNumber: '8175551234' }
});
});
it('returns fail(500) when prisma update throws', async () => {
profileUpdate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
firstName: 'Jane',
lastName: 'Doe',
phoneNumber: '(555) 010-0100'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('updateProfile', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
});
it('returns user with profile when found', async () => {
const user = mockUserWithProfile();
findUnique.mockResolvedValue(user);
describe('addAddress action', () => {
it('throws 403 when locals.user is undefined', async () => {
const request = createFormRequest({
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.user).toEqual(user);
const err = await Promise.resolve(
invokeAction('addAddress', request, createLocals())
).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns fail(400) when a required field is missing', async () => {
const request = createFormRequest({
street: '123 Main St',
city: '',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('addAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).data.error).toBe('All address fields are required.');
});
it('throws 400 when profile is not found', async () => {
profileFindUnique.mockResolvedValue(null);
const request = createFormRequest({
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(
invokeAction('addAddress', request, locals)
).catch((e: unknown) => e);
expect(isHttpError(err, 400)).toBe(true);
});
it('creates address linked to profile on success', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockResolvedValue({});
const request = createFormRequest({
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('addAddress', request, locals);
expect(result).toEqual({ success: true, action: 'addAddress' });
expect(addressCreate).toHaveBeenCalledWith({
data: {
profileId: 'profile-1',
street: '456 Oak Ave',
city: 'Austin',
state: 'TX',
postalCode: '73301',
country: 'US'
}
});
});
it('returns fail(500) when prisma create throws', async () => {
profileFindUnique.mockResolvedValue({ id: 'profile-1' });
addressCreate.mockRejectedValue(new Error('db error'));
const request = createFormRequest({
street: '123 Main St',
city: 'Dallas',
state: 'TX',
postalCode: '75001',
country: 'US'
});
const locals = createLocals(mockUser() as any);
const result = await invokeAction('addAddress', request, locals);
expect(isActionFailure(result)).toBe(true);
expect((result as any).status).toBe(500);
});
});
});
@@ -0,0 +1,104 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
user: {
findUnique: vi.fn()
}
}
}));
import { prisma } from '$lib/db';
import { load } from './+layout.server';
const findUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
describe('admin layout server load', () => {
beforeEach(() => {
findUnique.mockReset();
});
it('throws 403 when user is not found', async () => {
findUnique.mockResolvedValue(null);
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('throws 403 when roleId is null', async () => {
findUnique.mockResolvedValue({ id: 'user-1', name: 'Jane', roleId: null, role: null });
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('throws 403 when roleId is 0', async () => {
findUnique.mockResolvedValue({ id: 'user-1', name: 'Jane', roleId: 0, role: null });
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('throws 403 when roleId is USER (4)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 4,
role: { name: 'User' }
});
const locals = createLocals(mockUser() as any);
const err = await Promise.resolve(load({ locals } as any)).catch((e: unknown) => e);
expect(isHttpError(err, 403)).toBe(true);
});
it('allows access for SUPER (roleId 1)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 1,
role: { name: 'Super' }
});
const locals = createLocals(mockUser() as any);
const result = await load({ locals } as any);
expect(result).toEqual({
adminUser: { id: 'user-1', name: 'Jane', roleId: 1, roleName: 'Super' }
});
});
it('allows access for ADMIN (roleId 2)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 2,
role: { name: 'Admin' }
});
const locals = createLocals(mockUser() as any);
const result = await load({ locals } as any);
expect(result).toEqual({
adminUser: { id: 'user-1', name: 'Jane', roleId: 2, roleName: 'Admin' }
});
});
it('allows access for STAFF (roleId 3)', async () => {
findUnique.mockResolvedValue({
id: 'user-1',
name: 'Jane',
roleId: 3,
role: { name: 'Staff' }
});
const locals = createLocals(mockUser() as any);
const result = await load({ locals } as any);
expect(result).toEqual({
adminUser: { id: 'user-1', name: 'Jane', roleId: 3, roleName: 'Staff' }
});
});
});
@@ -1,97 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
const user = await prisma.user.findUnique({
where: { id: locals.user.id },
select: { id: true, name: true, role: true }
});
const affirmations = await prisma.affirmation.findMany({
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
}
});
const affirmationCategories = await prisma.affirmationCategory.findMany({
include: { children: true }
});
const solfeggioFrequencies = await prisma.solfeggioFrequency.findMany();
return { user, affirmationCategories, affirmations, solfeggioFrequencies };
};
async function resolveCategory(categoryName: string, subCategoryName: string | null) {
let parent = await prisma.affirmationCategory.findFirst({
where: { name: categoryName, parentId: null }
});
if (!parent) {
parent = await prisma.affirmationCategory.create({
data: { name: categoryName }
});
}
if (!subCategoryName) return parent;
let child = await prisma.affirmationCategory.findFirst({
where: { name: subCategoryName, parentId: parent.id }
});
if (!child) {
child = await prisma.affirmationCategory.create({
data: { name: subCategoryName, parentId: parent.id }
});
}
return child;
}
export const actions: Actions = {
create: async ({ request }: RequestEvent) => {
const form = await request.formData();
const frequencyId = Number(form.get('solfeggioFrequencyId')?.toString()) || 0;
const price = Number(form.get('price')) || 0.0;
const categoryName = form.get('categoryName') as string;
const subCategoryName = (form.get('subCategoryName') as string) || null;
if (frequencyId === 0) {
return fail(400, {
error: 'Solfeggio Frequency not selected!',
...Object.fromEntries(form)
});
}
if (!categoryName) {
return fail(400, {
error: 'Category is required!',
...Object.fromEntries(form)
});
}
const category = await resolveCategory(categoryName, subCategoryName);
try {
const newAffirmation = await prisma.affirmation.create({
data: {
name: form.get('name') as string,
content: form.get('content') as string,
solfeggioFrequencyId: frequencyId,
categoryId: category.id,
price: price
},
include: {
category: true,
solfeggioFrequency: true
}
});
return { success: true, newAffirmation };
} catch (err) {
console.log(err);
return fail(500, {
error: 'An unknown error has occurred. Please try again later.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,72 +0,0 @@
<script lang="ts">
import AffirmationForm from '$lib/components/forms/affirmation/AffirmationForm.svelte';
import Center from '$lib/components/containers/Center.svelte';
import type { ActionData, PageData } from './$types';
import AffirmationList from '$lib/components/ui/AffirmationList/AffirmationList.svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let showModal = $state(false);
$effect(() => {
if (form?.success) {
showModal = false;
}
});
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 mb-4">
<div class="flex shrink-0 items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Affirmations</h1>
<button
type="button"
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showModal = true)}
>
Create +
</button>
</div>
{#if data.affirmations.length > 0}
<AffirmationList affirmations={data.affirmations} />
{:else}
<Center>
<div class="text-center">
<p class="text-lg text-gray-500">No affirmations yet</p>
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add your first one</p>
</div>
</Center>
{/if}
</div>
{#if showModal}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
<div class="flex min-h-full items-center justify-center p-4">
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Create New Affirmation</h2>
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onclick={() => (showModal = false)}
>
✕
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<AffirmationForm
categories={data.affirmationCategories}
frequencies={data.solfeggioFrequencies}
{form}
action="?/create"
submitLabel="Create Affirmation"
/>
</div>
</div>
</div>
{/if}
@@ -1,101 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error, fail, type Actions, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }: ServerLoadEvent) => {
const affirmation = await prisma.affirmation.findUnique({
where: { id: params.id },
include: { solfeggioFrequency: true, category: { include: { parent: true } } }
});
const solfeggioFrequencies = await prisma.solfeggioFrequency.findMany();
const affirmationCategories = await prisma.affirmationCategory.findMany({
include: { children: true }
});
if (!affirmation) {
throw error(404, 'not found');
}
return { affirmation, solfeggioFrequencies, affirmationCategories };
};
async function resolveCategory(categoryName: string, subCategoryName: string | null) {
let parent = await prisma.affirmationCategory.findFirst({
where: { name: categoryName, parentId: null }
});
if (!parent) {
parent = await prisma.affirmationCategory.create({
data: { name: categoryName }
});
}
if (!subCategoryName) return parent;
let child = await prisma.affirmationCategory.findFirst({
where: { name: subCategoryName, parentId: parent.id }
});
if (!child) {
child = await prisma.affirmationCategory.create({
data: { name: subCategoryName, parentId: parent.id }
});
}
return child;
}
export const actions: Actions = {
update: async ({ request }: RequestEvent) => {
const form = await request.formData();
const frequencyId = Number(form.get('solfeggioFrequencyId')?.toString()) || 0;
const price = Number(form.get('price')) || 0.0;
const categoryName = form.get('categoryName') as string;
const subCategoryName = (form.get('subCategoryName') as string) || null;
if (!categoryName) {
return fail(400, {
error: 'Category is required!',
...Object.fromEntries(form)
});
}
if (frequencyId === 0) {
return fail(400, {
error: 'Solfeggio Frequency not selected!',
...Object.fromEntries(form)
});
}
const category = await resolveCategory(categoryName, subCategoryName);
try {
const updatedAffirmation = await prisma.affirmation.update({
where: {
id: form.get('id') as string
},
data: {
name: form.get('name') as string,
content: form.get('content') as string,
solfeggioFrequencyId: frequencyId,
categoryId: category.id,
price: price
},
include: {
category: true,
solfeggioFrequency: true
}
});
return { success: true, updatedAffirmation };
} catch (err) {
console.log(err);
return fail(500, {
error:
'An unknown error has occurred. Please try again later. Your changes have not been saved.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,49 +0,0 @@
<script lang="ts">
import AffirmationForm from '$lib/components/forms/affirmation/AffirmationForm.svelte';
import type { ActionData, PageData } from './$types';
import { onMount } from 'svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let seconds = $state(0);
let remainder = $derived(5 - seconds);
onMount(() => {
const interval = setInterval(() => {
if (form?.success) {
seconds += 1;
if (seconds === 6) {
window.location.href = '/affirmations';
}
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="mx-auto mb-auto mt-24 max-w-2xl px-4">
<a href="/affirmations" class="text-blue-600">Back to Affirmations</a>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Affirmation updated successfully! You will be redirected in {remainder} seconds.
</div>
{/if}
<div class="mt-6">
<AffirmationForm
affirmation={data.affirmation}
categories={data.affirmationCategories}
frequencies={data.solfeggioFrequencies}
{form}
action="?/update"
submitLabel="Save Affirmation"
/>
</div>
</div>
@@ -0,0 +1,33 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const profile = await prisma.profile.findUnique({
where: { userId: locals.user.id },
select: { firstName: true }
});
const [keyStats, proclamationCount] = await Promise.all([
prisma.licenseKey.groupBy({
by: ['active'],
where: { userId: locals.user.id },
_count: true
}),
prisma.proclamation.count()
]);
const activeKeys = keyStats.find((s) => s.active)?._count ?? 0;
const totalKeys = keyStats.reduce((sum, s) => sum + s._count, 0);
return {
firstName: profile?.firstName ?? 'User',
activeKeys,
totalKeys,
proclamationCount
};
};
+36 -3
View File
@@ -1,5 +1,38 @@
<script lang="ts">
// This page should show a user's dashboard, not the admin.
// There should be a dedicated 'admin/' page for admins
// There should be another dedicated 'support/' page for staff.
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Welcome, {data.firstName}!</h1>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<a
href="/license-keys"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Active License Keys</p>
<p class="mt-2 text-3xl font-bold text-gray-900">{data.activeKeys}</p>
<p class="mt-1 text-sm text-gray-400">{data.totalKeys} total</p>
</a>
<a
href="/proclamations"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Proclamations</p>
<p class="mt-2 text-3xl font-bold text-gray-900">{data.proclamationCount}</p>
<p class="mt-1 text-sm text-gray-400">Browse all</p>
</a>
<a
href="/account"
class="rounded-lg border border-gray-200 bg-white p-6 shadow-sm transition-shadow hover:shadow-md"
>
<p class="text-sm font-medium text-gray-500">Account</p>
<p class="mt-2 text-lg font-semibold text-gray-900">Manage Profile</p>
<p class="mt-1 text-sm text-gray-400">Edit your details</p>
</a>
</div>
</div>
@@ -0,0 +1,88 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
profile: { findUnique: vi.fn() },
licenseKey: { groupBy: vi.fn() },
proclamation: { count: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const profileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
const licenseKeyGroupBy = prisma.licenseKey.groupBy as ReturnType<typeof vi.fn>;
const proclamationCount = prisma.proclamation.count as ReturnType<typeof vi.fn>;
describe('(protected)/dashboard page server load', () => {
beforeEach(() => {
profileFindUnique.mockReset();
licenseKeyGroupBy.mockReset();
proclamationCount.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns firstName from profile', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('Jane');
});
it('defaults firstName to "User" when profile is null', async () => {
profileFindUnique.mockResolvedValue(null);
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.firstName).toBe('User');
});
it('computes activeKeys and totalKeys from groupBy results', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([
{ active: true, _count: 3 },
{ active: false, _count: 2 }
]);
proclamationCount.mockResolvedValue(10);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(3);
expect(result.totalKeys).toBe(5);
});
it('returns 0 for activeKeys when none are active', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([{ active: false, _count: 4 }]);
proclamationCount.mockResolvedValue(0);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.activeKeys).toBe(0);
expect(result.totalKeys).toBe(4);
});
it('returns proclamationCount', async () => {
profileFindUnique.mockResolvedValue({ firstName: 'Jane' });
licenseKeyGroupBy.mockResolvedValue([]);
proclamationCount.mockResolvedValue(42);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.proclamationCount).toBe(42);
});
});
@@ -1,47 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { fail, type Actions, type RequestEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async () => {
const frequencies = await prisma.solfeggioFrequency.findMany({
include: {
_count: {
select: { affirmations: true }
}
}
});
return { frequencies };
};
export const actions: Actions = {
create: async ({ request }: RequestEvent) => {
const form = await request.formData();
const name = form.get('name') as string;
const value = Number(form.get('value'));
if (!name) {
return fail(400, { error: 'Name is required!', ...Object.fromEntries(form) });
}
if (!value || value <= 0) {
return fail(400, {
error: 'A valid frequency value is required!',
...Object.fromEntries(form)
});
}
try {
const newFrequency = await prisma.solfeggioFrequency.create({
data: { name, value }
});
return { success: true, newFrequency };
} catch (err) {
console.log(err);
return fail(500, {
error: 'An unknown error has occurred. Please try again later.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,66 +0,0 @@
<script lang="ts">
import FrequencyForm from '$lib/components/forms/frequency/FrequencyForm.svelte';
import Center from '$lib/components/containers/Center.svelte';
import type { ActionData, PageData } from './$types';
import FrequencyList from '$lib/components/ui/FrequencyList/FrequencyList.svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let showModal = $state(false);
$effect(() => {
if (form?.success) {
showModal = false;
}
});
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6 mb-4">
<div class="flex shrink-0 items-center justify-between">
<h1 class="text-2xl font-semibold text-gray-900">Frequencies</h1>
<button
type="button"
class="rounded-lg bg-green-600 px-4 py-2 font-medium text-white transition-colors hover:bg-green-700"
onclick={() => (showModal = true)}
>
Create +
</button>
</div>
{#if data.frequencies.length > 0}
<FrequencyList frequencies={data.frequencies} />
{:else}
<Center>
<div class="text-center">
<p class="text-lg text-gray-500">No frequencies yet</p>
<p class="mt-1 text-sm text-gray-400">Click "Create +" to add your first one</p>
</div>
</Center>
{/if}
</div>
{#if showModal}
<div class="fixed inset-0 z-50 overflow-y-auto bg-black/50">
<div class="flex min-h-full items-center justify-center p-4">
<div class="w-full max-w-2xl rounded-lg bg-white p-8 shadow-xl">
<div class="mb-6 flex items-center justify-between">
<h2 class="text-xl font-semibold text-gray-900">Create New Frequency</h2>
<button
type="button"
class="rounded p-1 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600"
onclick={() => (showModal = false)}
>
✕
</button>
</div>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
<FrequencyForm {form} action="?/create" submitLabel="Create Frequency" />
</div>
</div>
</div>
{/if}
@@ -1,50 +0,0 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error, fail, type Actions, type RequestEvent, type ServerLoadEvent } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }: ServerLoadEvent) => {
const frequency = await prisma.solfeggioFrequency.findUnique({
where: { id: Number(params.id) }
});
if (!frequency) {
throw error(404, 'not found');
}
return { frequency };
};
export const actions: Actions = {
update: async ({ request }: RequestEvent) => {
const form = await request.formData();
const id = Number(form.get('id'));
const name = form.get('name') as string;
const value = Number(form.get('value'));
if (!name) {
return fail(400, { error: 'Name is required!', ...Object.fromEntries(form) });
}
if (!value || value <= 0) {
return fail(400, {
error: 'A valid frequency value is required!',
...Object.fromEntries(form)
});
}
try {
const updatedFrequency = await prisma.solfeggioFrequency.update({
where: { id },
data: { name, value }
});
return { success: true, updatedFrequency };
} catch (err) {
console.log(err);
return fail(500, {
error: 'An unknown error has occurred. Please try again later. Your changes have not been saved.',
...Object.fromEntries(form)
});
}
}
};
@@ -1,47 +0,0 @@
<script lang="ts">
import FrequencyForm from '$lib/components/forms/frequency/FrequencyForm.svelte';
import type { ActionData, PageData } from './$types';
import { onMount } from 'svelte';
let { data, form } = $props<{ data: PageData; form: ActionData }>();
let seconds = $state(0);
let remainder = $derived(5 - seconds);
onMount(() => {
const interval = setInterval(() => {
if (form?.success) {
seconds += 1;
if (seconds === 6) {
window.location.href = '/frequencies';
}
}
}, 1000);
return () => clearInterval(interval);
});
</script>
<div class="mx-auto mb-auto mt-24 max-w-2xl px-4">
<a href="/frequencies" class="text-blue-600">Back to Frequencies</a>
{#if form?.error}
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-red-700">
{form.error}
</div>
{/if}
{#if form?.success}
<div class="mb-4 rounded-lg border border-green-200 bg-green-50 p-3 text-green-700">
Frequency updated successfully! You will be redirected in {remainder} seconds.
</div>
{/if}
<div class="mt-6">
<FrequencyForm
frequency={data.frequency}
{form}
action="?/update"
submitLabel="Save Frequency"
/>
</div>
</div>
@@ -0,0 +1,16 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user?.id) {
throw error(403, 'not authorized');
}
const licenseKeys = await prisma.licenseKey.findMany({
where: { userId: locals.user.id },
orderBy: { key: 'asc' }
});
return { licenseKeys };
};
@@ -0,0 +1,54 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">License Keys</h1>
{#if data.licenseKeys.length > 0}
<div class="overflow-x-auto rounded-lg border border-gray-200">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Key
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Level
</th>
<th class="px-6 py-3 text-left text-xs font-medium uppercase tracking-wider text-gray-500">
Status
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 bg-white">
{#each data.licenseKeys as key}
<tr>
<td class="whitespace-nowrap px-6 py-4 font-mono text-sm text-gray-900">
{key.key}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm text-gray-700">
{key.level}
</td>
<td class="whitespace-nowrap px-6 py-4 text-sm">
<span
class="inline-flex rounded-full px-2 text-xs font-semibold leading-5 {key.active
? 'bg-green-100 text-green-800'
: 'bg-red-100 text-red-800'}"
>
{key.active ? 'Active' : 'Inactive'}
</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500">You don't have any license keys yet.</p>
</div>
{/if}
</div>
@@ -0,0 +1,59 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { createLocals, mockUser, mockLicenseKey } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
licenseKey: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findMany = prisma.licenseKey.findMany as ReturnType<typeof vi.fn>;
describe('(protected)/license-keys page server load', () => {
beforeEach(() => {
findMany.mockReset();
});
it('throws 403 when locals.user is undefined', async () => {
const err = await Promise.resolve(load({ locals: createLocals() } as any)).catch(
(e: unknown) => e
);
expect(isHttpError(err, 403)).toBe(true);
});
it('returns license keys for the authenticated user', async () => {
const keys = [
mockLicenseKey(),
mockLicenseKey({ id: 'key-2', key: 'EEEE-FFFF-GGGG-HHHH', active: false })
];
findMany.mockResolvedValue(keys);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual(keys);
});
it('passes userId in the where clause', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
await load({ locals } as any);
expect(findMany).toHaveBeenCalledWith({
where: { userId: 'user-1' },
orderBy: { key: 'asc' }
});
});
it('returns empty array when user has no keys', async () => {
findMany.mockResolvedValue([]);
const locals = createLocals(mockUser() as any);
const result = (await load({ locals } as any)) as any;
expect(result.licenseKeys).toEqual([]);
});
});
@@ -0,0 +1,38 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
export const load: PageServerLoad = async ({ url }) => {
const search = url.searchParams.get('search') ?? '';
const categoryId = url.searchParams.get('category');
const where: Record<string, unknown> = {};
if (search) {
where.name = { contains: search, mode: 'insensitive' };
}
if (categoryId) {
where.categoryId = parseInt(categoryId, 10);
}
const [proclamations, categories] = await Promise.all([
prisma.proclamation.findMany({
where,
include: {
solfeggioFrequency: true,
category: true
},
orderBy: { name: 'asc' }
}),
prisma.proclamationCategory.findMany({
orderBy: { name: 'asc' }
})
]);
return {
proclamations,
categories,
search,
categoryId: categoryId ? parseInt(categoryId, 10) : null
};
};
@@ -0,0 +1,108 @@
<script lang="ts">
import { goto } from '$app/navigation';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
let search = $state('');
let categoryId: number | null = $state(null);
let initialized = $state(false);
$effect(() => {
if (!initialized) {
search = data.search;
categoryId = data.categoryId;
initialized = true;
}
});
function applyFilters() {
const params = new URLSearchParams();
if (search) params.set('search', search);
if (categoryId) params.set('category', String(categoryId));
goto(`/proclamations?${params.toString()}`, { replaceState: true });
}
function clearFilters() {
search = '';
categoryId = null;
goto('/proclamations', { replaceState: true });
}
</script>
<div class="flex h-full min-h-0 flex-col gap-6 p-6">
<h1 class="text-2xl font-semibold text-gray-900">Proclamations</h1>
<!-- Filters -->
<div class="flex flex-wrap items-end gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700">Search</label>
<input
type="text"
id="search"
bind:value={search}
placeholder="Search by name..."
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
onkeydown={(e) => e.key === 'Enter' && applyFilters()}
/>
</div>
<div>
<label for="category" class="block text-sm font-medium text-gray-700">Category</label>
<select
id="category"
bind:value={categoryId}
onchange={applyFilters}
class="mt-1 rounded-lg border border-gray-300 px-3 py-2 shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500"
>
<option value={null}>All Categories</option>
{#each data.categories as cat}
<option value={cat.id}>{cat.name}</option>
{/each}
</select>
</div>
<button
type="button"
class="rounded-lg bg-blue-600 px-4 py-2 font-medium text-white transition-colors hover:bg-blue-700"
onclick={applyFilters}
>
Search
</button>
{#if data.search || data.categoryId}
<button
type="button"
class="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50"
onclick={clearFilters}
>
Clear
</button>
{/if}
</div>
<!-- Results -->
{#if data.proclamations.length > 0}
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each data.proclamations as proclamation}
<a
href="/proclamations/{proclamation.id}"
class="flex flex-col rounded-lg border border-gray-200 bg-white p-5 shadow-sm transition-shadow hover:shadow-md"
>
<h2 class="text-lg font-medium text-gray-900">{proclamation.name}</h2>
<p class="mt-1 text-sm text-gray-500">{proclamation.category.name}</p>
<div class="mt-auto flex items-center justify-between pt-4">
<span class="text-sm text-purple-600">
{proclamation.solfeggioFrequency.value} Hz &mdash;
{proclamation.solfeggioFrequency.name}
</span>
<span class="font-semibold text-gray-900">
${proclamation.price.toFixed(2)}
</span>
</div>
</a>
{/each}
</div>
{:else}
<div class="flex flex-1 items-center justify-center">
<p class="text-lg text-gray-500">No proclamations found.</p>
</div>
{/if}
</div>
@@ -0,0 +1,21 @@
import type { PageServerLoad } from './$types';
import { prisma } from '$lib/db';
import { error } from '@sveltejs/kit';
export const load: PageServerLoad = async ({ params }) => {
const proclamation = await prisma.proclamation.findUnique({
where: { id: params.id },
include: {
solfeggioFrequency: true,
category: {
include: { parent: true }
}
}
});
if (!proclamation) {
throw error(404, 'Proclamation not found');
}
return { proclamation };
};
@@ -0,0 +1,45 @@
<script lang="ts">
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const categoryBreadcrumb = $derived(
data.proclamation.category.parent
? `${data.proclamation.category.parent.name} / ${data.proclamation.category.name}`
: data.proclamation.category.name
);
</script>
<div class="mx-auto flex h-full min-h-0 w-full max-w-3xl flex-col gap-6 p-6">
<a
href="/proclamations"
class="text-sm text-blue-600 hover:text-blue-800 hover:underline"
>
&larr; Back to Proclamations
</a>
<div>
<h1 class="text-2xl font-semibold text-gray-900">{data.proclamation.name}</h1>
<p class="mt-1 text-sm text-gray-500">{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">
<p class="text-xs font-medium text-purple-600">Frequency</p>
<p class="text-sm font-semibold text-purple-900">
{data.proclamation.solfeggioFrequency.value} Hz &mdash;
{data.proclamation.solfeggioFrequency.name}
</p>
</div>
<div class="rounded-lg border border-gray-200 bg-gray-50 px-4 py-2">
<p class="text-xs font-medium text-gray-500">Price</p>
<p class="text-sm font-semibold text-gray-900">
${data.proclamation.price.toFixed(2)}
</p>
</div>
</div>
<div class="prose max-w-none rounded-lg border border-gray-200 bg-white p-6">
{@html data.proclamation.content}
</div>
</div>
@@ -0,0 +1,66 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { isHttpError } from '@sveltejs/kit';
import { mockProclamation } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: { findUnique: vi.fn() }
}
}));
import { prisma } from '$lib/db';
import { load } from './+page.server';
const findUnique = prisma.proclamation.findUnique as ReturnType<typeof vi.fn>;
describe('(protected)/proclamations/[id] page server load', () => {
beforeEach(() => {
findUnique.mockReset();
});
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);
expect(isHttpError(err, 404)).toBe(true);
});
it('returns proclamation when found', async () => {
const proclamation = mockProclamation();
findUnique.mockResolvedValue(proclamation);
const result = (await load({ params: { id: 'proc-1' } } as any)) as any;
expect(result.proclamation).toEqual(proclamation);
});
it('queries by params.id with frequency and category (including parent)', async () => {
findUnique.mockResolvedValue(mockProclamation());
await load({ params: { id: 'proc-1' } } as any);
expect(findUnique).toHaveBeenCalledWith({
where: { id: 'proc-1' },
include: {
solfeggioFrequency: true,
category: { include: { parent: true } }
}
});
});
it('returns proclamation with parent category when present', async () => {
const proclamation = mockProclamation({
category: {
id: 2,
name: 'Inner Healing',
parentId: 1,
parent: { id: 1, name: 'Healing', parentId: null }
}
});
findUnique.mockResolvedValue(proclamation);
const result = (await load({ params: { id: 'proc-1' } } as any)) as any;
expect(result.proclamation.category.parent.name).toBe('Healing');
});
});
@@ -0,0 +1,113 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mockProclamation, mockCategory, createUrl } from '$lib/test-utils';
vi.mock('$lib/db', () => ({
prisma: {
proclamation: { findMany: vi.fn() },
proclamationCategory: { findMany: vi.fn() }
}
}));
import { prisma } from '$lib/db';
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)/proclamations page server load', () => {
beforeEach(() => {
proclamationFindMany.mockReset();
categoryFindMany.mockReset();
});
it('returns proclamations and categories with no filters', async () => {
const proclamations = [mockProclamation()];
const categories = [mockCategory()];
proclamationFindMany.mockResolvedValue(proclamations);
categoryFindMany.mockResolvedValue(categories);
const url = createUrl('/proclamations');
const result = (await load({ url } as any)) as any;
expect(result.proclamations).toEqual(proclamations);
expect(result.categories).toEqual(categories);
expect(result.search).toBe('');
expect(result.categoryId).toBeNull();
});
it('passes search filter to findMany when search param is present', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'healing' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { name: { contains: 'healing', mode: 'insensitive' } }
})
);
});
it('returns search string from URL params', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'peace' });
const result = (await load({ url } as any)) as any;
expect(result.search).toBe('peace');
});
it('passes categoryId filter when category param is present', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { category: '3' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: { categoryId: 3 }
})
);
});
it('returns parsed categoryId from URL params', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { category: '5' });
const result = (await load({ url } as any)) as any;
expect(result.categoryId).toBe(5);
});
it('combines search and category filters', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations', { search: 'love', category: '2' });
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
where: {
name: { contains: 'love', mode: 'insensitive' },
categoryId: 2
}
})
);
});
it('includes solfeggioFrequency and category relations', async () => {
proclamationFindMany.mockResolvedValue([]);
categoryFindMany.mockResolvedValue([]);
const url = createUrl('/proclamations');
await load({ url } as any);
expect(proclamationFindMany).toHaveBeenCalledWith(
expect.objectContaining({
include: { solfeggioFrequency: true, category: true }
})
);
});
});