Rebuild admin pages around DataTable/AdminModal; restructure categories/groups and v2 groups API
Remove the generic $lib/svelte-admin CRUD layer and AdminTable.svelte; each admin route now owns its own form/modal logic. Add DataTable.svelte, superform-errors.ts, and profile-specific modal/field components. Flatten proclamation categories/groups and drop subscription levels; add the v2 groups API; update v1/v2 endpoints, serializers, auth, and regenerated Prisma client with the new migrations. Document the overhaul plan in docs/ADMIN_OVERHAUL.md.
This commit is contained in:
@@ -1,49 +1,146 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import {
|
||||
loadRecords,
|
||||
upsertRecord,
|
||||
deleteRecord,
|
||||
superFormErrorsToFieldErrors
|
||||
} from '$lib/svelte-admin/crud';
|
||||
import { prisma } from '$lib/db';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import { superValidate } from 'sveltekit-superforms/server';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import * as Schemas from '$lib/svelte-admin/schemas';
|
||||
import { z } from 'zod';
|
||||
|
||||
const config = getModelConfig('profiles')!;
|
||||
import { superFormErrorsToFieldErrors } from '$lib/superform-errors';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals }) => {
|
||||
return loadRecords(locals, config);
|
||||
const email = z.string().email('Invalid email address');
|
||||
const phone = z
|
||||
.string()
|
||||
.min(10, 'Phone number must be at least 10 digits')
|
||||
.regex(/^[\d\-\+\(\) ]+$/, 'Invalid phone number format');
|
||||
const name = z.string().min(1, 'Required').max(100, 'Too long');
|
||||
|
||||
const ProfileSchema = z.object({
|
||||
email,
|
||||
firstName: name,
|
||||
lastName: name,
|
||||
phoneNumber: phone
|
||||
});
|
||||
|
||||
const AddressSchema = z.object({
|
||||
profileId: z.string().min(1),
|
||||
street: z.string().min(1, 'Street is required'),
|
||||
city: z.string().min(1, 'City is required'),
|
||||
state: z.string().min(1, 'State is required'),
|
||||
postalCode: z.string().min(1, 'Postal code is required'),
|
||||
country: z.string().min(1, 'Country is required'),
|
||||
type: z.enum(['Mailing', 'Billing', 'Shipping']).default('Mailing'),
|
||||
isDefaultMailing: z.coerce.boolean().default(false),
|
||||
isDefaultBilling: z.coerce.boolean().default(false),
|
||||
isDefaultShipping: z.coerce.boolean().default(false)
|
||||
});
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
const records = await prisma.profile.findMany({
|
||||
include: { user: true, addresses: true },
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
|
||||
return {
|
||||
records: records as unknown as Record<string, unknown>[]
|
||||
};
|
||||
};
|
||||
|
||||
export const actions = {
|
||||
upsert: async ({ request, locals }) => {
|
||||
return upsertRecord(request, config, locals);
|
||||
},
|
||||
delete: async ({ request, locals }) => {
|
||||
return deleteRecord(request, config, locals);
|
||||
},
|
||||
upsertAddress: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const form = await superValidate(request, zod4(Schemas.Address));
|
||||
upsert: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const form = await superValidate(formData, zod4(ProfileSchema));
|
||||
if (!form.valid) {
|
||||
const { error: addrError, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
const { error: formError, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { success: false, addressError: addrError, addressFieldErrors: fieldErrors };
|
||||
return { success: false, action: 'upsert', error: formError, fieldErrors };
|
||||
}
|
||||
|
||||
const rawId = formData.get('id')?.toString();
|
||||
const data = {
|
||||
email: form.data.email,
|
||||
firstName: form.data.firstName,
|
||||
lastName: form.data.lastName,
|
||||
phoneNumber: form.data.phoneNumber
|
||||
};
|
||||
|
||||
if (rawId) {
|
||||
const existing = await prisma.profile.findUnique({
|
||||
where: { id: rawId },
|
||||
include: { user: true }
|
||||
});
|
||||
if (!existing) {
|
||||
return { success: false, action: 'upsert', error: 'Record not found' };
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { ...data };
|
||||
|
||||
if (existing.userId) {
|
||||
if (existing.user?.email !== data.email) {
|
||||
const conflict = await prisma.user.findFirst({
|
||||
where: { email: data.email, id: { not: existing.userId } }
|
||||
});
|
||||
if (conflict) {
|
||||
return {
|
||||
success: false,
|
||||
action: 'upsert',
|
||||
error: 'That email already belongs to another user.'
|
||||
};
|
||||
}
|
||||
await prisma.user.update({ where: { id: existing.userId }, data: { email: data.email } });
|
||||
}
|
||||
} else {
|
||||
const match = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
if (match) updateData.userId = match.id;
|
||||
}
|
||||
|
||||
await prisma.profile.update({ where: { id: rawId }, data: updateData });
|
||||
return { success: true, action: 'upsert' };
|
||||
}
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({ where: { email: data.email } });
|
||||
await prisma.profile.create({ data: { ...data, ...(user ? { userId: user.id } : {}) } });
|
||||
} catch (e: unknown) {
|
||||
if (e && typeof e === 'object' && 'code' in e && (e as { code: string }).code === 'P2002') {
|
||||
return {
|
||||
success: false,
|
||||
action: 'upsert',
|
||||
error: 'A profile for this email already exists.'
|
||||
};
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return { success: true, action: 'upsert' };
|
||||
},
|
||||
delete: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const rawId = formData.get('id')?.toString();
|
||||
if (!rawId) return { success: false, error: 'Missing id' };
|
||||
await prisma.profile.delete({ where: { id: rawId } });
|
||||
return { success: true, action: 'delete' };
|
||||
},
|
||||
upsertAddress: async ({ request }) => {
|
||||
const formData = await request.formData();
|
||||
const form = await superValidate(formData, zod4(AddressSchema));
|
||||
if (!form.valid) {
|
||||
const { error: addressError, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return {
|
||||
success: false,
|
||||
action: 'upsertAddress',
|
||||
addressError,
|
||||
addressFieldErrors: fieldErrors
|
||||
};
|
||||
}
|
||||
|
||||
const { profileId, isDefaultMailing, isDefaultBilling, isDefaultShipping, ...addressData } =
|
||||
form.data;
|
||||
|
||||
if (!profileId) {
|
||||
return { success: false, addressError: 'Missing profile ID' };
|
||||
return { success: false, action: 'upsertAddress', addressError: 'Missing profile ID' };
|
||||
}
|
||||
|
||||
// Unique-default enforcement: clear defaults of same type before setting
|
||||
if (isDefaultMailing) {
|
||||
await prisma.address.updateMany({
|
||||
where: { profileId, isDefaultMailing: true },
|
||||
@@ -72,32 +169,6 @@ export const actions = {
|
||||
};
|
||||
|
||||
const created = await prisma.address.create({ data: payload });
|
||||
return { success: true, address: created };
|
||||
},
|
||||
updateUser: async ({ request, locals }) => {
|
||||
if (!locals.user) throw error(401, 'Unauthorized');
|
||||
|
||||
const form = await superValidate(request, zod4(Schemas.UserUpdate));
|
||||
if (!form.valid) {
|
||||
const { error: userErr, fieldErrors } = superFormErrorsToFieldErrors(
|
||||
form.errors as Record<string, unknown>
|
||||
);
|
||||
return { success: false, userError: userErr, userFieldErrors: fieldErrors };
|
||||
}
|
||||
|
||||
const { id, email, name } = form.data;
|
||||
if (!id) {
|
||||
return { success: false, userError: 'Missing user ID' };
|
||||
}
|
||||
|
||||
const data: Record<string, string> = {};
|
||||
if (email) data.email = email;
|
||||
if (name) data.name = name;
|
||||
if (Object.keys(data).length === 0) {
|
||||
return { success: false, userError: 'No fields to update' };
|
||||
}
|
||||
|
||||
await prisma.user.update({ where: { id }, data });
|
||||
return { success: true };
|
||||
return { success: true, action: 'upsertAddress', address: created };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,487 +1,59 @@
|
||||
<script lang="ts">
|
||||
import type { PageProps } from './$types';
|
||||
import type { ActionResult } from '$lib/svelte-admin/types';
|
||||
import AdminTable from '$lib/components/admin/AdminTable.svelte';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import Toast from '$lib/components/admin/Toast.svelte';
|
||||
import { enhance } from '$app/forms';
|
||||
import { getModelConfig } from '$lib/svelte-admin/registry';
|
||||
import DataTable, { type DataTableColumn } from '$lib/components/DataTable.svelte';
|
||||
import CreateProfileModal from './CreateProfileModal.svelte';
|
||||
import EditProfileModal from './EditProfileModal.svelte';
|
||||
|
||||
let { data, form }: PageProps = $props();
|
||||
|
||||
const config = getModelConfig('profiles')!;
|
||||
let canDelete = $derived((data.adminUser?.roleId ?? 4) <= 2);
|
||||
|
||||
let modalOpen = $state(false);
|
||||
let editingRecord = $state<Record<string, unknown> | null>(null);
|
||||
let toastMessage = $state('');
|
||||
let toastType = $state<'success' | 'error'>('success');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let formError = $state('');
|
||||
let addressFormOpen = $state(false);
|
||||
let userFormOpen = $state(false);
|
||||
let addressError = $state('');
|
||||
let userError = $state('');
|
||||
const columns: DataTableColumn[] = [
|
||||
{ label: 'First Name', value: (r) => r.firstName },
|
||||
{ label: 'Last Name', value: (r) => r.lastName },
|
||||
{
|
||||
label: 'Email',
|
||||
value: (r) => (r.user as Record<string, unknown> | undefined)?.email ?? r.email ?? ''
|
||||
},
|
||||
{ label: 'Phone', value: (r) => r.phoneNumber },
|
||||
{ label: 'User', value: (r) => (r.user as Record<string, unknown> | undefined)?.name ?? '' }
|
||||
];
|
||||
|
||||
let addressFields = $state<Record<string, string>>({
|
||||
street: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
country: '',
|
||||
isDefaultMailing: 'false',
|
||||
isDefaultBilling: 'false',
|
||||
isDefaultShipping: 'false'
|
||||
});
|
||||
|
||||
let userEmail = $state('');
|
||||
let userName = $state('');
|
||||
|
||||
$effect(() => {
|
||||
if (form) {
|
||||
if ((form as any).addressError) {
|
||||
addressError = (form as any).addressError;
|
||||
} else if ((form as any).userError) {
|
||||
userError = (form as any).userError;
|
||||
} else if (form.success) {
|
||||
toastType = 'success';
|
||||
if ((form as any).address) {
|
||||
toastMessage = 'Address saved';
|
||||
const addr = (form as any).address as Record<string, unknown>;
|
||||
if (editingRecord) {
|
||||
const addrs = (editingRecord.addresses as Record<string, unknown>[]) ?? [];
|
||||
editingRecord = {
|
||||
...editingRecord,
|
||||
addresses: [...addrs, addr]
|
||||
};
|
||||
}
|
||||
addressFormOpen = false;
|
||||
addressError = '';
|
||||
} else {
|
||||
toastMessage = 'Saved successfully';
|
||||
closeModal();
|
||||
}
|
||||
} else if (!(form as any).addressError && !(form as any).userError) {
|
||||
formError = (form as ActionResult).error ?? '';
|
||||
fieldErrors = (form as ActionResult).fieldErrors ?? {};
|
||||
}
|
||||
}
|
||||
});
|
||||
let createOpen = $state(false);
|
||||
let editRecord = $state<Record<string, unknown> | null>(null);
|
||||
|
||||
function openCreate() {
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
addressFormOpen = false;
|
||||
userFormOpen = false;
|
||||
modalOpen = true;
|
||||
createOpen = true;
|
||||
}
|
||||
|
||||
function openEdit(record: Record<string, unknown>) {
|
||||
editingRecord = record;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
addressFormOpen = false;
|
||||
userFormOpen = false;
|
||||
addressError = '';
|
||||
userError = '';
|
||||
userEmail = ((record.user as Record<string, unknown>)?.email as string) ?? '';
|
||||
userName = ((record.user as Record<string, unknown>)?.name as string) ?? '';
|
||||
modalOpen = true;
|
||||
editRecord = record;
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
modalOpen = false;
|
||||
editingRecord = null;
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
addressFormOpen = false;
|
||||
userFormOpen = false;
|
||||
addressError = '';
|
||||
userError = '';
|
||||
function closeCreate() {
|
||||
createOpen = false;
|
||||
}
|
||||
|
||||
function toggleAddressForm() {
|
||||
addressFormOpen = !addressFormOpen;
|
||||
addressError = '';
|
||||
}
|
||||
|
||||
function toggleUserForm() {
|
||||
userFormOpen = !userFormOpen;
|
||||
userError = '';
|
||||
function closeEdit() {
|
||||
editRecord = null;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="p-6">
|
||||
<h1 class="mb-4 text-xl font-bold text-gray-900 dark:text-gray-100">{config.displayName}</h1>
|
||||
<AdminTable
|
||||
<div>
|
||||
<DataTable
|
||||
title="User Profiles"
|
||||
{columns}
|
||||
records={data.records}
|
||||
listFields={config.listFields}
|
||||
searchFields={config.searchFields}
|
||||
fieldTypes={config.fieldTypes}
|
||||
singular={config.displayNameSingular}
|
||||
newLabel="New Profile"
|
||||
searchPlaceholder="Search profiles..."
|
||||
onRowClick={openEdit}
|
||||
onNew={openCreate}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AdminModal
|
||||
title={editingRecord
|
||||
? `Edit ${config.displayNameSingular}`
|
||||
: `Create ${config.displayNameSingular}`}
|
||||
open={modalOpen}
|
||||
onClose={closeModal}
|
||||
{canDelete}
|
||||
recordId={editingRecord?.id as string | undefined}
|
||||
>
|
||||
<form method="POST" action="?/upsert" use:enhance>
|
||||
{#if editingRecord?.id}
|
||||
<input type="hidden" name="id" value={String(editingRecord.id)} />
|
||||
{/if}
|
||||
{#if formError}
|
||||
<div class="mb-4 border border-red-300 bg-red-50 px-4 py-2 text-sm text-red-700">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label
|
||||
for="field-userId"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
User
|
||||
</label>
|
||||
<select
|
||||
id="field-userId"
|
||||
name="userId"
|
||||
value={String(editingRecord?.userId ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.userId
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="">-- Select User --</option>
|
||||
{#each data.relationOptionsData.userId ?? [] as opt (String(opt.value))}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{#if fieldErrors.userId}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.userId}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="field-firstName"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
First Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="field-firstName"
|
||||
name="firstName"
|
||||
value={String(editingRecord?.firstName ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.firstName
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{#if fieldErrors.firstName}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.firstName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="field-lastName"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Last Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="field-lastName"
|
||||
name="lastName"
|
||||
value={String(editingRecord?.lastName ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.lastName
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{#if fieldErrors.lastName}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.lastName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="field-phoneNumber"
|
||||
class="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300"
|
||||
>
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="field-phoneNumber"
|
||||
name="phoneNumber"
|
||||
value={String(editingRecord?.phoneNumber ?? '')}
|
||||
class="w-full border px-3 py-1.5 text-sm {fieldErrors.phoneNumber
|
||||
? 'border-red-400'
|
||||
: 'border-gray-300 dark:border-gray-600'} dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
{#if fieldErrors.phoneNumber}
|
||||
<p class="mt-1 text-xs text-red-600">{fieldErrors.phoneNumber}</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-4 py-1.5 text-sm font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{editingRecord ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<CreateProfileModal open={createOpen} onClose={closeCreate} {form} />
|
||||
|
||||
{#if editingRecord}
|
||||
{@const user = editingRecord.user as Record<string, unknown> | undefined}
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">User</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleUserForm}
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{userFormOpen ? 'Cancel' : 'Edit User'}
|
||||
</button>
|
||||
</div>
|
||||
<p class="mb-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{(user?.name as string) ?? ''} ({(user?.email as string) ?? ''})
|
||||
</p>
|
||||
|
||||
{#if userFormOpen}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/updateUser"
|
||||
use:enhance
|
||||
class="mt-3 rounded border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<input type="hidden" name="id" value={String(user?.id ?? '')} />
|
||||
{#if userError}
|
||||
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
|
||||
{userError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>
|
||||
<label
|
||||
for="user-name"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="user-name"
|
||||
name="name"
|
||||
value={userName}
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="user-email"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="user-email"
|
||||
name="email"
|
||||
value={userEmail}
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Save User
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if editingRecord}
|
||||
{@const addresses = (editingRecord.addresses as Record<string, unknown>[]) ?? []}
|
||||
<div class="mt-6 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||||
<div class="mb-2 flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-gray-700 dark:text-gray-300">Addresses</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleAddressForm}
|
||||
class="text-xs font-medium text-blue-600 hover:text-blue-800 dark:text-blue-400"
|
||||
>
|
||||
{addressFormOpen ? 'Cancel' : 'Add Address +'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addressFormOpen}
|
||||
<form
|
||||
method="POST"
|
||||
action="?/upsertAddress"
|
||||
use:enhance
|
||||
class="mb-3 rounded border border-gray-200 p-3 dark:border-gray-700"
|
||||
>
|
||||
<input type="hidden" name="profileId" value={String(editingRecord.id)} />
|
||||
{#if addressError}
|
||||
<div class="mb-2 border border-red-300 bg-red-50 px-3 py-1.5 text-sm text-red-700">
|
||||
{addressError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<div>
|
||||
<label
|
||||
for="addr-street"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Street
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-street"
|
||||
name="street"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-city"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
City
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-city"
|
||||
name="city"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-state"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
State
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-state"
|
||||
name="state"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-postal"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Postal Code
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-postal"
|
||||
name="postalCode"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label
|
||||
for="addr-country"
|
||||
class="mb-0.5 block text-xs font-medium text-gray-600 dark:text-gray-400"
|
||||
>
|
||||
Country
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="addr-country"
|
||||
name="country"
|
||||
required
|
||||
class="w-full border border-gray-300 px-2 py-1 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap gap-4">
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" name="isDefaultMailing" class="h-3.5 w-3.5" />
|
||||
Default Mailing
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" name="isDefaultBilling" class="h-3.5 w-3.5" />
|
||||
Default Billing
|
||||
</label>
|
||||
<label class="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" name="isDefaultShipping" class="h-3.5 w-3.5" />
|
||||
Default Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div class="mt-2 flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="border border-gray-300 bg-gray-100 px-3 py-1 text-xs font-medium hover:bg-gray-200 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if addresses.length > 0}
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Street</th
|
||||
>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">City</th>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">State</th>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase">Zip</th>
|
||||
<th class="px-2 py-1 text-left text-xs font-medium text-gray-500 uppercase"
|
||||
>Country</th
|
||||
>
|
||||
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">M</th>
|
||||
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">B</th>
|
||||
<th class="px-2 py-1 text-center text-xs font-medium text-gray-500 uppercase">S</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each addresses as addr}
|
||||
<tr class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="px-2 py-1">{addr.street as string}</td>
|
||||
<td class="px-2 py-1">{addr.city as string}</td>
|
||||
<td class="px-2 py-1">{addr.state as string}</td>
|
||||
<td class="px-2 py-1">{addr.postalCode as string}</td>
|
||||
<td class="px-2 py-1">{addr.country as string}</td>
|
||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultMailing ? '✓' : ''}</td>
|
||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultBilling ? '✓' : ''}</td>
|
||||
<td class="px-2 py-1 text-center text-xs">{addr.isDefaultShipping ? '✓' : ''}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<p class="text-sm text-gray-500">No addresses on file.</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</AdminModal>
|
||||
|
||||
{#if toastMessage}
|
||||
<Toast message={toastMessage} type={toastType} />
|
||||
{#if editRecord}
|
||||
{#key editRecord.id}
|
||||
<EditProfileModal record={editRecord} open={true} onClose={closeEdit} {canDelete} {form} />
|
||||
{/key}
|
||||
{/if}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import ProfileFields from './ProfileFields.svelte';
|
||||
|
||||
type ActionResult = {
|
||||
success?: boolean;
|
||||
action?: string;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
} | null;
|
||||
|
||||
let {
|
||||
open = false,
|
||||
onClose,
|
||||
form
|
||||
}: {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
form?: ActionResult;
|
||||
} = $props();
|
||||
|
||||
let formError = $state('');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let successMessage = $state('');
|
||||
let lastForm = $state<ActionResult>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
successMessage = '';
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!form || form === lastForm) return;
|
||||
lastForm = form;
|
||||
|
||||
if (form.success) {
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
successMessage = 'Profile created';
|
||||
setTimeout(() => onClose?.(), 1200);
|
||||
} else {
|
||||
successMessage = '';
|
||||
formError = form.error ?? '';
|
||||
fieldErrors = form.fieldErrors ?? {};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<AdminModal title="Create Profile" {open} {onClose}>
|
||||
{#if successMessage}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
|
||||
>
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
{#if formError}
|
||||
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/upsert" use:enhance>
|
||||
<ProfileFields record={null} {fieldErrors} />
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
|
||||
>
|
||||
Create Profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</AdminModal>
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import AdminModal from '$lib/components/admin/AdminModal.svelte';
|
||||
import ProfileFields from './ProfileFields.svelte';
|
||||
|
||||
type ActionResult = {
|
||||
success?: boolean;
|
||||
action?: string;
|
||||
error?: string;
|
||||
fieldErrors?: Record<string, string>;
|
||||
addressError?: string;
|
||||
address?: Record<string, unknown>;
|
||||
} | null;
|
||||
|
||||
let {
|
||||
record,
|
||||
open = false,
|
||||
onClose,
|
||||
canDelete = false,
|
||||
form
|
||||
}: {
|
||||
record: Record<string, unknown>;
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
canDelete?: boolean;
|
||||
form?: ActionResult;
|
||||
} = $props();
|
||||
|
||||
let formError = $state('');
|
||||
let fieldErrors = $state<Record<string, string>>({});
|
||||
let addressError = $state('');
|
||||
let successMessage = $state('');
|
||||
let addressFormOpen = $state(false);
|
||||
let addresses = $state<Record<string, unknown>[]>([]);
|
||||
|
||||
let addrStreet = $state('');
|
||||
let addrCity = $state('');
|
||||
let addrState = $state('');
|
||||
let addrPostal = $state('');
|
||||
let addrCountry = $state('');
|
||||
let addrMailing = $state(false);
|
||||
let addrBilling = $state(false);
|
||||
let addrShipping = $state(false);
|
||||
|
||||
let lastRecordId = $state<string | null>(null);
|
||||
let lastForm = $state<ActionResult>(null);
|
||||
|
||||
function resetAddressFields() {
|
||||
addrStreet = '';
|
||||
addrCity = '';
|
||||
addrState = '';
|
||||
addrPostal = '';
|
||||
addrCountry = '';
|
||||
addrMailing = false;
|
||||
addrBilling = false;
|
||||
addrShipping = false;
|
||||
}
|
||||
|
||||
function seedRecord() {
|
||||
addresses = (record.addresses as Record<string, unknown>[]) ?? [];
|
||||
addressFormOpen = false;
|
||||
addressError = '';
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
successMessage = '';
|
||||
resetAddressFields();
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (open) {
|
||||
seedRecord();
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
const id = String(record.id ?? '');
|
||||
if (id === lastRecordId) return;
|
||||
lastRecordId = id;
|
||||
seedRecord();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!form || form === lastForm) return;
|
||||
lastForm = form;
|
||||
|
||||
switch (form.action) {
|
||||
case 'upsertAddress':
|
||||
if (form.success && form.address) {
|
||||
addresses = [...addresses, form.address];
|
||||
addressFormOpen = false;
|
||||
addressError = '';
|
||||
successMessage = 'Address saved';
|
||||
resetAddressFields();
|
||||
} else {
|
||||
successMessage = '';
|
||||
addressError = form.addressError ?? '';
|
||||
}
|
||||
break;
|
||||
case 'upsert':
|
||||
default:
|
||||
if (form.success) {
|
||||
formError = '';
|
||||
fieldErrors = {};
|
||||
successMessage = 'Profile updated';
|
||||
} else {
|
||||
successMessage = '';
|
||||
formError = form.error ?? '';
|
||||
fieldErrors = form.fieldErrors ?? {};
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
function toggleAddressForm() {
|
||||
addressFormOpen = !addressFormOpen;
|
||||
addressError = '';
|
||||
if (addressFormOpen) resetAddressFields();
|
||||
}
|
||||
</script>
|
||||
|
||||
<AdminModal title="Edit Profile" {open} {onClose} {canDelete} recordId={String(record.id ?? '')}>
|
||||
{#if successMessage}
|
||||
<div
|
||||
class="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700"
|
||||
>
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
{#if formError}
|
||||
<div class="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{formError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form method="POST" action="?/upsert" use:enhance>
|
||||
<input type="hidden" name="id" value={String(record.id ?? '')} />
|
||||
|
||||
<ProfileFields {record} {fieldErrors} />
|
||||
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onclick={onClose}
|
||||
class="rounded-md border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-indigo-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-indigo-700"
|
||||
>
|
||||
Save Profile
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="mt-8 border-t border-slate-200 pt-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-sm font-semibold text-slate-900">Addresses</h3>
|
||||
<button
|
||||
type="button"
|
||||
onclick={toggleAddressForm}
|
||||
class="rounded-md border border-slate-300 bg-white px-3 py-1.5 text-xs font-medium text-slate-700 transition hover:bg-slate-50"
|
||||
>
|
||||
{addressFormOpen ? 'Cancel' : 'Add Address +'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if addressFormOpen}
|
||||
<form method="POST" action="?/upsertAddress" use:enhance class="mt-4 flex flex-col gap-4">
|
||||
<input type="hidden" name="profileId" value={String(record.id ?? '')} />
|
||||
{#if addressError}
|
||||
<div class="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
{addressError}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<label for="addr-street" class="mb-1 block text-sm font-medium text-slate-700"
|
||||
>Street</label
|
||||
>
|
||||
<input
|
||||
id="addr-street"
|
||||
type="text"
|
||||
name="street"
|
||||
required
|
||||
bind:value={addrStreet}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="addr-city" class="mb-1 block text-sm font-medium text-slate-700">City</label
|
||||
>
|
||||
<input
|
||||
id="addr-city"
|
||||
type="text"
|
||||
name="city"
|
||||
required
|
||||
bind:value={addrCity}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="addr-state" class="mb-1 block text-sm font-medium text-slate-700"
|
||||
>State</label
|
||||
>
|
||||
<input
|
||||
id="addr-state"
|
||||
type="text"
|
||||
name="state"
|
||||
required
|
||||
bind:value={addrState}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="addr-postal" class="mb-1 block text-sm font-medium text-slate-700"
|
||||
>Postal Code</label
|
||||
>
|
||||
<input
|
||||
id="addr-postal"
|
||||
type="text"
|
||||
name="postalCode"
|
||||
required
|
||||
bind:value={addrPostal}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="addr-country" class="mb-1 block text-sm font-medium text-slate-700"
|
||||
>Country</label
|
||||
>
|
||||
<input
|
||||
id="addr-country"
|
||||
type="text"
|
||||
name="country"
|
||||
required
|
||||
bind:value={addrCountry}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-4 text-sm text-slate-700">
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="isDefaultMailing" bind:checked={addrMailing} />
|
||||
Default Mailing
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="isDefaultBilling" bind:checked={addrBilling} />
|
||||
Default Billing
|
||||
</label>
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="isDefaultShipping" bind:checked={addrShipping} />
|
||||
Default Shipping
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
class="rounded-md bg-slate-800 px-4 py-2 text-sm font-medium text-white transition hover:bg-slate-700"
|
||||
>
|
||||
Save Address
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if addresses.length > 0}
|
||||
<div class="mt-4 overflow-x-auto rounded-md border border-slate-200">
|
||||
<table class="w-full min-w-max text-left text-sm text-slate-700">
|
||||
<thead>
|
||||
<tr
|
||||
class="border-b border-slate-200 bg-slate-100 text-xs font-semibold tracking-wide text-slate-500"
|
||||
>
|
||||
<th scope="col" class="px-3 py-2">Street</th>
|
||||
<th scope="col" class="px-3 py-2">City</th>
|
||||
<th scope="col" class="px-3 py-2">State</th>
|
||||
<th scope="col" class="px-3 py-2">Zip</th>
|
||||
<th scope="col" class="px-3 py-2">Country</th>
|
||||
<th scope="col" class="px-3 py-2">M</th>
|
||||
<th scope="col" class="px-3 py-2">B</th>
|
||||
<th scope="col" class="px-3 py-2">S</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each addresses as addr, i (String(addr.id ?? i))}
|
||||
<tr class="bg-white">
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.street as string}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.city as string}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.state as string}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.postalCode as string}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.country as string}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultMailing ? '✓' : ''}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultBilling ? '✓' : ''}</td>
|
||||
<td class="px-3 py-2 whitespace-nowrap">{addr.isDefaultShipping ? '✓' : ''}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="mt-2 text-sm text-slate-600">No addresses on file.</p>
|
||||
{/if}
|
||||
</div>
|
||||
</AdminModal>
|
||||
@@ -0,0 +1,92 @@
|
||||
<script lang="ts">
|
||||
let {
|
||||
record = null,
|
||||
fieldErrors = {} as Record<string, string>
|
||||
}: {
|
||||
record?: Record<string, unknown> | null;
|
||||
fieldErrors?: Record<string, string>;
|
||||
} = $props();
|
||||
|
||||
let email = $state('');
|
||||
let firstName = $state('');
|
||||
let lastName = $state('');
|
||||
let phoneNumber = $state('');
|
||||
|
||||
$effect(() => {
|
||||
const rec = record ?? {};
|
||||
const user = rec.user as Record<string, unknown> | undefined;
|
||||
email = String((user?.email as string) ?? rec.email ?? '');
|
||||
firstName = String(rec.firstName ?? '');
|
||||
lastName = String(rec.lastName ?? '');
|
||||
phoneNumber = String(rec.phoneNumber ?? '');
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
<div>
|
||||
<label for="field-email" class="mb-1 block text-sm font-medium text-slate-700">Email</label>
|
||||
<input
|
||||
id="field-email"
|
||||
type="email"
|
||||
name="email"
|
||||
required
|
||||
bind:value={email}
|
||||
placeholder="user@example.com"
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{#if fieldErrors.email}
|
||||
<p class="mt-1 text-sm text-red-600">{fieldErrors.email}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="field-firstName" class="mb-1 block text-sm font-medium text-slate-700"
|
||||
>First Name</label
|
||||
>
|
||||
<input
|
||||
id="field-firstName"
|
||||
type="text"
|
||||
name="firstName"
|
||||
required
|
||||
bind:value={firstName}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{#if fieldErrors.firstName}
|
||||
<p class="mt-1 text-sm text-red-600">{fieldErrors.firstName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="field-lastName" class="mb-1 block text-sm font-medium text-slate-700"
|
||||
>Last Name</label
|
||||
>
|
||||
<input
|
||||
id="field-lastName"
|
||||
type="text"
|
||||
name="lastName"
|
||||
required
|
||||
bind:value={lastName}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{#if fieldErrors.lastName}
|
||||
<p class="mt-1 text-sm text-red-600">{fieldErrors.lastName}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="field-phoneNumber" class="mb-1 block text-sm font-medium text-slate-700">
|
||||
Phone Number
|
||||
</label>
|
||||
<input
|
||||
id="field-phoneNumber"
|
||||
type="text"
|
||||
name="phoneNumber"
|
||||
required
|
||||
bind:value={phoneNumber}
|
||||
class="w-full rounded-md border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm transition focus:border-indigo-500 focus:outline-none"
|
||||
/>
|
||||
{#if fieldErrors.phoneNumber}
|
||||
<p class="mt-1 text-sm text-red-600">{fieldErrors.phoneNumber}</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,233 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { createLocals, createFormRequest } from '$lib/test-utils';
|
||||
|
||||
vi.mock('$lib/db', () => ({
|
||||
prisma: {
|
||||
profile: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn()
|
||||
},
|
||||
user: {
|
||||
findUnique: vi.fn(),
|
||||
findFirst: vi.fn(),
|
||||
update: vi.fn()
|
||||
}
|
||||
}
|
||||
}));
|
||||
|
||||
import { prisma } from '$lib/db';
|
||||
import { load, actions } from './+page.server';
|
||||
|
||||
const mockProfileFindMany = prisma.profile.findMany as ReturnType<typeof vi.fn>;
|
||||
const mockProfileFindUnique = prisma.profile.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockProfileCreate = prisma.profile.create as ReturnType<typeof vi.fn>;
|
||||
const mockProfileUpdate = prisma.profile.update as ReturnType<typeof vi.fn>;
|
||||
const mockProfileDelete = prisma.profile.delete as ReturnType<typeof vi.fn>;
|
||||
const mockUserFindUnique = prisma.user.findUnique as ReturnType<typeof vi.fn>;
|
||||
const mockUserFindFirst = prisma.user.findFirst as ReturnType<typeof vi.fn>;
|
||||
const mockUserUpdate = prisma.user.update as ReturnType<typeof vi.fn>;
|
||||
|
||||
const validFields = {
|
||||
email: 'jane@example.com',
|
||||
firstName: 'Jane',
|
||||
lastName: 'Doe',
|
||||
phoneNumber: '555-010-1234'
|
||||
};
|
||||
|
||||
function makeProfileRequest(overrides: Record<string, string> = {}) {
|
||||
return createFormRequest({ ...validFields, ...overrides });
|
||||
}
|
||||
|
||||
describe('profiles admin page server', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('load', () => {
|
||||
it('returns profiles with user and addresses', async () => {
|
||||
const records = [
|
||||
{
|
||||
id: 'p1',
|
||||
email: null,
|
||||
user: { email: 'jane@example.com', name: 'Jane Doe' },
|
||||
addresses: []
|
||||
}
|
||||
];
|
||||
mockProfileFindMany.mockResolvedValue(records);
|
||||
|
||||
const result = await load({} as any);
|
||||
|
||||
expect(mockProfileFindMany).toHaveBeenCalledWith({
|
||||
include: { user: true, addresses: true },
|
||||
orderBy: { id: 'asc' }
|
||||
});
|
||||
expect(result).toEqual({ records });
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert - create', () => {
|
||||
it('links the profile to an existing user with the same email', async () => {
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-1', email: validFields.email });
|
||||
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
|
||||
|
||||
const result = await actions.upsert({ request: makeProfileRequest() } as any);
|
||||
|
||||
expect(result).toEqual({ success: true, action: 'upsert' });
|
||||
expect(mockUserFindUnique).toHaveBeenCalledWith({ where: { email: validFields.email } });
|
||||
expect(mockProfileCreate).toHaveBeenCalledWith({
|
||||
data: { ...validFields, userId: 'user-1' }
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the email on the profile when no user matches', async () => {
|
||||
mockUserFindUnique.mockResolvedValue(null);
|
||||
mockProfileCreate.mockResolvedValue({ id: 'p-new' });
|
||||
|
||||
const result = await actions.upsert({ request: makeProfileRequest() } as any);
|
||||
|
||||
expect(result).toEqual({ success: true, action: 'upsert' });
|
||||
expect(mockProfileCreate).toHaveBeenCalledWith({ data: validFields });
|
||||
});
|
||||
|
||||
it('returns a friendly error when the email already exists (P2002)', async () => {
|
||||
mockUserFindUnique.mockResolvedValue(null);
|
||||
mockProfileCreate.mockRejectedValue({ code: 'P2002' });
|
||||
|
||||
const result = await actions.upsert({ request: makeProfileRequest() } as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
action: 'upsert',
|
||||
error: 'A profile for this email already exists.'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert - update', () => {
|
||||
it('syncs the linked user email when it changes', async () => {
|
||||
mockProfileFindUnique.mockResolvedValue({
|
||||
id: 'p1',
|
||||
userId: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
user: { email: 'old@example.com' }
|
||||
});
|
||||
mockUserFindFirst.mockResolvedValue(null);
|
||||
mockUserUpdate.mockResolvedValue({});
|
||||
mockProfileUpdate.mockResolvedValue({});
|
||||
|
||||
const request = makeProfileRequest({ email: 'new@example.com', id: 'p1' });
|
||||
const result = await actions.upsert({ request } as any);
|
||||
|
||||
expect(result).toEqual({ success: true, action: 'upsert' });
|
||||
expect(mockUserUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'user-1' },
|
||||
data: { email: 'new@example.com' }
|
||||
});
|
||||
expect(mockProfileUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'p1' },
|
||||
data: { ...validFields, email: 'new@example.com' }
|
||||
});
|
||||
});
|
||||
|
||||
it('does not touch the user when the email is unchanged', async () => {
|
||||
mockProfileFindUnique.mockResolvedValue({
|
||||
id: 'p1',
|
||||
userId: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
user: { email: 'jane@example.com' }
|
||||
});
|
||||
mockProfileUpdate.mockResolvedValue({});
|
||||
|
||||
const request = makeProfileRequest({ id: 'p1' });
|
||||
const result = await actions.upsert({ request } as any);
|
||||
|
||||
expect(result).toEqual({ success: true, action: 'upsert' });
|
||||
expect(mockUserFindFirst).not.toHaveBeenCalled();
|
||||
expect(mockUserUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a change to an email owned by another user', async () => {
|
||||
mockProfileFindUnique.mockResolvedValue({
|
||||
id: 'p1',
|
||||
userId: 'user-1',
|
||||
email: 'jane@example.com',
|
||||
user: { email: 'old@example.com' }
|
||||
});
|
||||
mockUserFindFirst.mockResolvedValue({ id: 'user-2' });
|
||||
|
||||
const request = makeProfileRequest({ email: 'taken@example.com', id: 'p1' });
|
||||
const result = await actions.upsert({ request } as any);
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
action: 'upsert',
|
||||
error: 'That email already belongs to another user.'
|
||||
});
|
||||
expect(mockUserUpdate).not.toHaveBeenCalled();
|
||||
expect(mockProfileUpdate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('links the unlinked profile when a user matches the new email', async () => {
|
||||
mockProfileFindUnique.mockResolvedValue({
|
||||
id: 'p1',
|
||||
userId: null,
|
||||
email: 'jane@example.com',
|
||||
user: null
|
||||
});
|
||||
mockUserFindUnique.mockResolvedValue({ id: 'user-9' });
|
||||
mockProfileUpdate.mockResolvedValue({});
|
||||
|
||||
const request = makeProfileRequest({ id: 'p1' });
|
||||
const result = await actions.upsert({ request } as any);
|
||||
|
||||
expect(result).toEqual({ success: true, action: 'upsert' });
|
||||
expect(mockProfileUpdate).toHaveBeenCalledWith({
|
||||
where: { id: 'p1' },
|
||||
data: { ...validFields, userId: 'user-9' }
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert - validation', () => {
|
||||
it('returns field errors for invalid input', async () => {
|
||||
const request = makeProfileRequest({ email: 'not-an-email' });
|
||||
const result = await actions.upsert({ request } as any);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.action).toBe('upsert');
|
||||
expect((result as { fieldErrors: Record<string, string> }).fieldErrors.email).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns a record-not-found error for a missing profile', async () => {
|
||||
mockProfileFindUnique.mockResolvedValue(null);
|
||||
|
||||
const request = makeProfileRequest({ id: 'missing' });
|
||||
const result = await actions.upsert({ request } as any);
|
||||
|
||||
expect(result).toEqual({ success: false, action: 'upsert', error: 'Record not found' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('deletes the profile by id', async () => {
|
||||
mockProfileDelete.mockResolvedValue({});
|
||||
|
||||
const locals = createLocals();
|
||||
const req = createFormRequest({ id: 'p1' });
|
||||
const result = await actions.delete({ request: req, locals } as any);
|
||||
|
||||
expect(result).toEqual({ success: true, action: 'delete' });
|
||||
expect(mockProfileDelete).toHaveBeenCalledWith({ where: { id: 'p1' } });
|
||||
});
|
||||
|
||||
it('returns an error when id is missing', async () => {
|
||||
const locals = createLocals();
|
||||
const result = await actions.delete({ request: createFormRequest({}), locals } as any);
|
||||
|
||||
expect(result).toEqual({ success: false, error: 'Missing id' });
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user