loads of mobile ui layout upgrades
This commit is contained in:
@@ -7,7 +7,15 @@
|
||||
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();
|
||||
}: {
|
||||
cols?: 1 | 2 | 3 | 4 | 5 | 6;
|
||||
title?: string;
|
||||
accent?: string;
|
||||
scrollX?: boolean;
|
||||
children?: any;
|
||||
class?: string;
|
||||
selected?: boolean;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div
|
||||
@@ -53,12 +61,6 @@
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
/* Mobile (1-col grid): every card is a full row */
|
||||
@media (max-width: 639px) {
|
||||
.card {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
.card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
@@ -76,4 +78,17 @@
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
/* Mobile (1-col grid): every card is a full row — declared last so it
|
||||
overrides the base .card/.card-body rules (same specificity). */
|
||||
@media (max-width: 639px) {
|
||||
.card {
|
||||
grid-column: span 1;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.card-body {
|
||||
background: transparent;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -298,7 +298,7 @@
|
||||
/* Mobile: full-screen fixed layer. */
|
||||
@media (max-width: 767.98px) {
|
||||
.chat-panel {
|
||||
width: 100vw;
|
||||
width: 100dvw;
|
||||
}
|
||||
}
|
||||
.chat-head {
|
||||
|
||||
@@ -96,5 +96,18 @@
|
||||
.footer-aside {
|
||||
right: 1rem;
|
||||
}
|
||||
.footer-row {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
.footer-star {
|
||||
position: static;
|
||||
}
|
||||
.footer-aside {
|
||||
position: static;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -147,6 +147,9 @@
|
||||
}
|
||||
.price { margin: 0.55rem 0 0.15rem; line-height: 1; }
|
||||
.amount { font-size: 2.4rem; font-weight: 800; color: #111827; }
|
||||
@media (max-width: 639px) {
|
||||
.amount { font-size: 1.5em; }
|
||||
}
|
||||
.period { font-size: 0.85rem; color: #9ca3af; margin-left: 0.3rem; }
|
||||
.blurb { margin: 0 0 0.9rem; font-size: 0.85rem; color: #6b7280; }
|
||||
|
||||
|
||||
@@ -11,6 +11,12 @@
|
||||
prefsIcon
|
||||
} from './icons';
|
||||
import FamDone from './FamDone.svelte';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
|
||||
// Outstanding claim requests (children asked → parent still to issue).
|
||||
let requestedClaims = $derived(
|
||||
famStore.initialized ? famStore.rewards.filter((r: any) => r.status === 'requested').length : 0
|
||||
);
|
||||
|
||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
||||
|
||||
@@ -62,6 +68,9 @@
|
||||
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
||||
{@html item.icon}
|
||||
<span class="nav-label">{item.label}</span>
|
||||
{#if isParent && item.label === 'Dashboard' && requestedClaims > 0}
|
||||
<span class="nav-badge">{requestedClaims}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
@@ -147,6 +156,19 @@
|
||||
margin: 0 0.3rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
position: relative;
|
||||
}
|
||||
.nav-badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 6px;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
.nav-item:hover {
|
||||
background: #3730a3;
|
||||
|
||||
@@ -73,6 +73,14 @@
|
||||
<style>
|
||||
.view-header {
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
/* Stack title above tools so the header doesn't squish on phones */
|
||||
.view-header {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
.view-title-group {
|
||||
margin-bottom: 0.5rem;
|
||||
@@ -82,6 +90,11 @@
|
||||
color: #111827;
|
||||
margin: 0;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.view-title {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
}
|
||||
.view-subtitle {
|
||||
font-size: 0.9rem;
|
||||
color: #6b7280;
|
||||
@@ -90,11 +103,19 @@
|
||||
.view-tools {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
/* Tools row takes the full header width so the title never gets squeezed */
|
||||
width: 100%;
|
||||
}
|
||||
/* Immediate tools (tab bar / week nav / sort) grow to span the row; they
|
||||
wrap to their own full-width line only when they don't fit side by side */
|
||||
.view-tools > * {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 2px;
|
||||
background: #f3f4f6;
|
||||
border-radius: 6px;
|
||||
@@ -118,6 +139,7 @@
|
||||
.week-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.nav-btn {
|
||||
|
||||
@@ -1,33 +1,66 @@
|
||||
export const dashboardIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>'
|
||||
export const choresIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>'
|
||||
export const rewardsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>'
|
||||
export const moneyIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>'
|
||||
export const coinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>'
|
||||
export const walletIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>'
|
||||
export const circleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>'
|
||||
export const handCoinsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>'
|
||||
export const gemIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>'
|
||||
export const archiveIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>'
|
||||
export const todoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>'
|
||||
export const homeIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>'
|
||||
export const settingsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>'
|
||||
export const logoutIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>'
|
||||
export const prefsIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>'
|
||||
export const chevronLeft = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>'
|
||||
export const chevronRight = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>'
|
||||
export const bellIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>'
|
||||
export const chatIcon = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>'
|
||||
export const sendIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>'
|
||||
export const checkCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>'
|
||||
export const revokeIcon = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>'
|
||||
export const targetIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>'
|
||||
export const calendarIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>'
|
||||
export const sunIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>'
|
||||
export const lockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>'
|
||||
export const clockIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>'
|
||||
export const giftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>'
|
||||
export const chevronLeftIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 6 -6 6 6 6"/></svg>'
|
||||
export const chevronRightIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m9 6 6 6 -6 6"/></svg>'
|
||||
export const alertTriangleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>'
|
||||
export const xCircleIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>'
|
||||
export const infoIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>'
|
||||
export const dashboardIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>';
|
||||
export const choresIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/></svg>';
|
||||
export const rewardsIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="8" r="6"/><path d="M15.477 12.89L17 22l-5-3-5 3 1.523-9.11"/></svg>';
|
||||
export const moneyIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>';
|
||||
export const coinsIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><ellipse cx="12" cy="6" rx="8" ry="3"/><path d="M4 6v6c0 1.66 3.58 3 8 3s8-1.34 8-3V6"/><path d="M4 12v6c0 1.66 3.58 3 8 3s8-1.34 8-3v-6"/></svg>';
|
||||
export const walletIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/><path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/><path d="M18 12a2 2 0 0 0 0 4h4v-4Z"/></svg>';
|
||||
export const circleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/></svg>';
|
||||
export const handCoinsIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M11 15h2a2 2 0 1 0 0-4h-3c-.6 0-1.1.2-1.4.6L3 17"/><path d="m7 21 1.6-1.4c.3-.4.8-.6 1.4-.6h4c1.1 0 2.1-.4 2.8-1.2l4.6-4.4a2 2 0 0 0-2.75-2.91l-4.2 3.9"/><path d="M2 16l1 1h2"/><path d="M4 12l1 1h2"/><path d="M6 8l1 1h2"/><path d="M2 20l1.5-.5"/><circle cx="16" cy="8" r="2"/></svg>';
|
||||
export const gemIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 3h12l4 6-10 13L2 9Z"/><path d="M11 3 8 9l4 13 4-13-3-6"/><path d="M2 9h20"/></svg>';
|
||||
export const archiveIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect width="20" height="5" x="2" y="3" rx="1"/><path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8"/><path d="M10 12h4"/></svg>';
|
||||
export const todoIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M8 6h13"/><path d="M8 12h13"/><path d="M8 18h13"/><path d="M3 6h.01"/><path d="M3 12h.01"/><path d="M3 18h.01"/></svg>';
|
||||
export const homeIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 9l9-7 9 7v11a2 2 0 01-2 2H5a2 2 0 01-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/></svg>';
|
||||
export const settingsIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-2 2 2 2 0 01-2-2v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83 0 2 2 0 010-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 01-2-2 2 2 0 012-2h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 010-2.83 2 2 0 012.83 0l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 012-2 2 2 0 012 2v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 0 2 2 0 010 2.83l-.06.06a1.65 1.65 0 00-.33 1.82V9a1.65 1.65 0 001.51 1H21a2 2 0 012 2 2 2 0 01-2 2h-.09a1.65 1.65 0 00-1.51 1z"/></svg>';
|
||||
export const logoutIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 21H5a2 2 0 01-2-2V5a2 2 0 012-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
|
||||
export const prefsIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
||||
export const chevronLeft =
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="15 18 9 12 15 6"/></svg>';
|
||||
export const chevronRight =
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||
export const bellIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 01-3.46 0"/></svg>';
|
||||
export const chatIcon =
|
||||
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/></svg>';
|
||||
export const sendIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>';
|
||||
export const checkCircleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>';
|
||||
export const revokeIcon =
|
||||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="1 4 1 10 7 10"/><path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/></svg>';
|
||||
export const targetIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="5"/><circle cx="12" cy="12" r="1.5" fill="currentColor"/></svg>';
|
||||
export const calendarIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>';
|
||||
export const sunIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>';
|
||||
export const lockIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||
export const clockIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>';
|
||||
export const giftIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="4"/><path d="M12 8v13M12 8C12 8 9 3 6 5s0 3 3 3M12 8c0 0 3-5 6-3s0 3-3 3"/><path d="M5 12v9h14v-9"/></svg>';
|
||||
export const chevronLeftIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m15 6 -6 6 6 6"/></svg>';
|
||||
export const chevronRightIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 6 6 6 -6 6"/></svg>';
|
||||
export const alertTriangleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4M12 17h.01"/></svg>';
|
||||
export const xCircleIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="m15 9-6 6M9 9l6 6"/></svg>';
|
||||
export const infoIcon =
|
||||
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><path d="M12 16v-4M12 8h.01"/></svg>';
|
||||
|
||||
@@ -643,4 +643,9 @@
|
||||
font-size: 2rem;
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
h1 {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -126,7 +126,7 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!session || !famStore.initialized) return;
|
||||
if (!isParent || !session || !famStore.initialized) return;
|
||||
const rewards = famStore.rewards;
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
@@ -180,7 +180,7 @@
|
||||
<div class="app-shell">
|
||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
||||
<TopNav
|
||||
{role}
|
||||
role={data.session ? role : ''}
|
||||
seasons={famStore.seasons.filter((s) => s.active !== false)}
|
||||
announcement={disabled && !activating ? accessMessage(accessReason, isParent) : ''}
|
||||
>
|
||||
@@ -191,7 +191,7 @@
|
||||
{/if}
|
||||
</button>
|
||||
</TopNav>
|
||||
<main class="app-main">
|
||||
<main class="app-main" class:join-page={page.url.pathname.split('/')[2] === 'join'}>
|
||||
<div class="page-wrap">
|
||||
<div class="page-content" class:locked>{@render children()}</div>
|
||||
|
||||
@@ -218,7 +218,12 @@
|
||||
</div>
|
||||
</main>
|
||||
{#if claimToast}
|
||||
<div class="claim-toast">{claimToast}</div>
|
||||
<a
|
||||
class="claim-toast"
|
||||
href={`/${page.params.fam}/${data.session?.username || page.params.username || ''}`}
|
||||
>
|
||||
{claimToast} — view dashboard →
|
||||
</a>
|
||||
{/if}
|
||||
<Footer sidebar />
|
||||
</div>
|
||||
@@ -232,17 +237,33 @@
|
||||
<style>
|
||||
.claim-toast {
|
||||
position: fixed;
|
||||
top: 3.5rem;
|
||||
right: 1.5rem;
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 8px;
|
||||
top: 52px;
|
||||
left: 220px;
|
||||
right: 0;
|
||||
background: #f59e0b;
|
||||
color: #fff;
|
||||
padding: 0.5rem 1.5rem;
|
||||
font-size: 0.85rem;
|
||||
z-index: 999;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
font-weight: 600;
|
||||
z-index: 95;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
text-decoration: none;
|
||||
animation: fadein 0.2s;
|
||||
}
|
||||
.claim-toast:hover {
|
||||
background: #d97706;
|
||||
}
|
||||
@media (max-width: 1023px) {
|
||||
.claim-toast {
|
||||
left: 56px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.claim-toast {
|
||||
left: 0;
|
||||
padding: 0.5rem 1rem;
|
||||
}
|
||||
}
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
@@ -283,6 +304,16 @@
|
||||
margin-left: 56px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.app-main {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
}
|
||||
/* Join pages center their own content — no shell padding needed */
|
||||
.app-main.join-page {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
.page-wrap {
|
||||
position: relative;
|
||||
min-height: 70vh;
|
||||
|
||||
@@ -49,7 +49,6 @@
|
||||
let tileChores = $derived(
|
||||
summary?.summaries?.reduce((s: number, m: any) => s + (m.choresCompleted || 0), 0) ?? 0
|
||||
);
|
||||
|
||||
// ─── All-time header stats (server-computed) ───
|
||||
let allTimePts = $derived(Number(stats.allTimePts) || 0);
|
||||
let allTimeCash = $derived(Number(stats.allTimeCash) || 0);
|
||||
@@ -58,6 +57,9 @@
|
||||
// ─── Goals completed (rewards from bonus configs, pocket money excluded) ───
|
||||
// Derived from famStore so realtime updates (a goal met / revoked) flow in.
|
||||
let rewards = $derived(famStore.initialized ? famStore.rewards : data.rewards || []);
|
||||
// All-time goals achieved — every materialized bonus reward (prize / points /
|
||||
// cash), including pocket money configs.
|
||||
let tileGoals = $derived(rewards.filter((r: any) => r.bonusConfigId).length);
|
||||
let bonusConfigs = $derived(
|
||||
famStore.initialized ? famStore.bonusConfigs : data.bonusConfigs || []
|
||||
);
|
||||
@@ -326,6 +328,10 @@
|
||||
<span class="tile-val">£{tileMoney.toFixed(2)}</span>
|
||||
<span class="tile-lbl">cash this week</span>
|
||||
</div>
|
||||
<div class="tile tile-goals">
|
||||
<span class="tile-val">{tileGoals}</span>
|
||||
<span class="tile-lbl">goals achieved</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<CardGrid>
|
||||
@@ -364,7 +370,7 @@
|
||||
{@const scVal = avgDailyPointsFor(s)}
|
||||
{@const scPct = scPot > 0 ? Math.min(100, Math.round((scVal / scPot) * 100)) : 0}
|
||||
{@const goals = goalsByMember.get(s.memberId)}
|
||||
<div class="column col-member">
|
||||
<div class="column col-member" style="--mc: {s.memberColor}">
|
||||
<div class="member-head">
|
||||
<span class="dot" style="background:{s.memberColor}"></span>
|
||||
<span class="member-name">{s.memberName}</span>
|
||||
@@ -527,7 +533,8 @@
|
||||
/* ── Tiles (matching child dashboard) ── */
|
||||
.tiles {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
/* members 1 unit wide; goals / points / cash 2 units each (1+2+2+2 on one row) */
|
||||
grid-template-columns: 1fr 2fr 2fr 2fr;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -535,6 +542,9 @@
|
||||
.tiles {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
.vh-num {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
}
|
||||
.tile {
|
||||
border-radius: 14px;
|
||||
@@ -557,6 +567,9 @@
|
||||
.tile-chores {
|
||||
background: linear-gradient(135deg, #0284c7, #38bdf8);
|
||||
}
|
||||
.tile-goals {
|
||||
background: linear-gradient(135deg, #f59e0b, #f97316);
|
||||
}
|
||||
.tile-val {
|
||||
font-size: 1.6rem;
|
||||
font-weight: 800;
|
||||
@@ -584,7 +597,8 @@
|
||||
}
|
||||
.col-member {
|
||||
border-top-color: #6366f1;
|
||||
background: #eef2ff;
|
||||
/* Translucent wash of the member's own colour (set via --mc inline) */
|
||||
background: color-mix(in srgb, var(--mc, #6366f1) 12%, transparent);
|
||||
}
|
||||
|
||||
.member-head {
|
||||
@@ -705,4 +719,13 @@
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
/* Mobile: member columns stack full width (same convention as the chores
|
||||
kanban) instead of the 450px horizontal scroll. Declared last so it wins
|
||||
over the base .kanban rule. */
|
||||
@media (max-width: 639px) {
|
||||
.kanban {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -144,10 +144,19 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Same "Bonus name – prize" split as the ledger, so prize rewards don't
|
||||
// duplicate the full label in both columns of a claim row.
|
||||
function rewardLabelParts(r: any): { name: string; prize: string } {
|
||||
const label = r.label || '';
|
||||
const idx = label.indexOf(' – ');
|
||||
if (idx === -1) return { name: label, prize: label };
|
||||
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
|
||||
}
|
||||
|
||||
function rewardLabel(r: any): string {
|
||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||
return r.label;
|
||||
return rewardLabelParts(r).prize;
|
||||
}
|
||||
|
||||
function manualConfigs() {
|
||||
@@ -605,8 +614,14 @@
|
||||
const existingReward = rewards.find(
|
||||
(r) => r.bonusConfigId === cfg.id && r.memberId === memberId
|
||||
);
|
||||
// An earned reward only keeps lighting up the card while it's fresh
|
||||
// (earned in the viewed week) or still outstanding (not yet claimed).
|
||||
// Once claimed and the week rolls over, "Reward earned" disappears.
|
||||
const rDate = (existingReward?.date || '').slice(0, 10);
|
||||
const rewardInWeek = !!rDate && rDate >= weekStart && rDate <= weekEnd;
|
||||
const rewardOutstanding = !!existingReward && existingReward.status !== 'claimed';
|
||||
const achieved = existingReward
|
||||
? true
|
||||
? rewardInWeek || rewardOutstanding
|
||||
: isPercent
|
||||
? totalPotential > 0 && ratio * 100 >= configured
|
||||
: target > 0 && current >= target;
|
||||
@@ -729,6 +744,12 @@
|
||||
function headDay(): number {
|
||||
return headDate().getDate();
|
||||
}
|
||||
function headDayOrdinal(): string {
|
||||
const n = headDay();
|
||||
if (n % 100 >= 11 && n % 100 <= 13) return `${n}th`;
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
return `${n}${s[n % 10] || s[0]}`;
|
||||
}
|
||||
function headWeekday(): string {
|
||||
return headDate().toLocaleDateString(undefined, { weekday: 'long' });
|
||||
}
|
||||
@@ -914,7 +935,6 @@
|
||||
</div>
|
||||
<div class="gm-bottom">
|
||||
<span class="gm-reward">{g.rewardText}</span>
|
||||
<span class="gm-date">{formatDDMMYY(g.date)}</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
@@ -1047,7 +1067,7 @@
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Claims" accent="#f59e0b">
|
||||
<Card cols={1} title="Claims" accent="#f97316" class="claims-card">
|
||||
{#if claimableRewards().length === 0}
|
||||
<div class="claims-empty">
|
||||
{@html checkCircleIcon}<span>No outstanding claims</span>
|
||||
@@ -1106,7 +1126,7 @@
|
||||
<p class="section-label">Requested</p>
|
||||
{#each requested as r}
|
||||
<form
|
||||
class="flex justify-between items-center"
|
||||
class="flex flex-wrap justify-between items-center"
|
||||
method="POST"
|
||||
action="?/claim"
|
||||
use:enhance={() => {
|
||||
@@ -1118,7 +1138,7 @@
|
||||
<span class="claim-icon"
|
||||
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
|
||||
>
|
||||
<span class="claim-label">{r.label}</span>
|
||||
<span class="claim-label">{rewardLabelParts(r).name}</span>
|
||||
<span class="claim-value">{rewardLabel(r)}</span>
|
||||
</div>
|
||||
<Button type="submit" size="sm" variant="primary">Issue</Button>
|
||||
@@ -1133,7 +1153,7 @@
|
||||
<span class="claim-icon"
|
||||
>{@html r.rewardType === 'cash' ? coinsIcon : giftIcon}</span
|
||||
>
|
||||
<span class="claim-label">{r.label}</span>
|
||||
<span class="claim-label">{rewardLabelParts(r).name}</span>
|
||||
<span class="claim-value">
|
||||
{rewardLabel(r)}
|
||||
{#if paydayLocked(r)}
|
||||
@@ -1265,8 +1285,8 @@
|
||||
{/if}
|
||||
</div>
|
||||
<div class="hero-date">
|
||||
<span class="hero-day">{headDay()}</span>
|
||||
<span class="hero-weekday">{headWeekday()}</span>
|
||||
<span class="hero-day">{headDayOrdinal()}</span>
|
||||
<span class="hero-month">{headMonth()}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2224,11 +2244,15 @@
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.hero-days {
|
||||
font-size: 0.85rem;
|
||||
font-size: 1rem;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
/* Own full-width line, centered under the hero content */
|
||||
flex: 1 1 100%;
|
||||
text-align: center;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.hero-days b {
|
||||
font-size: 1.4rem;
|
||||
font-size: 1.5rem;
|
||||
margin-right: 0.15rem;
|
||||
}
|
||||
.hero-nav {
|
||||
@@ -2483,20 +2507,25 @@
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.goal-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
.goal-mini {
|
||||
background: linear-gradient(135deg, #d1d3fa, #ccfbf1);
|
||||
border: 1px solid #a7f3d0;
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem 0.7rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
min-width: 0;
|
||||
/* Own width as a query container, so tight cards drop the member name */
|
||||
container-type: inline-size;
|
||||
}
|
||||
/* Card narrower than 250px: hide the member name so the row doesn't squish */
|
||||
@container (max-width: 249px) {
|
||||
.gm-member {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.gm-top {
|
||||
display: flex;
|
||||
@@ -2526,18 +2555,14 @@
|
||||
.gm-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gm-reward {
|
||||
font-weight: 800;
|
||||
color: #047857;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.gm-date {
|
||||
color: #9ca3af;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
/* Goal celebration notice (child view) */
|
||||
.celebrate-card.goal {
|
||||
@@ -2954,4 +2979,60 @@
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
/* Mobile: declared last so it wins over the base .goal-mini (.kanban)
|
||||
rules (same specificity, source order decides). */
|
||||
@media (max-width: 639px) {
|
||||
.goal-mini-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.kanban {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* Admin overview cards flow full width on phones — same convention as
|
||||
the chores kanban (no horizontal scroll). */
|
||||
.kanban-scroll {
|
||||
overflow-x: visible;
|
||||
}
|
||||
.kanban-inner {
|
||||
min-width: 0;
|
||||
}
|
||||
/* Keep the orange wash visible: restore body padding for this card
|
||||
only (the shared Card removes it on mobile). */
|
||||
:global(.card.claims-card .card-body) {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
/* Claim rows: icon/label/value stay on one line (label truncates
|
||||
with ellipsis) and the Issue button becomes a full-width action
|
||||
below — no squishing or overflow under 450px. */
|
||||
.member-claims form.flex {
|
||||
flex-wrap: wrap;
|
||||
row-gap: 0.5rem;
|
||||
}
|
||||
.member-claims form.flex > div {
|
||||
flex: 1 1 100%;
|
||||
min-width: 0;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
}
|
||||
/* :global — the button element is rendered by the shared Button
|
||||
component, so a scoped selector never matches it. */
|
||||
.member-claims form.flex :global(button) {
|
||||
flex: 1 1 100%;
|
||||
width: 100%;
|
||||
margin-left: 0;
|
||||
}
|
||||
.member-claims .claim-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
.member-claims .claim-item {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
/* Claims card — orange wash so pending requests are impossible to miss.
|
||||
:global + !important deliberately override the shared Card's white/
|
||||
mobile-transparent backgrounds. */
|
||||
:global(.card.claims-card) {
|
||||
background: linear-gradient(135deg, #ffedd5, #fdba74 70%, #fb923c) !important;
|
||||
border-color: #f97316;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -197,7 +197,7 @@
|
||||
const isShared = assignTarget === 'shared';
|
||||
const body: Record<string, unknown> = {
|
||||
memberId: isShared ? '' : assignModal.memberId,
|
||||
templateId: assignModal.t.id,
|
||||
templateId: assignModal.t?.id || '',
|
||||
customName: assignName,
|
||||
description: assignDescription,
|
||||
color: assignColor,
|
||||
@@ -242,6 +242,20 @@
|
||||
showTodoModal = true;
|
||||
}
|
||||
|
||||
function openCreateChore() {
|
||||
assignModal = { t: null as any, memberId: members[0]?.id || '' };
|
||||
assignStep = 1;
|
||||
assignTarget = 'member';
|
||||
assignName = '';
|
||||
assignDescription = '';
|
||||
assignColor = '';
|
||||
assignIcon = '';
|
||||
assignFreq = 'daily';
|
||||
assignType = 'points';
|
||||
assignValue = 10;
|
||||
assignSeasonIds = seasonFilter !== 'all' ? [seasonFilter] : [];
|
||||
}
|
||||
|
||||
// Sort todos by completeBy (soonest first), then by startDate
|
||||
function todoSort(a: AssignedChore, b: AssignedChore): number {
|
||||
const da = a.completeBy || '9999-99-99';
|
||||
@@ -381,6 +395,7 @@
|
||||
<div class="template-section">
|
||||
<div class="template-header">
|
||||
<h3>Templates — Drag to assign</h3>
|
||||
<button class="add-inline" onclick={openCreateChore}>+ Create a chore</button>
|
||||
</div>
|
||||
<div class="template-list">
|
||||
{#each templates as t}
|
||||
@@ -619,8 +634,7 @@
|
||||
<span class="accordion-label">Competitive Chores</span>
|
||||
</span>
|
||||
<span class="accordion-chevron"
|
||||
>{@html
|
||||
(accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>{@html (accordionState.shared?.chores ?? true) ? CHEVRON_UP : CHEVRON_DOWN}</span
|
||||
>
|
||||
</button>
|
||||
<span class="accordion-count" style="background:#6366f1"
|
||||
@@ -720,12 +734,15 @@
|
||||
<div
|
||||
class="overlay"
|
||||
onclick={(e) => {
|
||||
if (e.target === e.currentTarget) { assignModal = null; assignStep = 1; }
|
||||
if (e.target === e.currentTarget) {
|
||||
assignModal = null;
|
||||
assignStep = 1;
|
||||
}
|
||||
}}
|
||||
role="presentation"
|
||||
>
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Assign: {assignModal.t.name}</h3>
|
||||
<h3>{assignModal.t ? `Assign: ${assignModal.t.name}` : 'Create a chore'}</h3>
|
||||
|
||||
<!-- Step tabs -->
|
||||
<div class="step-tabs" role="tablist">
|
||||
@@ -743,14 +760,22 @@
|
||||
{#if assignStep === 1}
|
||||
<!-- Step 1: Who -->
|
||||
<label>Name <input bind:value={assignName} placeholder="Task name" /></label>
|
||||
<label>Description <input bind:value={assignDescription} placeholder="Optional description" /></label>
|
||||
<label
|
||||
>Description <input
|
||||
bind:value={assignDescription}
|
||||
placeholder="Optional description"
|
||||
/></label
|
||||
>
|
||||
<label>
|
||||
Assign to
|
||||
<select bind:value={assignTarget} onchange={() => {
|
||||
<select
|
||||
bind:value={assignTarget}
|
||||
onchange={() => {
|
||||
if (assignTarget === 'member' && !assignModal?.memberId && members.length) {
|
||||
assignModal = { ...assignModal!, memberId: members[0].id };
|
||||
}
|
||||
}}>
|
||||
}}
|
||||
>
|
||||
<option value="member">A specific member</option>
|
||||
<option value="shared">Shared (all members)</option>
|
||||
</select>
|
||||
@@ -824,7 +849,13 @@
|
||||
{#if assignStep > 1}
|
||||
<button type="button" onclick={() => assignStep--}>Back</button>
|
||||
{:else}
|
||||
<button type="button" onclick={() => { assignModal = null; assignStep = 1; }}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => {
|
||||
assignModal = null;
|
||||
assignStep = 1;
|
||||
}}>Cancel</button
|
||||
>
|
||||
{/if}
|
||||
{#if assignStep < 3}
|
||||
<button type="button" class="primary" onclick={() => assignStep++}>Next</button>
|
||||
@@ -1163,7 +1194,6 @@
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Kanban (3 columns, scrolls via Card scrollX) ── */
|
||||
.kanban {
|
||||
display: flex;
|
||||
@@ -1849,4 +1879,23 @@
|
||||
opacity: 0.5;
|
||||
min-height: 200px;
|
||||
}
|
||||
/* Mobile: declared last so it wins over the base .kanban/.column rules
|
||||
(same specificity, source order decides). */
|
||||
@media (max-width: 639px) {
|
||||
.template-list {
|
||||
display: none;
|
||||
}
|
||||
.kanban {
|
||||
flex-direction: column;
|
||||
overflow-x: visible;
|
||||
}
|
||||
.column {
|
||||
flex: none;
|
||||
width: 100%;
|
||||
min-height: auto;
|
||||
}
|
||||
.column-placeholder {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,14 +5,15 @@ export async function load(event) {
|
||||
if (!event.locals.user) throw redirect(303, '/login');
|
||||
const famId = event.locals.user.famId;
|
||||
const s = servicesFor(event);
|
||||
const [rewards, members, assigned, templates, completions] = await Promise.all([
|
||||
const [rewards, members, assigned, templates, completions, fam] = await Promise.all([
|
||||
s.crud.list('rewards', famId),
|
||||
s.crud.list('members', famId),
|
||||
s.crud.list('assigned-chores', famId),
|
||||
s.crud.list('chore-templates', famId),
|
||||
s.crud.list('completions', famId)
|
||||
s.crud.list('completions', famId),
|
||||
s.fam.get(famId)
|
||||
]);
|
||||
return { rewards, members, assigned, templates, completions };
|
||||
return { rewards, members, assigned, templates, completions, fam };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
import { enhance } from '$app/forms';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import { formatDDMMYY } from '$lib/format';
|
||||
import { formatDDMMYY, formatShortDate } from '$lib/format';
|
||||
import { resolveTz, weekStart as tzWeekStart, addDaysStr } from '@shared/timezone';
|
||||
import type { Member, AssignedChore, ChoreTemplate, Completion, Reward } from '$lib/types';
|
||||
|
||||
let { data } = $props();
|
||||
@@ -24,6 +25,28 @@
|
||||
famStore.initialized ? famStore.templates : ((data.templates || []) as ChoreTemplate[])
|
||||
);
|
||||
|
||||
// ── Week navigation (mirrors the member dashboard: payday-anchored weeks) ──
|
||||
// Anchor comes from data.fam at SSR; famStore takes over after init so a
|
||||
// payday/timezone change in settings flows through too.
|
||||
let fam = $derived(
|
||||
famStore.initialized ? (famStore.fam as any) : ((data as any).fam as any) || {}
|
||||
);
|
||||
let famPayday = $derived(fam?.payday != null ? Number(fam.payday) : 1);
|
||||
let famTz = $derived(resolveTz(fam?.timezone || 'auto'));
|
||||
let currentWeek = $derived.by(() => tzWeekStart(famPayday, famTz));
|
||||
let weekStart = $state(tzWeekStart(famPayday, famTz));
|
||||
let weekEnd = $derived(addDaysStr(weekStart, 6));
|
||||
let isCurrentWeek = $derived(weekStart === currentWeek);
|
||||
|
||||
function inWeek(dateStr: string | undefined): boolean {
|
||||
const d = (dateStr || '').slice(0, 10);
|
||||
return !!d && d >= weekStart && d <= weekEnd;
|
||||
}
|
||||
|
||||
function navigateWeek(amount: number) {
|
||||
weekStart = addDaysStr(weekStart, amount * 7);
|
||||
}
|
||||
|
||||
let activeTab = $state('rewards');
|
||||
|
||||
let memberMap = $derived.by(() => new Map(members.map((m: any) => [m.id, m])));
|
||||
@@ -43,10 +66,22 @@
|
||||
}
|
||||
|
||||
// ── Rewards tab ──
|
||||
// Rewards store the label as "Bonus name – prize" (built in bonuses.ts:
|
||||
// cash → "Reading Bonus 3 – £5.00", points → "Reading Bonus 3 – 50",
|
||||
// items → "Reading Bonus 3 – fluffy toy"). Split it so the description
|
||||
// column shows the bonus name and the amount column shows the prize;
|
||||
// labels without a separator keep the full text in both columns.
|
||||
function rewardLabelParts(r: any): { name: string; prize: string } {
|
||||
const label = r.label || '';
|
||||
const idx = label.indexOf(' – ');
|
||||
if (idx === -1) return { name: label, prize: label };
|
||||
return { name: label.slice(0, idx), prize: label.slice(idx + 3) };
|
||||
}
|
||||
|
||||
function rewardAmount(r: any): string {
|
||||
if (r.rewardType === 'cash') return `£${Number(r.value).toFixed(2)}`;
|
||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||
return r.label;
|
||||
return rewardLabelParts(r).prize;
|
||||
}
|
||||
|
||||
function rewardStatus(r: any): string {
|
||||
@@ -71,8 +106,11 @@
|
||||
})
|
||||
);
|
||||
|
||||
let weekRewards = $derived(sortedRewards.filter((r: any) => inWeek(r.date)));
|
||||
|
||||
// Footer total matches the visible week, not all-time history.
|
||||
let totalOutstanding = $derived(
|
||||
rewards
|
||||
weekRewards
|
||||
.filter((r: any) => r.status !== 'claimed')
|
||||
.filter((r: any) => r.rewardType === 'cash')
|
||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||
@@ -93,6 +131,9 @@
|
||||
[...todoCompletions].sort((a: any, b: any) => (b.date || '').localeCompare(a.date || ''))
|
||||
);
|
||||
|
||||
let weekChoreCompletions = $derived(sortedChoreCompletions.filter((c: any) => inWeek(c.date)));
|
||||
let weekTodoCompletions = $derived(sortedTodoCompletions.filter((c: any) => inWeek(c.date)));
|
||||
|
||||
let toast = $state('');
|
||||
|
||||
async function fire() {
|
||||
@@ -125,6 +166,11 @@
|
||||
active: activeTab,
|
||||
onchange: (v: string) => (activeTab = v)
|
||||
}}
|
||||
weeknav={{
|
||||
current: isCurrentWeek ? 'This week' : formatShortDate(weekStart),
|
||||
onPrev: () => navigateWeek(-1),
|
||||
onNext: () => navigateWeek(1)
|
||||
}}
|
||||
/>
|
||||
|
||||
{#if toast}
|
||||
@@ -134,10 +180,11 @@
|
||||
<CardGrid stackTablet>
|
||||
{#if activeTab === 'rewards'}
|
||||
<Card cols={3}>
|
||||
{#if rewards.length === 0}
|
||||
{#if weekRewards.length === 0}
|
||||
<p class="empty">
|
||||
No rewards yet. Points earned will automatically create bonus rewards when thresholds are
|
||||
met.
|
||||
{isCurrentWeek
|
||||
? 'No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.'
|
||||
: `No rewards for the week of ${formatShortDate(weekStart)}.`}
|
||||
</p>
|
||||
{:else}
|
||||
<table>
|
||||
@@ -152,15 +199,19 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedRewards as r}
|
||||
{#each weekRewards as r}
|
||||
{@const outstanding = isOutstanding(r)}
|
||||
<tr class:claimed={!outstanding && !isRequested(r)} class:requested={isRequested(r)}>
|
||||
<tr
|
||||
class:claimed={!outstanding && !isRequested(r)}
|
||||
class:requested={isRequested(r)}
|
||||
style="--mc:{memberColor(r.memberId)}"
|
||||
>
|
||||
<td class="date">{r.date ? formatDDMMYY(r.date) : '—'}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||
{memberName(r.memberId)}
|
||||
</td>
|
||||
<td>{r.label}</td>
|
||||
<td>{rewardLabelParts(r).name}</td>
|
||||
<td class="num">{rewardAmount(r)}</td>
|
||||
<td>
|
||||
<span
|
||||
@@ -208,8 +259,12 @@
|
||||
</Card>
|
||||
{:else if activeTab === 'chores'}
|
||||
<Card cols={3} scrollX title="Chore completions">
|
||||
{#if sortedChoreCompletions.length === 0}
|
||||
<p class="empty">No chore completions yet.</p>
|
||||
{#if weekChoreCompletions.length === 0}
|
||||
<p class="empty">
|
||||
{isCurrentWeek
|
||||
? 'No chore completions yet.'
|
||||
: `No chore completions for the week of ${formatShortDate(weekStart)}.`}
|
||||
</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
@@ -221,9 +276,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedChoreCompletions as c}
|
||||
{#each weekChoreCompletions as c}
|
||||
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||
<tr>
|
||||
<tr style="--mc:{memberColor(c.memberId)}">
|
||||
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||
@@ -242,8 +297,12 @@
|
||||
</Card>
|
||||
{:else}
|
||||
<Card cols={3} scrollX title="Todo completions">
|
||||
{#if sortedTodoCompletions.length === 0}
|
||||
<p class="empty">No todos completed yet.</p>
|
||||
{#if weekTodoCompletions.length === 0}
|
||||
<p class="empty">
|
||||
{isCurrentWeek
|
||||
? 'No todos completed yet.'
|
||||
: `No todos completed for the week of ${formatShortDate(weekStart)}.`}
|
||||
</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
@@ -255,9 +314,9 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedTodoCompletions as c}
|
||||
{#each weekTodoCompletions as c}
|
||||
{@const a = assignedMap.get(c.assignedChoreId)}
|
||||
<tr>
|
||||
<tr style="--mc:{memberColor(c.memberId)}">
|
||||
<td class="date">{formatDDMMYY(c.date)}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(c.memberId)}"></span>
|
||||
@@ -356,4 +415,20 @@
|
||||
border-top: 2px solid #d1d5db;
|
||||
padding-top: 0.6rem;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
/* Hide Member (2nd) and Status (5th, rewards only) columns */
|
||||
th:nth-child(2),
|
||||
td:nth-child(2) {
|
||||
display: none;
|
||||
}
|
||||
th:nth-child(5),
|
||||
td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
/* Member colour as the entry background instead of the member column */
|
||||
tbody tr,
|
||||
tbody tr.requested {
|
||||
background: color-mix(in srgb, var(--mc, #6366f1) 10%, transparent);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1161,4 +1161,7 @@
|
||||
.resume-hint {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.code-display { font-size: 1.5em; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,7 +14,9 @@
|
||||
|
||||
<main class="mx-auto flex min-h-screen max-w-md flex-col items-center justify-center px-6">
|
||||
<section class="w-full rounded-2xl border border-slate-200 bg-white p-8 text-center shadow-sm">
|
||||
<div class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl">
|
||||
<div
|
||||
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-indigo-100 text-2xl"
|
||||
>
|
||||
<span class="home-badge">{@html homeIcon}</span>
|
||||
</div>
|
||||
<h1 class="text-xl font-bold text-slate-900">Join {famSlug}</h1>
|
||||
@@ -51,7 +53,7 @@
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
{#if form?.error}
|
||||
<p class="text-sm font-medium text-rose-600">{form.error}</p>
|
||||
|
||||
@@ -58,7 +58,7 @@
|
||||
maxlength="6"
|
||||
placeholder="6-digit code"
|
||||
autocomplete="one-time-code"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-2xl tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
class="w-full rounded-lg border border-slate-300 px-4 py-3 text-center text-lg tracking-[0.5em] text-slate-900 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-200"
|
||||
/>
|
||||
{#if isParent}
|
||||
<input
|
||||
|
||||
Reference in New Issue
Block a user