diff --git a/prisma/migrations/20260707181820_profiles_address_type_default/migration.sql b/prisma/migrations/20260707181820_profiles_address_type_default/migration.sql new file mode 100644 index 0000000..3b207d4 --- /dev/null +++ b/prisma/migrations/20260707181820_profiles_address_type_default/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "Address" ADD COLUMN "isDefault" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "type" TEXT NOT NULL DEFAULT 'Mailing'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 56ca350..44cd473 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -55,6 +55,8 @@ model Address { state String postalCode String country String + type String @default("Mailing") + isDefault Boolean @default(false) } model Session { @@ -119,7 +121,6 @@ model Proclamation { solfeggioFrequency SolfeggioFrequency @relation(fields: [solfeggioFrequencyId], references: [id]) categoryId Int category ProclamationCategory @relation(fields: [categoryId], references: [id]) - price Float @default(0.99) userProclamations UserProclamations[] subscriptionLevelId Int @default(1) subscriptionLevel SubscriptionLevel @relation(fields: [subscriptionLevelId], references: [id]) @@ -141,7 +142,7 @@ model UserProclamations { model SolfeggioFrequency { id Int @id @default(autoincrement()) name String @unique - value Float @unique + value Int @unique proclamations Proclamation[] } diff --git a/src/lib/components/admin/AdminForm.svelte b/src/lib/components/admin/AdminForm.svelte new file mode 100644 index 0000000..ef527bf --- /dev/null +++ b/src/lib/components/admin/AdminForm.svelte @@ -0,0 +1,327 @@ + + +
+ {#if record?.id} + + {/if} + + {#if formError} +
+ {formError} +
+ {/if} + +
+ {#each fields as field (field)} + {@const type = fieldTypes[field] ?? 'String'} + {@const ro = readonlyFields.includes(field)} + {@const rel = relationOptions[field]} + {@const value = record?.[field]} + {@const err = fieldErrors[field]} + {@const isCascadeParentField = isCascadeParent(field)} + {@const isChildField = getChildFieldName(field) != null} + + {#if isCascadeParentField} + {@const childField = getChildFieldName(field)!} + {@const childVal = record?.[childField]} + {@const childErr = fieldErrors[childField]} +
+ + + {#if err} +

{err}

+ {/if} +
+
+ + + {#if childErr} +

{childErr}

+ {/if} +
+ {:else if isChildField} + + {:else if ro} +
+ +

+ {formatReadonly(value, type)} +

+
+ {:else if type === 'Tiptap'} +
+ +
+ +
+ {#if err} +

{err}

+ {/if} +
+ {:else if rel} +
+ + + {#if err} +

{err}

+ {/if} +
+ {:else if type === 'Boolean'} +
+ + +
+ {:else if type === 'DateTime'} +
+ + + {#if err} +

{err}

+ {/if} +
+ {:else if type === 'Int' || type === 'Float'} +
+ + + {#if err} +

{err}

+ {/if} +
+ {:else} +
+ + + {#if err} +

{err}

+ {/if} +
+ {/if} + {/each} + +
+ +
+
+
diff --git a/src/lib/components/admin/AdminModal.svelte b/src/lib/components/admin/AdminModal.svelte new file mode 100644 index 0000000..4c4e06e --- /dev/null +++ b/src/lib/components/admin/AdminModal.svelte @@ -0,0 +1,82 @@ + + +{#if open} + +{/if} diff --git a/src/lib/components/admin/AdminPage.svelte b/src/lib/components/admin/AdminPage.svelte new file mode 100644 index 0000000..f36fab6 --- /dev/null +++ b/src/lib/components/admin/AdminPage.svelte @@ -0,0 +1,104 @@ + + +
+

{config.displayName}

+ +
+ + + + + +{#if toastMessage} + +{/if} diff --git a/src/lib/components/admin/AdminTable.svelte b/src/lib/components/admin/AdminTable.svelte new file mode 100644 index 0000000..3960d1c --- /dev/null +++ b/src/lib/components/admin/AdminTable.svelte @@ -0,0 +1,142 @@ + + +
+
+ {#if searchFields.length > 0} + + {:else} +
+ {/if} + {#if onNew} + + {/if} +
+ +
+ + + + {#each listFields as field (field)} + + {/each} + + + + {#each sorted as record, i ((record['id'] as string) ?? (record['key'] as string) ?? i)} + onRowClick?.(record)} + class="cursor-pointer border-b border-gray-200 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-100 dark:hover:bg-gray-800" + > + {#each listFields as field (field)} + + {/each} + + {:else} + + + + {/each} + +
toggleSort(field)} + class="cursor-pointer px-4 py-2 text-left text-xs font-medium tracking-wider text-gray-500 uppercase hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200" + > + {fieldNameToLabel(field)} + {#if sortCol === field} + {sortAsc ? '\u25B2' : '\u25BC'} + {/if} +
+ {formatCellValue(resolveFieldValue(record, field), field)} +
+ No {singular}s found. +
+
+
diff --git a/src/lib/components/admin/Toast.svelte b/src/lib/components/admin/Toast.svelte new file mode 100644 index 0000000..c6e16da --- /dev/null +++ b/src/lib/components/admin/Toast.svelte @@ -0,0 +1,47 @@ + + +{#if visible} + +{/if} diff --git a/src/lib/svelte-admin/registry.ts b/src/lib/svelte-admin/registry.ts index 9d164e9..727a107 100644 --- a/src/lib/svelte-admin/registry.ts +++ b/src/lib/svelte-admin/registry.ts @@ -73,7 +73,7 @@ const models: AdminModelConfig[] = [ relationKey: 'addresses', label: 'Addresses', type: 'has-many', - displayFields: ['street', 'city', 'state', 'zip'] + displayFields: ['type', 'street', 'city', 'state', 'postalCode', 'country', 'isDefault'] } ] }, diff --git a/src/routes/(protected)/admin/profiles/+page.server.ts b/src/routes/(protected)/admin/profiles/+page.server.ts new file mode 100644 index 0000000..63aaf03 --- /dev/null +++ b/src/routes/(protected)/admin/profiles/+page.server.ts @@ -0,0 +1,89 @@ +import type { PageServerLoad } from './$types'; +import { getModelConfig } from '$lib/svelte-admin/registry'; +import { loadRecords, upsertRecord, deleteRecord } from '$lib/svelte-admin/crud'; +import { prisma } from '$lib/db'; +import { error } from '@sveltejs/kit'; + +const config = getModelConfig('profiles')!; + +export const load: PageServerLoad = async ({ locals }) => { + return loadRecords(locals, config); +}; + +export const actions = { + upsert: async ({ request, locals }) => { + const formData = await request.formData(); + return upsertRecord(formData, config, locals); + }, + delete: async ({ request, locals }) => { + const formData = await request.formData(); + return deleteRecord(formData, config, locals); + }, + upsertAddress: async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const formData = await request.formData(); + const id = formData.get('id')?.toString(); + const profileId = formData.get('profileId')?.toString(); + if (!profileId) { + return { success: false, addressError: 'Missing profile ID' }; + } + + const type = formData.get('type')?.toString() || 'Mailing'; + const street = formData.get('street')?.toString() || ''; + const city = formData.get('city')?.toString() || ''; + const state = formData.get('state')?.toString() || ''; + const postalCode = formData.get('postalCode')?.toString() || ''; + const country = formData.get('country')?.toString() || ''; + const isDefault = formData.get('isDefault') === 'on'; + + if (!street || !city || !state || !postalCode || !country) { + return { + success: false, + addressError: 'All address fields are required' + }; + } + + if (isDefault) { + await prisma.address.updateMany({ + where: { profileId, type, isDefault: true }, + data: { isDefault: false } + }); + } + + if (id) { + const updated = await prisma.address.update({ + where: { id }, + data: { type, street, city, state, postalCode, country, isDefault } + }); + return { success: true, address: updated }; + } + + const created = await prisma.address.create({ + data: { profileId, type, street, city, state, postalCode, country, isDefault } + }); + return { success: true, address: created }; + }, + updateUser: async ({ request, locals }) => { + if (!locals.user) throw error(401, 'Unauthorized'); + + const formData = await request.formData(); + const userId = formData.get('id')?.toString(); + const email = formData.get('email')?.toString(); + const name = formData.get('name')?.toString(); + + if (!userId) { + return { success: false, userError: 'Missing user ID' }; + } + + const data: Record = {}; + 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: userId }, data }); + return { success: true }; + } +}; diff --git a/src/routes/(protected)/admin/profiles/+page.svelte b/src/routes/(protected)/admin/profiles/+page.svelte new file mode 100644 index 0000000..597f0a4 --- /dev/null +++ b/src/routes/(protected)/admin/profiles/+page.svelte @@ -0,0 +1,364 @@ + + +
+

{config.displayName}

+ +
+ + + + + {#if editingRecord} + {@const user = editingRecord.user as Record | undefined} +
+
+

User

+ +
+

+ {user?.name as string ?? ''} ({user?.email as string ?? ''}) +

+ + {#if userFormOpen} +
+ + {#if userError} +
+ {userError} +
+ {/if} +
+
+ + +
+
+ + +
+
+ +
+
+
+ {/if} +
+ {/if} + + {#if editingRecord} + {@const addresses = (editingRecord.addresses as Record[]) ?? []} +
+
+

Addresses

+ +
+ + {#if addressFormOpen} +
+ + {#if addressError} +
+ {addressError} +
+ {/if} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ {/if} + + {#if addresses.length > 0} + + + + + + + + + + + + + + {#each addresses as addr} + + + + + + + + + + {/each} + +
TypeStreetCityStateZipCountryDefault
{addr.type as string}{addr.street as string}{addr.city as string}{addr.state as string}{addr.postalCode as string}{addr.country as string}{addr.isDefault ? 'Yes' : ''}
+ {:else} +

No addresses on file.

+ {/if} +
+ {/if} +
+ +{#if toastMessage} + +{/if}