feature plus ux rearrangements

This commit is contained in:
JCEEE
2026-08-22 19:49:46 +01:00
parent f14f4e2ac1
commit 42b9a27ce6
44 changed files with 2020 additions and 538 deletions
+12 -29
View File
@@ -1,36 +1,19 @@
<script lang="ts">
let { items }: { items: { title: string; content: any }[] } = $props();
let openIndex = $state<number | null>(null);
import type { Snippet } from 'svelte';
// Styled wrapper — group AccordionItem children inside.
let { children, defaultOpen = false }: { children: Snippet; defaultOpen?: boolean } = $props();
</script>
<div class="accordion">
{#each items as item, i}
<div class="accordion-item" class:open={openIndex === i}>
<button class="accordion-trigger" onclick={() => openIndex = openIndex === i ? null : i}>
<span>{item.title}</span>
<span class="accordion-arrow">{openIndex === i ? '▾' : '▸'}</span>
</button>
{#if openIndex === i}
<div class="accordion-body">
{@render item.content()}
</div>
{/if}
</div>
{/each}
<div class="accordion" class:default-open={defaultOpen}>
{@render children()}
</div>
<style>
.accordion { border: 1px solid #e5e7eb; border-radius: 8px; overflow: hidden; }
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.9rem; font-weight: 500; color: #374151;
cursor: pointer; text-align: left;
.accordion {
border: 1px solid #e5e7eb;
border-radius: 8px;
overflow: hidden;
background: #fff;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
</style>
@@ -0,0 +1,36 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { title, open = $bindable(false), children }: { title: string; open?: boolean; children: Snippet } = $props();
</script>
<div class="accordion-item" class:open>
<button
class="accordion-trigger"
onclick={() => (open = !open)}
aria-expanded={open}
>
<span>{title}</span>
<span class="accordion-arrow">{open ? '▾' : '▸'}</span>
</button>
{#if open}
<div class="accordion-body">
{@render children()}
</div>
{/if}
</div>
<style>
.accordion-item { border-bottom: 1px solid #f3f4f6; }
.accordion-item:last-child { border-bottom: none; }
.accordion-trigger {
display: flex; justify-content: space-between; align-items: center;
width: 100%; padding: 0.7rem 1rem;
background: #fafafa; border: none;
font-size: 0.95rem; font-weight: 600; color: #374151;
cursor: pointer; text-align: left;
}
.accordion-trigger:hover { background: #f3f4f6; }
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
.accordion-body { padding: 1rem; }
</style>
+5 -3
View File
@@ -4,14 +4,16 @@
title,
accent,
scrollX = false,
children
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any } = $props();
children,
class: className,
selected = false
}: { cols?: 1 | 2 | 3 | 4 | 5 | 6; title?: string; accent?: string; scrollX?: boolean; children?: any; class?: string; selected?: boolean } = $props();
</script>
<div
class="card"
data-cols={cols}
style="--card-cols: {cols}; {accent ? `--card-accent: ${accent}` : ''}"
class="card {className || ''} {selected ? 'selected' : ''}"
class:has-accent={!!accent}
class:scroll-x={scrollX}
>
@@ -0,0 +1,112 @@
<script lang="ts">
import { notices, type NoticeType } from '$lib/stores/notices.svelte';
function icon(type: NoticeType) {
switch (type) {
case 'success': return '✅';
case 'warning': return '⚠️';
case 'error': return '❌';
default: return 'ℹ️';
}
}
</script>
{#if notices.list.length > 0}
<div class="notice-container" role="region" aria-label="Notifications">
{#each notices.list as notice (notice.id)}
<div class="notice notice-{notice.type}">
<div class="notice-content">
<div class="notice-icon">{icon(notice.type)}</div>
<div class="notice-text">
<h4>{notice.title}</h4>
{#if notice.message}
<p>{notice.message}</p>
{/if}
</div>
</div>
{#if notice.action}
<a href={notice.action.href} class="notice-action">{notice.action.label}</a>
{/if}
{#if notice.dismissible}
<button class="notice-dismiss" onclick={() => notices.remove(notice.id)} aria-label="Dismiss">✕</button>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.notice-container {
position: fixed;
top: 5rem;
right: 1.5rem;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 0.5rem;
max-width: 380px;
pointer-events: none;
}
.notice {
background: white;
border-radius: 10px;
padding: 1rem 1.25rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
display: flex;
align-items: flex-start;
gap: 0.75rem;
border-left: 4px solid #6366f1;
animation: slidein 0.25s ease;
pointer-events: auto;
}
.notice-success { border-left-color: #059669; }
.notice-warning { border-left-color: #d97706; }
.notice-error { border-left-color: #dc2626; }
.notice-content {
display: flex;
align-items: flex-start;
gap: 0.6rem;
flex: 1;
min-width: 0;
}
.notice-icon {
font-size: 1.1rem;
flex-shrink: 0;
}
.notice-text h4 {
margin: 0 0 0.25rem;
font-size: 0.9rem;
font-weight: 600;
color: #111827;
}
.notice-text p {
margin: 0;
font-size: 0.8rem;
color: #6b7280;
line-height: 1.4;
}
.notice-action {
font-size: 0.8rem;
color: #4338ca;
font-weight: 500;
text-decoration: none;
flex-shrink: 0;
align-self: center;
}
.notice-action:hover { text-decoration: underline; }
.notice-dismiss {
background: none;
border: none;
font-size: 1rem;
color: #9ca3af;
cursor: pointer;
padding: 0.2rem;
line-height: 1;
flex-shrink: 0;
}
.notice-dismiss:hover { color: #374151; }
@keyframes slidein {
from { opacity: 0; transform: translateX(20px); }
to { opacity: 1; transform: translateX(0); }
}
</style>
@@ -0,0 +1,152 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { Button, CardGrid, Card } from '$lib/components';
interface Tier {
id: 'trial' | 'monthly' | 'yearly';
name: string;
price: string;
period: string;
desc: string;
cta: string;
featured?: boolean;
}
const tiers: Tier[] = [
{
id: 'trial',
name: 'Trial',
price: 'Free',
period: 'for a set period',
desc: 'Try FamChore free for a period (e.g. 3 months) with a code.',
cta: 'Start trial'
},
{
id: 'monthly',
name: 'Monthly',
price: '£3',
period: '/month',
desc: 'Full access for one family. Cancel anytime.',
cta: 'Choose monthly',
featured: true
},
{
id: 'yearly',
name: 'Yearly',
price: '£30',
period: '/year',
desc: 'Two months free vs monthly. Best value for committed families.',
cta: 'Choose yearly'
}
];
let { action, hideTrial = false, selected = '', error = '', onsubmit }: {
action: string;
hideTrial?: boolean;
selected?: string;
error?: string;
onsubmit?: any;
} = $props();
</script>
<CardGrid>
{#each tiers.filter((t) => !(hideTrial && t.id === 'trial')) as tier}
<Card title={tier.name} accent={tier.featured ? '#6366f1' : undefined} selected={selected === tier.id}>
<p class="price">
<span class="amount">{tier.price}</span>
<span class="period">{tier.period}</span>
</p>
<p class="hint">{tier.desc}</p>
{#if tier.id === 'trial'}
<form
method="POST"
action={action}
use:enhance={onsubmit ?? undefined}
>
<input type="hidden" name="plan" value="trial" />
<label class="field-label" for="trial-code">Trial code</label>
<input
id="trial-code"
name="code"
placeholder="e.g. FAM3MONTHS"
autocomplete="off"
/>
<Button type="submit" size="md" variant="secondary">Start trial</Button>
</form>
{:else}
<form
method="POST"
action={action}
use:enhance={onsubmit ?? undefined}
>
<input type="hidden" name="plan" value={tier.id} />
<Button type="submit" size="md" variant={tier.featured ? 'primary' : 'secondary'}>
{tier.cta}
</Button>
</form>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</Card>
{/each}
</CardGrid>
<style>
.hint {
font-size: 0.85rem;
color: #9ca3af;
line-height: 1.4;
}
.price {
margin: 0 0 0.5rem;
}
.amount {
font-size: 2rem;
font-weight: 700;
color: #1f2937;
}
.period {
color: #9ca3af;
font-size: 0.9rem;
margin-left: 0.25rem;
}
form {
display: flex;
flex-direction: column;
gap: 0.6rem;
margin-top: 0.75rem;
}
.field-label {
font-size: 0.8rem;
font-weight: 500;
color: #6b7280;
}
input {
padding: 0.55rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 8px;
font-size: 0.9rem;
text-transform: uppercase;
}
input:focus {
outline: 2px solid #6366f1;
outline-offset: -1px;
}
form :global(.btn) {
width: 100%;
}
.error {
color: #dc2626;
background: #fef2f2;
padding: 0.5rem;
border-radius: 6px;
font-size: 0.85rem;
margin-top: 0.5rem;
}
:global(.selected) {
border: 2px solid #6366f1;
}
</style>
+1 -1
View File
@@ -15,7 +15,7 @@
let collapsed = $state(false);
let famSlug = $derived(page.params.fam);
let famSlug = $derived(page.data.famSlug ?? page.params.fam);
let memberName = $derived(session?.memberName || page.params.username || '');
function toggle() {
+3
View File
@@ -6,5 +6,8 @@ export { default as Card } from './Card.svelte';
export { default as CardGrid } from './CardGrid.svelte';
export { default as Button } from './Button.svelte';
export { default as Accordion } from './Accordion.svelte';
export { default as AccordionItem } from './AccordionItem.svelte';
export { default as Chat } from './Chat.svelte';
export { default as AuthShell } from './AuthShell.svelte';
export { default as NoticeDialog } from './NoticeDialog.svelte';
export { default as PricingPlans } from './PricingPlans.svelte';