Add 5-step setup wizard for initial org configuration

Accessible only by sysadmin when setupComplete is false. Steps cover
org info, branding (color pickers + logo upload), application form
toggles, Stripe integration keys, and a link to CSV import. Each step
saves to org_config via upsert and calls invalidateConfigCache().
Completing the wizard sets setup_complete = true and redirects to /admin.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 12:54:02 -05:00
co-authored by Claude Sonnet 4.6
parent 6e3ca2e496
commit 8e24f5637d
2 changed files with 674 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
import type { PageServerLoad, Actions } from './$types';
import { redirect, fail, error } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { orgConfig, siteSettings } from '$lib/server/schema';
import { eq } from 'drizzle-orm';
import { invalidateConfigCache } from '$lib/server/config';
import { saveUpload } from '$lib/server/upload';
import { logAudit } from '$lib/server/audit';
export const load: PageServerLoad = async ({ locals, url }) => {
if (!locals.user) {
throw redirect(303, '/login');
}
if (locals.user.role !== 'sysadmin') {
throw error(403, 'Setup wizard is only accessible by system administrators.');
}
// If setup is already complete, redirect to admin unless explicitly revisiting
if (locals.orgConfig.setupComplete && !url.searchParams.has('revisit')) {
throw redirect(303, '/admin');
}
const step = parseInt(url.searchParams.get('step') ?? '1', 10) || 1;
return {
step: Math.min(Math.max(step, 1), 5),
orgConfig: locals.orgConfig
};
};
async function upsertOrgConfig(key: string, value: unknown): Promise<void> {
const existing = await db.select({ id: orgConfig.id }).from(orgConfig).where(eq(orgConfig.key, key)).limit(1);
if (existing.length > 0) {
await db.update(orgConfig).set({ value: value as Record<string, unknown> }).where(eq(orgConfig.key, key));
} else {
await db.insert(orgConfig).values({ key, value: value as Record<string, unknown> });
}
}
async function upsertSiteSetting(key: string, value: string): Promise<void> {
const existing = await db.select({ id: siteSettings.id }).from(siteSettings).where(eq(siteSettings.key, key)).limit(1);
if (existing.length > 0) {
await db.update(siteSettings).set({ value }).where(eq(siteSettings.key, key));
} else {
await db.insert(siteSettings).values({ key, value });
}
}
export const actions: Actions = {
saveStep1: async ({ request, locals }) => {
if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' });
const fd = await request.formData();
const orgInfo = {
name: fd.get('name')?.toString().trim() ?? '',
tagline: fd.get('tagline')?.toString().trim() ?? '',
city: fd.get('city')?.toString().trim() ?? '',
state: fd.get('state')?.toString().trim() ?? '',
phone: fd.get('phone')?.toString().trim() ?? '',
email: fd.get('email')?.toString().trim() ?? '',
facebookUrl: fd.get('facebookUrl')?.toString().trim() ?? '',
instagramUrl: fd.get('instagramUrl')?.toString().trim() ?? ''
};
if (!orgInfo.name) {
return fail(400, { error: 'Organization name is required.', step: 1 });
}
await upsertOrgConfig('org_info', orgInfo);
invalidateConfigCache();
await logAudit({
userId: locals.user!.id,
action: 'setup_step1',
entity: 'org_config',
diff: { key: 'org_info' }
});
return { success: true, nextStep: 2 };
},
saveStep2: async ({ request, locals }) => {
if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' });
const fd = await request.formData();
const branding: Record<string, string> = {
primaryColor: fd.get('primaryColor')?.toString() ?? '#0d9488',
primaryColorLight: fd.get('primaryColorLight')?.toString() ?? '#14b8a6',
primaryColorDark: fd.get('primaryColorDark')?.toString() ?? '#0f766e',
accentColor: fd.get('accentColor')?.toString() ?? '#f59e0b',
accentColorLight: fd.get('accentColorLight')?.toString() ?? '#fbbf24',
accentColorDark: fd.get('accentColorDark')?.toString() ?? '#d97706',
logoUrl: fd.get('existingLogoUrl')?.toString() ?? ''
};
// Handle logo upload
const logoFile = fd.get('logo') as File | null;
if (logoFile && logoFile.size > 0) {
try {
const uploaded = await saveUpload(logoFile, 'branding');
branding.logoUrl = uploaded.url;
} catch (err) {
return fail(400, { error: err instanceof Error ? err.message : 'Logo upload failed.', step: 2 });
}
}
await upsertOrgConfig('branding', branding);
invalidateConfigCache();
await logAudit({
userId: locals.user!.id,
action: 'setup_step2',
entity: 'org_config',
diff: { key: 'branding' }
});
return { success: true, nextStep: 3 };
},
saveStep3: async ({ request, locals }) => {
if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' });
const fd = await request.formData();
const applications = {
adoptionEnabled: fd.get('adoptionEnabled') === 'true',
fosterEnabled: fd.get('fosterEnabled') === 'true',
volunteerEnabled: fd.get('volunteerEnabled') === 'true',
surrenderEnabled: fd.get('surrenderEnabled') === 'true',
adoptionFee: parseInt(fd.get('adoptionFee')?.toString() ?? '250', 10) || 250
};
await upsertOrgConfig('applications', applications);
invalidateConfigCache();
await logAudit({
userId: locals.user!.id,
action: 'setup_step3',
entity: 'org_config',
diff: { key: 'applications' }
});
return { success: true, nextStep: 4 };
},
saveStep4: async ({ request, locals }) => {
if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' });
const fd = await request.formData();
// Save Stripe keys to site_settings
const stripePublishable = fd.get('stripePublishableKey')?.toString().trim() ?? '';
const stripeSecret = fd.get('stripeSecretKey')?.toString().trim() ?? '';
if (stripePublishable) await upsertSiteSetting('stripe_publishable_key', stripePublishable);
if (stripeSecret) await upsertSiteSetting('stripe_secret_key', stripeSecret);
await logAudit({
userId: locals.user!.id,
action: 'setup_step4',
entity: 'site_settings',
diff: { keys: ['stripe_publishable_key', 'stripe_secret_key'] }
});
return { success: true, nextStep: 5 };
},
complete: async ({ locals }) => {
if (locals.user?.role !== 'sysadmin') return fail(403, { error: 'Access denied.' });
await upsertOrgConfig('setup_complete', true);
invalidateConfigCache();
await logAudit({
userId: locals.user!.id,
action: 'setup_complete',
entity: 'org_config',
diff: { key: 'setup_complete', value: true }
});
throw redirect(303, '/admin');
}
};
+488
View File
@@ -0,0 +1,488 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { goto } from '$app/navigation';
let { data, form } = $props();
let currentStep = $state(data.step);
let submitting = $state(false);
// Step 2 — branding color state
let primaryColor = $state(data.orgConfig.branding.primaryColor);
let accentColor = $state(data.orgConfig.branding.accentColor);
// Step 3 — application toggles
let adoptionEnabled = $state(data.orgConfig.applications.adoptionEnabled);
let fosterEnabled = $state(data.orgConfig.applications.fosterEnabled);
let volunteerEnabled = $state(data.orgConfig.applications.volunteerEnabled);
let surrenderEnabled = $state(data.orgConfig.applications.surrenderEnabled);
$effect(() => {
if (form?.nextStep) {
currentStep = form.nextStep;
// Update URL without reload
const url = new URL(window.location.href);
url.searchParams.set('step', String(form.nextStep));
goto(url.toString(), { replaceState: true, noScroll: true });
}
});
function handleEnhance() {
submitting = true;
return async ({ update }: { update: (opts?: { reset?: boolean }) => Promise<void> }) => {
submitting = false;
await update({ reset: false });
};
}
const steps = [
{ num: 1, label: 'Organization Info' },
{ num: 2, label: 'Branding' },
{ num: 3, label: 'Application Forms' },
{ num: 4, label: 'Integrations' },
{ num: 5, label: 'Import Data' }
];
const inputClass = 'w-full px-3 py-2.5 rounded-lg border border-gray-200 bg-white text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-teal-500 focus:border-teal-500 text-sm';
const labelClass = 'block text-sm font-medium text-gray-700 mb-1';
const sectionClass = 'bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6';
</script>
<svelte:head>
<title>Setup Wizard — FosterFlow</title>
</svelte:head>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<div class="bg-white border-b border-gray-200 px-4 py-4">
<div class="max-w-2xl mx-auto flex items-center justify-between">
<h1 class="text-xl font-bold text-gray-900">FosterFlow Setup</h1>
<span class="text-sm text-gray-500">Step {currentStep} of 5</span>
</div>
</div>
<!-- Progress bar -->
<div class="bg-white border-b border-gray-100">
<div class="max-w-2xl mx-auto px-4">
<div class="flex">
{#each steps as s}
<div class="flex-1 py-3 text-center text-xs font-medium border-b-2 transition-colors {currentStep === s.num ? 'border-teal-500 text-teal-600' : currentStep > s.num ? 'border-teal-200 text-teal-500' : 'border-transparent text-gray-400'}">
<span class="hidden sm:inline">{s.label}</span>
<span class="sm:hidden">{s.num}</span>
</div>
{/each}
</div>
</div>
</div>
<div class="max-w-2xl mx-auto px-4 py-8">
{#if form?.error}
<div class="mb-6 p-4 bg-red-50 border border-red-200 rounded-xl text-sm text-red-700">
{form.error}
</div>
{/if}
<!-- Step 1: Org Info -->
{#if currentStep === 1}
<div class={sectionClass}>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Organization Information</h2>
<p class="text-sm text-gray-500 mb-6">Tell us about your rescue organization.</p>
<form method="POST" action="?/saveStep1" use:enhance={handleEnhance}>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div class="sm:col-span-2">
<label for="name" class={labelClass}>Organization name <span class="text-red-500">*</span></label>
<input type="text" id="name" name="name" required value={data.orgConfig.orgInfo.name !== 'My Rescue' ? data.orgConfig.orgInfo.name : ''} placeholder="e.g., Happy Paws Rescue" class={inputClass} />
</div>
<div class="sm:col-span-2">
<label for="tagline" class={labelClass}>Tagline</label>
<input type="text" id="tagline" name="tagline" value={data.orgConfig.orgInfo.tagline} placeholder="e.g., Saving lives one paw at a time" class={inputClass} />
</div>
<div>
<label for="city" class={labelClass}>City</label>
<input type="text" id="city" name="city" value={data.orgConfig.orgInfo.city} placeholder="Your city" class={inputClass} />
</div>
<div>
<label for="state" class={labelClass}>State</label>
<input type="text" id="state" name="state" value={data.orgConfig.orgInfo.state} placeholder="e.g., TX" maxlength="2" class={inputClass} />
</div>
<div>
<label for="phone" class={labelClass}>Phone</label>
<input type="tel" id="phone" name="phone" value={data.orgConfig.orgInfo.phone} placeholder="(555) 123-4567" class={inputClass} />
</div>
<div>
<label for="email" class={labelClass}>Contact email</label>
<input type="email" id="email" name="email" value={data.orgConfig.orgInfo.email} placeholder="info@yourrescue.org" class={inputClass} />
</div>
<div>
<label for="facebookUrl" class={labelClass}>Facebook URL</label>
<input type="url" id="facebookUrl" name="facebookUrl" value={data.orgConfig.orgInfo.facebookUrl} placeholder="https://facebook.com/yourpage" class={inputClass} />
</div>
<div>
<label for="instagramUrl" class={labelClass}>Instagram URL</label>
<input type="url" id="instagramUrl" name="instagramUrl" value={data.orgConfig.orgInfo.instagramUrl} placeholder="https://instagram.com/yourpage" class={inputClass} />
</div>
</div>
<div class="mt-6 flex justify-end">
<button
type="submit"
disabled={submitting}
class="px-6 py-2.5 text-sm font-medium rounded-lg text-white bg-teal-600 hover:bg-teal-700 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Saving...' : 'Save & Continue'}
</button>
</div>
</form>
</div>
<!-- Step 2: Branding -->
{:else if currentStep === 2}
<div class={sectionClass}>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Branding</h2>
<p class="text-sm text-gray-500 mb-6">Customize colors and your logo.</p>
<form method="POST" action="?/saveStep2" enctype="multipart/form-data" use:enhance={handleEnhance}>
<input type="hidden" name="existingLogoUrl" value={data.orgConfig.branding.logoUrl} />
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label for="primaryColor" class={labelClass}>Primary color</label>
<div class="flex items-center gap-3">
<input
type="color"
id="primaryColor"
name="primaryColor"
bind:value={primaryColor}
class="h-10 w-16 rounded border border-gray-200 cursor-pointer p-1"
/>
<input type="text" value={primaryColor} readonly class="flex-1 {inputClass} bg-gray-50" />
</div>
<p class="text-xs text-gray-400 mt-1">Used for navigation, buttons, and accents.</p>
</div>
<div>
<label for="accentColor" class={labelClass}>Accent color</label>
<div class="flex items-center gap-3">
<input
type="color"
id="accentColor"
name="accentColor"
bind:value={accentColor}
class="h-10 w-16 rounded border border-gray-200 cursor-pointer p-1"
/>
<input type="text" value={accentColor} readonly class="flex-1 {inputClass} bg-gray-50" />
</div>
<p class="text-xs text-gray-400 mt-1">Used for highlights and call-to-action elements.</p>
</div>
<!-- Derived shades (auto-computed or user can adjust) -->
<div>
<label for="primaryColorLight" class={labelClass}>Primary color (light)</label>
<input type="color" id="primaryColorLight" name="primaryColorLight" value={data.orgConfig.branding.primaryColorLight} class="h-10 w-16 rounded border border-gray-200 cursor-pointer p-1" />
</div>
<div>
<label for="primaryColorDark" class={labelClass}>Primary color (dark)</label>
<input type="color" id="primaryColorDark" name="primaryColorDark" value={data.orgConfig.branding.primaryColorDark} class="h-10 w-16 rounded border border-gray-200 cursor-pointer p-1" />
</div>
<div>
<label for="accentColorLight" class={labelClass}>Accent color (light)</label>
<input type="color" id="accentColorLight" name="accentColorLight" value={data.orgConfig.branding.accentColorLight} class="h-10 w-16 rounded border border-gray-200 cursor-pointer p-1" />
</div>
<div>
<label for="accentColorDark" class={labelClass}>Accent color (dark)</label>
<input type="color" id="accentColorDark" name="accentColorDark" value={data.orgConfig.branding.accentColorDark} class="h-10 w-16 rounded border border-gray-200 cursor-pointer p-1" />
</div>
<div class="sm:col-span-2">
<label for="logo" class={labelClass}>Logo (optional)</label>
{#if data.orgConfig.branding.logoUrl}
<div class="mb-2 flex items-center gap-3">
<img src={data.orgConfig.branding.logoUrl} alt="Current logo" class="h-12 w-auto rounded border border-gray-200 p-1" />
<span class="text-xs text-gray-500">Current logo</span>
</div>
{/if}
<input
type="file"
id="logo"
name="logo"
accept="image/jpeg,image/png,image/webp,image/gif"
class="w-full text-sm text-gray-600 file:mr-4 file:py-2 file:px-4 file:rounded-lg file:border-0 file:text-sm file:font-medium file:bg-teal-50 file:text-teal-700 file:cursor-pointer cursor-pointer"
/>
<p class="text-xs text-gray-400 mt-1">Upload a new logo to replace the current one. Max 10MB.</p>
</div>
</div>
<div class="mt-6 flex justify-between">
<button
type="button"
onclick={() => { currentStep = 1; }}
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
>
&larr; Back
</button>
<button
type="submit"
disabled={submitting}
class="px-6 py-2.5 text-sm font-medium rounded-lg text-white bg-teal-600 hover:bg-teal-700 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Saving...' : 'Save & Continue'}
</button>
</div>
</form>
</div>
<!-- Step 3: Application Forms -->
{:else if currentStep === 3}
<div class={sectionClass}>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Application Forms</h2>
<p class="text-sm text-gray-500 mb-6">Configure which application forms are enabled on your public site.</p>
<form method="POST" action="?/saveStep3" use:enhance={handleEnhance}>
<div class="space-y-4">
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-900">Adoption applications</p>
<p class="text-xs text-gray-500">Allow the public to submit adoption applications online.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="adoptionEnabled" value={adoptionEnabled ? 'true' : 'false'} />
<button
type="button"
onclick={() => adoptionEnabled = !adoptionEnabled}
class="w-11 h-6 rounded-full transition-colors {adoptionEnabled ? 'bg-teal-500' : 'bg-gray-200'} relative"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform {adoptionEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</label>
</div>
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-900">Foster applications</p>
<p class="text-xs text-gray-500">Allow the public to apply to become foster families.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="fosterEnabled" value={fosterEnabled ? 'true' : 'false'} />
<button
type="button"
onclick={() => fosterEnabled = !fosterEnabled}
class="w-11 h-6 rounded-full transition-colors {fosterEnabled ? 'bg-teal-500' : 'bg-gray-200'} relative"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform {fosterEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</label>
</div>
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-900">Volunteer applications</p>
<p class="text-xs text-gray-500">Allow the public to apply to volunteer.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="volunteerEnabled" value={volunteerEnabled ? 'true' : 'false'} />
<button
type="button"
onclick={() => volunteerEnabled = !volunteerEnabled}
class="w-11 h-6 rounded-full transition-colors {volunteerEnabled ? 'bg-teal-500' : 'bg-gray-200'} relative"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform {volunteerEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</label>
</div>
<div class="flex items-center justify-between p-4 border border-gray-200 rounded-lg">
<div>
<p class="text-sm font-medium text-gray-900">Surrender requests</p>
<p class="text-xs text-gray-500">Allow the public to submit pet surrender requests.</p>
</div>
<label class="relative inline-flex items-center cursor-pointer">
<input type="hidden" name="surrenderEnabled" value={surrenderEnabled ? 'true' : 'false'} />
<button
type="button"
onclick={() => surrenderEnabled = !surrenderEnabled}
class="w-11 h-6 rounded-full transition-colors {surrenderEnabled ? 'bg-teal-500' : 'bg-gray-200'} relative"
>
<span class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow transition-transform {surrenderEnabled ? 'translate-x-5' : 'translate-x-0'}"></span>
</button>
</label>
</div>
<div>
<label for="adoptionFee" class={labelClass}>Default adoption fee (USD)</label>
<div class="flex items-center gap-2">
<span class="text-gray-500 text-sm">$</span>
<input
type="number"
id="adoptionFee"
name="adoptionFee"
min="0"
value={data.orgConfig.applications.adoptionFee}
class="w-32 {inputClass}"
/>
</div>
<p class="text-xs text-gray-400 mt-1">Can be overridden per pet.</p>
</div>
</div>
<div class="mt-6 flex justify-between">
<button
type="button"
onclick={() => { currentStep = 2; }}
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
>
&larr; Back
</button>
<button
type="submit"
disabled={submitting}
class="px-6 py-2.5 text-sm font-medium rounded-lg text-white bg-teal-600 hover:bg-teal-700 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Saving...' : 'Save & Continue'}
</button>
</div>
</form>
</div>
<!-- Step 4: Integrations -->
{:else if currentStep === 4}
<div class={sectionClass}>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Integrations</h2>
<p class="text-sm text-gray-500 mb-6">Configure optional third-party integrations. This step can be skipped and completed later.</p>
<form method="POST" action="?/saveStep4" use:enhance={handleEnhance}>
<div class="space-y-6">
<!-- Stripe -->
<div>
<h3 class="text-sm font-semibold text-gray-800 mb-3 flex items-center gap-2">
<span class="w-6 h-6 rounded-full bg-purple-100 text-purple-600 text-xs flex items-center justify-center font-bold">S</span>
Stripe (Online Payments)
</h3>
<div class="space-y-3">
<div>
<label for="stripePublishableKey" class={labelClass}>Publishable key</label>
<input
type="text"
id="stripePublishableKey"
name="stripePublishableKey"
placeholder="pk_live_..."
class={inputClass}
/>
</div>
<div>
<label for="stripeSecretKey" class={labelClass}>Secret key</label>
<input
type="password"
id="stripeSecretKey"
name="stripeSecretKey"
placeholder="sk_live_..."
class={inputClass}
/>
<p class="text-xs text-gray-400 mt-1">Stored securely in site settings.</p>
</div>
</div>
</div>
<!-- SMTP (display-only) -->
<div class="p-4 bg-blue-50 border border-blue-200 rounded-lg">
<h3 class="text-sm font-semibold text-blue-800 mb-2">Email (SMTP)</h3>
<p class="text-xs text-blue-700">
Email settings are configured via environment variables in your <code class="font-mono bg-blue-100 px-1 rounded">.env</code> file:
</p>
<ul class="text-xs text-blue-700 mt-2 space-y-1 font-mono">
<li>SMTP_HOST, SMTP_PORT</li>
<li>SMTP_USER, SMTP_PASS</li>
<li>SMTP_FROM</li>
</ul>
</div>
<!-- Facebook -->
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg">
<h3 class="text-sm font-semibold text-gray-800 mb-2">Facebook Integration</h3>
<p class="text-xs text-gray-600 mb-3">
Connect your Facebook page to automatically post new animals and events.
</p>
<a
href="/admin/system"
target="_blank"
class="text-sm text-teal-600 hover:text-teal-700 font-medium underline"
>
Configure Facebook in System Settings &rarr;
</a>
</div>
</div>
<div class="mt-6 flex justify-between">
<button
type="button"
onclick={() => { currentStep = 3; }}
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
>
&larr; Back
</button>
<div class="flex gap-3">
<button
type="button"
onclick={() => { currentStep = 5; }}
class="px-4 py-2 text-sm font-medium text-gray-500 hover:text-gray-700 transition-colors cursor-pointer"
>
Skip
</button>
<button
type="submit"
disabled={submitting}
class="px-6 py-2.5 text-sm font-medium rounded-lg text-white bg-teal-600 hover:bg-teal-700 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Saving...' : 'Save & Continue'}
</button>
</div>
</div>
</form>
</div>
<!-- Step 5: Import Data -->
{:else if currentStep === 5}
<div class={sectionClass}>
<h2 class="text-lg font-semibold text-gray-900 mb-1">Import Data</h2>
<p class="text-sm text-gray-500 mb-6">
Optionally import existing pet records from a CSV file. You can also do this later from the admin panel.
</p>
<div class="p-4 bg-gray-50 border border-gray-200 rounded-lg mb-6">
<p class="text-sm text-gray-700 mb-3">
Use the CSV import tool to bulk-upload pet records from a spreadsheet. The importer will auto-detect columns and let you map them to FosterFlow fields.
</p>
<a
href="/admin/import"
target="_blank"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-lg text-white bg-teal-600 hover:bg-teal-700 transition-colors"
>
Open CSV Import Tool &rarr;
</a>
</div>
<div class="flex justify-between items-center">
<button
type="button"
onclick={() => { currentStep = 4; }}
class="px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-900 transition-colors cursor-pointer"
>
&larr; Back
</button>
<form method="POST" action="?/complete" use:enhance={handleEnhance}>
<button
type="submit"
disabled={submitting}
class="px-8 py-2.5 text-sm font-semibold rounded-lg text-white bg-teal-600 hover:bg-teal-700 transition-colors disabled:opacity-50 cursor-pointer"
>
{submitting ? 'Finishing...' : 'Complete Setup &rarr;'}
</button>
</form>
</div>
</div>
{/if}
<!-- Step indicator (small text) -->
<p class="text-center text-xs text-gray-400">Step {currentStep} of 5 — You can always change these settings later in the admin panel.</p>
</div>
</div>