update ui and other frontend updates
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
||||
|
||||
const BASE_URL = typeof window === 'undefined'
|
||||
? `http://${SERVER_IP}:${PROXY_PORT}`
|
||||
: '';
|
||||
|
||||
async function memberFetch<T = unknown>(
|
||||
method: string,
|
||||
path: string,
|
||||
token: string,
|
||||
famId: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'x-device-token': token,
|
||||
'x-device-famid': famId,
|
||||
};
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
|
||||
return data as T;
|
||||
}
|
||||
|
||||
export const memberApi = {
|
||||
async toggleCompletion(token: string, famId: string, assignedChoreId: string, date: string) {
|
||||
return memberFetch('POST', '/api/completions/toggle', token, famId, { assignedChoreId, date });
|
||||
},
|
||||
async myChores(token: string, famId: string) {
|
||||
return memberFetch('POST', '/api/members/my-chores', token, famId);
|
||||
},
|
||||
async claimReward(token: string, famId: string, rewardId: string) {
|
||||
return memberFetch('POST', `/api/members/rewards/${rewardId}/claim`, token, famId);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
<script lang="ts">
|
||||
let { items }: { items: { title: string; content: any }[] } = $props();
|
||||
let openIndex = $state<number | null>(null);
|
||||
</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>
|
||||
|
||||
<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-trigger:hover { background: #f3f4f6; }
|
||||
.accordion-arrow { font-size: 0.8rem; color: #9ca3af; }
|
||||
.accordion-body { padding: 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,49 @@
|
||||
<script lang="ts">
|
||||
let { variant = 'primary', size = 'md', href, onclick, children, ...rest }: {
|
||||
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
href?: string;
|
||||
onclick?: () => void;
|
||||
children?: any;
|
||||
[key: string]: unknown;
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
{#if href}
|
||||
<a href={href} class="btn btn-{variant} btn-{size}" {...rest}>
|
||||
{@render children?.()}
|
||||
</a>
|
||||
{:else}
|
||||
<button class="btn btn-{variant} btn-{size}" {onclick} {...rest}>
|
||||
{@render children?.()}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.btn-sm { padding: 0.3rem 0.6rem; font-size: 0.8rem; }
|
||||
.btn-md { padding: 0.45rem 0.9rem; font-size: 0.85rem; }
|
||||
.btn-lg { padding: 0.6rem 1.2rem; font-size: 0.95rem; }
|
||||
|
||||
.btn-primary { background: #4338ca; color: #fff; border-color: #4338ca; }
|
||||
.btn-primary:hover { background: #3730a3; }
|
||||
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; border-color: #d1d5db; }
|
||||
.btn-secondary:hover { background: #e5e7eb; }
|
||||
|
||||
.btn-ghost { background: transparent; color: #6b7280; border-color: transparent; }
|
||||
.btn-ghost:hover { background: #f3f4f6; }
|
||||
|
||||
.btn-danger { background: #dc2626; color: #fff; border-color: #dc2626; }
|
||||
.btn-danger:hover { background: #b91c1c; }
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script lang="ts">
|
||||
let { cols = 1, title, accent, children }: { cols?: 1 | 2 | 3; title?: string; accent?: string; children?: any } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card" style="grid-column: span {cols}; {accent ? `--card-accent: ${accent}` : ''}" class:has-accent={!!accent}>
|
||||
{#if title}
|
||||
<div class="card-header">
|
||||
<span class="card-title">{title}</span>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card {
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.card.has-accent { border-top: 3px solid var(--card-accent, #6366f1); }
|
||||
.card-header {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid #f3f4f6;
|
||||
background: #fafafa;
|
||||
}
|
||||
.card-title { font-weight: 600; font-size: 0.9rem; color: #374151; }
|
||||
.card-body { padding: 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,21 @@
|
||||
<script lang="ts">
|
||||
let { children }: { children?: any } = $props();
|
||||
</script>
|
||||
|
||||
<div class="card-grid">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.card-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.card-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 600px) {
|
||||
.card-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,13 @@
|
||||
<footer class="app-footer">
|
||||
<span class="footer-text">FamChore</span>
|
||||
</footer>
|
||||
|
||||
<style>
|
||||
.app-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
text-align: center;
|
||||
font-size: 0.8rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,134 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { dashboardIcon, choresIcon, rewardsIcon, bonusesIcon, homeIcon, settingsIcon, logoutIcon, prefsIcon } from './icons';
|
||||
|
||||
let { famName = '', session = null, isParent = false, role = 'child' } = $props();
|
||||
|
||||
let collapsed = $state(false);
|
||||
|
||||
let famSlug = $derived(page.params.fam);
|
||||
let memberName = $derived(session?.memberName || page.params.username || '');
|
||||
|
||||
function toggle() { collapsed = !collapsed; }
|
||||
|
||||
let showChildItems = $derived(!!memberName);
|
||||
|
||||
let navItems = $derived(isParent && memberName
|
||||
? [
|
||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||
{ href: `/${famSlug}/${memberName}/chores`, label: 'Chores', icon: choresIcon },
|
||||
{ href: `/${famSlug}/${memberName}/rewards`, label: 'Rewards', icon: rewardsIcon },
|
||||
{ href: `/${famSlug}/${memberName}/bonuses`, label: 'Bonuses', icon: bonusesIcon },
|
||||
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
|
||||
]
|
||||
: showChildItems
|
||||
? [
|
||||
{ href: `/${famSlug}/${memberName}`, label: 'Dashboard', icon: dashboardIcon },
|
||||
{ href: `/${famSlug}/${memberName}/preferences`, label: 'Preferences', icon: prefsIcon },
|
||||
]
|
||||
: []
|
||||
);
|
||||
|
||||
let footerItems = $derived([
|
||||
{ href: `/${famSlug}`, label: famName, icon: homeIcon },
|
||||
...(isParent && memberName ? [{ href: `/${famSlug}/${memberName}/settings`, label: 'Settings', icon: settingsIcon }] : []),
|
||||
{ href: session ? '/logout' : '/login', label: session ? 'Log out' : 'Log in', icon: logoutIcon },
|
||||
]);
|
||||
</script>
|
||||
|
||||
<aside class="sidebar" class:collapsed>
|
||||
<button class="toggle-btn" onclick={toggle}>
|
||||
{collapsed ? '☰' : '✕'}
|
||||
</button>
|
||||
|
||||
<div class="sidebar-header">
|
||||
<span class="app-icon">✦</span>
|
||||
{#if !collapsed}<span class="app-name">FamChore</span>{/if}
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
{#each navItems as item}
|
||||
<a href={item.href} class="nav-item" class:active={page.url.pathname === item.href}>
|
||||
{@html item.icon}
|
||||
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
{#each footerItems as item}
|
||||
<a href={item.href} class="nav-item">
|
||||
{@html item.icon}
|
||||
{#if !collapsed}<span class="nav-label">{item.label}</span>{/if}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<style>
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0; left: 0;
|
||||
height: 100vh;
|
||||
width: 220px;
|
||||
background: #1e1b4b;
|
||||
color: #e0e7ff;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: width 0.2s;
|
||||
z-index: 100;
|
||||
overflow: hidden;
|
||||
}
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
.toggle-btn {
|
||||
position: absolute;
|
||||
top: 0.5rem; right: 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
color: #a5b4fc;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
.sidebar-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem 0.75rem;
|
||||
border-bottom: 1px solid #3730a3;
|
||||
min-height: 52px;
|
||||
}
|
||||
.app-icon { font-size: 1.3rem; flex-shrink: 0; }
|
||||
.app-name { font-weight: 700; font-size: 1.05rem; white-space: nowrap; }
|
||||
.sidebar-nav {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.sidebar-footer {
|
||||
border-top: 1px solid #3730a3;
|
||||
padding: 0.5rem 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
.nav-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
color: #c7d2fe;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 6px;
|
||||
margin: 0 0.3rem;
|
||||
white-space: nowrap;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-item:hover { background: #3730a3; color: #e0e7ff; }
|
||||
.nav-item.active { background: #4338ca; color: #fff; font-weight: 600; }
|
||||
.nav-label { overflow: hidden; }
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
let { announcement = '', children }: { announcement?: string; children?: any } = $props();
|
||||
</script>
|
||||
|
||||
<header class="topnav">
|
||||
<div class="topnav-announcement">
|
||||
{#if announcement}<span class="announcement-text">{announcement}</span>{/if}
|
||||
</div>
|
||||
<div class="topnav-actions">
|
||||
{@render children?.()}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<style>
|
||||
.topnav {
|
||||
position: fixed;
|
||||
top: 0; left: 220px; right: 0;
|
||||
height: 48px;
|
||||
background: #fff;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 1.5rem;
|
||||
z-index: 90;
|
||||
transition: left 0.2s;
|
||||
}
|
||||
.topnav-announcement { flex: 1; text-align: center; }
|
||||
.announcement-text { font-size: 0.85rem; color: #6b7280; }
|
||||
.topnav-actions { display: flex; align-items: center; gap: 0.5rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,71 @@
|
||||
<script lang="ts">
|
||||
let { title, subtitle, tabs, weeknav, sort }: {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
tabs?: { items: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
|
||||
weeknav?: { current: string; onPrev: () => void; onNext: () => void };
|
||||
sort?: { options: { label: string; value: string }[]; active: string; onchange: (v: string) => void };
|
||||
} = $props();
|
||||
</script>
|
||||
|
||||
<div class="view-header">
|
||||
<div class="view-title-group">
|
||||
<h1 class="view-title">{title}</h1>
|
||||
{#if subtitle}<p class="view-subtitle">{subtitle}</p>{/if}
|
||||
</div>
|
||||
|
||||
<div class="view-tools">
|
||||
{#if tabs}
|
||||
<div class="tab-bar">
|
||||
{#each tabs.items as tab}
|
||||
<button
|
||||
class="tab-btn"
|
||||
class:active={tab.value === tabs.active}
|
||||
onclick={() => tabs.onchange(tab.value)}
|
||||
>{tab.label}</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if weeknav}
|
||||
<div class="week-nav">
|
||||
<button class="nav-btn" onclick={weeknav.onPrev}>‹</button>
|
||||
<span class="nav-label">{weeknav.current}</span>
|
||||
<button class="nav-btn" onclick={weeknav.onNext}>›</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if sort}
|
||||
<select class="sort-select" value={sort.active} onchange={(e) => sort.onchange(e.currentTarget.value)}>
|
||||
{#each sort.options as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.view-header { margin-bottom: 1.5rem; }
|
||||
.view-title-group { margin-bottom: 0.5rem; }
|
||||
.view-title { font-size: 1.4rem; font-weight: 700; color: #111827; margin: 0; }
|
||||
.view-subtitle { font-size: 0.9rem; color: #6b7280; margin: 0.25rem 0 0 0; }
|
||||
.view-tools { display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
|
||||
.tab-bar { display: flex; gap: 2px; background: #f3f4f6; border-radius: 6px; padding: 2px; }
|
||||
.tab-btn {
|
||||
padding: 0.35rem 0.85rem; border: none; background: transparent; border-radius: 5px;
|
||||
font-size: 0.85rem; color: #6b7280; cursor: pointer;
|
||||
}
|
||||
.tab-btn.active { background: #fff; color: #4338ca; font-weight: 600; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
||||
.week-nav { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.nav-btn {
|
||||
padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; background: #fff; border-radius: 4px;
|
||||
cursor: pointer; font-size: 1rem; line-height: 1;
|
||||
}
|
||||
.nav-btn:hover { background: #f3f4f6; }
|
||||
.nav-label { font-size: 0.85rem; color: #374151; font-weight: 500; min-width: 140px; text-align: center; }
|
||||
.sort-select {
|
||||
padding: 0.3rem 0.6rem; border: 1px solid #d1d5db; border-radius: 5px;
|
||||
font-size: 0.85rem; background: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
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 bonusesIcon = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></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>'
|
||||
@@ -0,0 +1,8 @@
|
||||
export { default as Sidebar } from './Sidebar.svelte';
|
||||
export { default as TopNav } from './TopNav.svelte';
|
||||
export { default as Footer } from './Footer.svelte';
|
||||
export { default as ViewHeader } from './ViewHeader.svelte';
|
||||
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';
|
||||
@@ -0,0 +1,9 @@
|
||||
export async function sha256(input: string): Promise<string> {
|
||||
const hash = await globalThis.crypto.subtle.digest(
|
||||
'SHA-256',
|
||||
new TextEncoder().encode(input),
|
||||
);
|
||||
return Array.from(new Uint8Array(hash))
|
||||
.map((b) => b.toString(16).padStart(2, '0'))
|
||||
.join('');
|
||||
}
|
||||
@@ -1,5 +1,14 @@
|
||||
import PocketBase from 'pocketbase';
|
||||
import { SERVER_IP, PB_PORT } from '$app/env/public';
|
||||
import { PUBLIC_PB_URL } from '$app/env/public';
|
||||
|
||||
const url = `http://${SERVER_IP}:${PB_PORT}`;
|
||||
export const pb: PocketBase = new PocketBase(url);
|
||||
export const pb = new PocketBase(PUBLIC_PB_URL);
|
||||
pb.autoCancellation(false);
|
||||
|
||||
export function initPbFromCookie() {
|
||||
const match = document.cookie.match(/(?:^|;\s*)pb_token=([^;]*)/);
|
||||
if (match) {
|
||||
pb.authStore.save(match[1], null);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
||||
|
||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||
|
||||
export function getSession(event: RequestEvent) {
|
||||
return event.locals.session;
|
||||
}
|
||||
|
||||
export function requireAuth(event: RequestEvent) {
|
||||
const session = getSession(event);
|
||||
if (!session) {
|
||||
throw redirect(303, '/login');
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
export async function signup(email: string, password: string, famName: string, parentName?: string) {
|
||||
const res = await fetch(`${HONO_URL}/api/admin/signup`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password, famName, parentName }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Signup failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function login(email: string, password: string) {
|
||||
console.log(email);
|
||||
const res = await fetch(`${HONO_URL}/api/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Login failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function joinMember(inviteCode: string, name: string, deviceToken: string) {
|
||||
const res = await fetch(`${HONO_URL}/api/members/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inviteCode, name, deviceToken }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Join failed');
|
||||
return data;
|
||||
}
|
||||
|
||||
export function setSessionCookie(event: RequestEvent, session: { famId: string; userId: string; famSlug: string }) {
|
||||
event.cookies.set('session', JSON.stringify(session), {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 30,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function setDeviceTokenCookie(event: RequestEvent, token: string) {
|
||||
event.cookies.set('device_token', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24 * 365,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function setPbTokenCookie(event: RequestEvent, token: string) {
|
||||
event.cookies.set('pb_token', token, {
|
||||
httpOnly: false,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
maxAge: 60 * 60 * 24,
|
||||
secure: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSession(event: RequestEvent) {
|
||||
event.cookies.delete('session', { path: '/' });
|
||||
event.cookies.delete('pb_token', { path: '/' });
|
||||
event.cookies.delete('device_token', { path: '/' });
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../config.ts';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||
|
||||
function sessionHeaders(event: RequestEvent): Record<string, string> {
|
||||
const s = event.locals.session;
|
||||
if (!s) return {};
|
||||
return {
|
||||
'x-session-famid': s.famId,
|
||||
'x-session-userid': s.userId,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
async function request(method: string, path: string, body?: unknown, headers?: Record<string, string>) {
|
||||
const res = await fetch(`${HONO_URL}${path}`, {
|
||||
method,
|
||||
headers: headers || { 'Content-Type': 'application/json' },
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || `${method} ${path} failed`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export const hono = {
|
||||
admin: {
|
||||
async list(event: RequestEvent, resource: string, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/${resource}`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async create(event: RequestEvent, resource: string, famId: string, data: Record<string, unknown>) {
|
||||
return request('POST', `/api/admin/${famId}/${resource}`, data, sessionHeaders(event));
|
||||
},
|
||||
async update(event: RequestEvent, resource: string, famId: string, id: string, data: Record<string, unknown>) {
|
||||
return request('PATCH', `/api/admin/${famId}/${resource}/${id}`, data, sessionHeaders(event));
|
||||
},
|
||||
async remove(event: RequestEvent, resource: string, famId: string, id: string) {
|
||||
return request('DELETE', `/api/admin/${famId}/${resource}/${id}`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async renameFam(event: RequestEvent, famId: string, name: string) {
|
||||
return request('PATCH', `/api/admin/${famId}/fam`, { name }, sessionHeaders(event));
|
||||
},
|
||||
async updatePayday(event: RequestEvent, famId: string, payday: number) {
|
||||
return request('PATCH', `/api/admin/${famId}/fam`, { payday }, sessionHeaders(event));
|
||||
},
|
||||
async fam(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/fam`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async verify(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/verify`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async regenInvite(event: RequestEvent, famId: string) {
|
||||
return request('POST', `/api/admin/${famId}/regen-invite`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async weeklySummary(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/weekly-summary`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async completions(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/completions`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async rewards(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/rewards`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async claimReward(event: RequestEvent, famId: string, rewardId: string) {
|
||||
return request('POST', `/api/admin/${famId}/rewards/${rewardId}/claim`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async bonusConfigs(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/bonus-configs`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async bonusConfigProgress(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/bonus-configs/progress`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async evaluateBonusConfig(event: RequestEvent, famId: string, configId?: string) {
|
||||
return request('POST', `/api/admin/${famId}/bonus-configs/evaluate`, { configId }, sessionHeaders(event));
|
||||
},
|
||||
async triggerBonusConfig(event: RequestEvent, famId: string, configId: string, memberId?: string) {
|
||||
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/trigger`, { memberId }, sessionHeaders(event));
|
||||
},
|
||||
async assignBonusConfig(event: RequestEvent, famId: string, configId: string, data: Record<string, unknown>) {
|
||||
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/assign`, data, sessionHeaders(event));
|
||||
},
|
||||
async completeBonusConfig(event: RequestEvent, famId: string, configId: string, phase?: string) {
|
||||
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/complete`, { phase }, sessionHeaders(event));
|
||||
},
|
||||
async destroyBonusConfig(event: RequestEvent, famId: string, configId: string) {
|
||||
return request('POST', `/api/admin/${famId}/bonus-configs/${configId}/destroy`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async bonusConfigTallies(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/bonus-configs/tallies`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async revokeCompletion(event: RequestEvent, famId: string, completionId: string) {
|
||||
return request('POST', `/api/admin/${famId}/completions/${completionId}/revoke`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async sendMessage(event: RequestEvent, famId: string, memberId: string, message: string) {
|
||||
return request('POST', `/api/admin/${famId}/send-message`, { memberId, message }, sessionHeaders(event));
|
||||
},
|
||||
async memberChores(event: RequestEvent, famId: string, memberId: string) {
|
||||
return request('GET', `/api/admin/${famId}/members/${memberId}/chores`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async updateMember(event: RequestEvent, famId: string, memberId: string, data: Record<string, unknown>) {
|
||||
return request('PATCH', `/api/admin/${famId}/members/${memberId}`, data, sessionHeaders(event));
|
||||
},
|
||||
async getProfile(event: RequestEvent, famId: string) {
|
||||
return request('GET', `/api/admin/${famId}/profile`, undefined, sessionHeaders(event));
|
||||
},
|
||||
async updateProfile(event: RequestEvent, famId: string, data: Record<string, unknown>) {
|
||||
return request('PATCH', `/api/admin/${famId}/profile`, data, sessionHeaders(event));
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,45 @@
|
||||
import { SERVER_IP, PB_PORT, PB_EMAIL, PB_PASSWORD } from '../../../../config.ts';
|
||||
|
||||
const PB_ENDPOINT = `http://${SERVER_IP}:${PB_PORT}`;
|
||||
|
||||
let token: string | null = null;
|
||||
let tokenExpiry = 0;
|
||||
|
||||
async function ensureToken(): Promise<string> {
|
||||
if (token && Date.now() < tokenExpiry) return token;
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/_superusers/auth-with-password`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ identity: PB_EMAIL, password: PB_PASSWORD }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`PB admin auth failed: ${JSON.stringify(data)}`);
|
||||
token = data.token;
|
||||
tokenExpiry = Date.now() + 23 * 60 * 60 * 1000;
|
||||
return token!;
|
||||
}
|
||||
|
||||
export const pbAdmin = {
|
||||
async getList(collection: string, filter = '') {
|
||||
const t = await ensureToken();
|
||||
const params = new URLSearchParams();
|
||||
if (filter) params.set('filter', filter);
|
||||
params.set('perPage', '200');
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records?${params}`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`PB list ${collection}: ${JSON.stringify(data)}`);
|
||||
return data.items || [];
|
||||
},
|
||||
|
||||
async getOne(collection: string, id: string) {
|
||||
const t = await ensureToken();
|
||||
const res = await fetch(`${PB_ENDPOINT}/api/collections/${collection}/records/${id}`, {
|
||||
headers: { Authorization: `Bearer ${t}` },
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(`PB get ${collection}/${id}: ${JSON.stringify(data)}`);
|
||||
return data;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,155 @@
|
||||
import { pb } from '$lib/pocketbase';
|
||||
import type {
|
||||
Member, ChoreTemplate, AssignedChore, Completion,
|
||||
WeeklyHistory, Reward, BonusConfig, Fam,
|
||||
} from '$lib/types';
|
||||
|
||||
type CollectionName = 'members' | 'chore_templates' | 'assigned_chores' | 'completions' | 'bonus_configs' | 'rewards';
|
||||
|
||||
class FamStore {
|
||||
fam = $state<Fam | null>(null)
|
||||
members = $state<Member[]>([])
|
||||
templates = $state<ChoreTemplate[]>([])
|
||||
assigned = $state<AssignedChore[]>([])
|
||||
completions = $state<Completion[]>([])
|
||||
history = $state<WeeklyHistory[]>([])
|
||||
rewards = $state<Reward[]>([])
|
||||
bonusConfigs = $state<BonusConfig[]>([])
|
||||
initialized = $state(false)
|
||||
famId = $state('')
|
||||
|
||||
private unsubs: (() => void)[] = []
|
||||
private destroyed = false
|
||||
|
||||
memberMap(): Map<string, Member> {
|
||||
return new Map(this.members.map((m) => [m.id, m]))
|
||||
}
|
||||
|
||||
templateMap(): Map<string, ChoreTemplate> {
|
||||
return new Map(this.templates.map((t) => [t.id, t]))
|
||||
}
|
||||
|
||||
bonusConfigMap(): Map<string, BonusConfig> {
|
||||
return new Map(this.bonusConfigs.map((b) => [b.id, b]))
|
||||
}
|
||||
|
||||
assignedForMember(memberId: string): AssignedChore[] {
|
||||
return this.assigned.filter((a) => a.memberId === memberId)
|
||||
}
|
||||
|
||||
completionsForDate(date: string): Completion[] {
|
||||
return this.completions.filter((c) => (c.date?.slice(0, 10) || c.date) === date)
|
||||
}
|
||||
|
||||
isCompleted(assignedChoreId: string, date: string): boolean {
|
||||
return this.completions.some((c) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === date)
|
||||
}
|
||||
|
||||
private initPromise: Promise<void> | null = null
|
||||
|
||||
async init(famId: string) {
|
||||
if (this.initialized && this.famId === famId) return
|
||||
|
||||
// Wait for any in-flight init to finish first
|
||||
if (this.initPromise) {
|
||||
await this.initPromise
|
||||
if (this.initialized && this.famId === famId) return
|
||||
}
|
||||
|
||||
this.cleanup()
|
||||
this.destroyed = false
|
||||
this.famId = famId
|
||||
|
||||
this.initPromise = (async () => {
|
||||
try {
|
||||
const [famRes, membersRes, templatesRes, assignedRes, completionsRes, bonusConfigsRes, rewardsRes] =
|
||||
await Promise.all([
|
||||
pb.collection('fams').getOne(famId) as Promise<Fam>,
|
||||
pb.collection('members').getFullList({ filter: `famId = '${famId}'` }) as Promise<Member[]>,
|
||||
pb.collection('chore_templates').getFullList({ filter: `famId = '${famId}'` }) as Promise<ChoreTemplate[]>,
|
||||
pb.collection('assigned_chores').getFullList({ filter: `famId = '${famId}'` }) as Promise<AssignedChore[]>,
|
||||
pb.collection('completions').getFullList({ filter: `famId = '${famId}'` }) as Promise<Completion[]>,
|
||||
pb.collection('bonus_configs').getFullList({ filter: `famId = '${famId}'` }) as Promise<BonusConfig[]>,
|
||||
pb.collection('rewards').getFullList({ filter: `famId = '${famId}'` }) as Promise<Reward[]>,
|
||||
])
|
||||
this.fam = famRes
|
||||
this.members = membersRes
|
||||
this.templates = templatesRes
|
||||
this.assigned = assignedRes
|
||||
this.completions = completionsRes
|
||||
this.bonusConfigs = bonusConfigsRes
|
||||
this.rewards = rewardsRes
|
||||
this.initialized = true
|
||||
} catch (e) {
|
||||
console.error('FamStore.init failed:', e)
|
||||
this.initPromise = null
|
||||
throw e
|
||||
}
|
||||
|
||||
await this.subscribe()
|
||||
this.initPromise = null
|
||||
})()
|
||||
|
||||
return this.initPromise!
|
||||
}
|
||||
|
||||
private async subscribe() {
|
||||
const subs: { collection: CollectionName; filter?: string }[] = [
|
||||
{ collection: 'members', filter: this.famId },
|
||||
{ collection: 'chore_templates', filter: this.famId },
|
||||
{ collection: 'assigned_chores', filter: this.famId },
|
||||
{ collection: 'completions', filter: this.famId },
|
||||
{ collection: 'bonus_configs', filter: this.famId },
|
||||
{ collection: 'rewards', filter: this.famId },
|
||||
]
|
||||
|
||||
const promises = subs.map(({ collection, filter }) => {
|
||||
const filterStr = filter ? `famId = '${filter}'` : ''
|
||||
return pb.collection(collection).subscribe('*', (data: any) => {
|
||||
if (this.destroyed) return
|
||||
this.handleRealtime(collection, data.action, data.record)
|
||||
}, { filter: filterStr || undefined }).then((unsub) => {
|
||||
if (this.destroyed) { unsub(); return }
|
||||
this.unsubs.push(unsub)
|
||||
}).catch((err: Error) => {
|
||||
console.error(`[famStore] subscribe failed for ${collection}:`, err)
|
||||
})
|
||||
})
|
||||
|
||||
await Promise.allSettled(promises)
|
||||
}
|
||||
|
||||
// Called from form action callbacks for instant UI feedback,
|
||||
// and from PB subscribe SSE for multi-user realtime.
|
||||
applyRecord(collection: CollectionName, record: any, action: 'create' | 'update' | 'delete') {
|
||||
const apply = <T extends { id: string }>(list: T[]): T[] => {
|
||||
if (action === 'create') return [record, ...list]
|
||||
if (action === 'update') return list.map((x) => (x.id === record.id ? { ...x, ...record } : x))
|
||||
if (action === 'delete') return list.filter((x) => x.id !== record.id)
|
||||
return list
|
||||
}
|
||||
|
||||
switch (collection) {
|
||||
case 'members': this.members = apply(this.members); break
|
||||
case 'chore_templates': this.templates = apply(this.templates); break
|
||||
case 'assigned_chores': this.assigned = apply(this.assigned); break
|
||||
case 'completions': this.completions = apply(this.completions); break
|
||||
case 'bonus_configs': this.bonusConfigs = apply(this.bonusConfigs); break
|
||||
case 'rewards': this.rewards = apply(this.rewards); break
|
||||
}
|
||||
}
|
||||
|
||||
private handleRealtime(collection: string, action: string, record: any) {
|
||||
this.applyRecord(collection as CollectionName, record, action as 'create' | 'update' | 'delete')
|
||||
}
|
||||
|
||||
cleanup() {
|
||||
// TODO - we need to properly unsubscribe from pocketbase
|
||||
this.destroyed = true
|
||||
for (const unsub of this.unsubs) unsub()
|
||||
this.unsubs = []
|
||||
this.initialized = false
|
||||
}
|
||||
}
|
||||
|
||||
export const famStore = new FamStore()
|
||||
@@ -0,0 +1,142 @@
|
||||
export type Frequency = 'daily' | 'weekly'
|
||||
export type RewardType = 'points' | 'money'
|
||||
export type BonusTarget = 'individual' | 'competitive' | 'collaborative'
|
||||
export type BonusType = 'threshold' | 'count' | 'manual'
|
||||
export type BonusOccurrence = 'recurring' | 'once'
|
||||
export type BonusRewardType = 'points' | 'cash' | 'prize'
|
||||
export type BonusPeriod = 'weekly' | 'monthly'
|
||||
export type BonusStatus = 'active' | 'archived'
|
||||
export type BonusState = 'pending' | 'unclaimed' | 'claimed'
|
||||
|
||||
export interface Fam {
|
||||
id: string
|
||||
name: string
|
||||
slug: string
|
||||
inviteCode: string
|
||||
stripeCustomerId?: string
|
||||
featureFlags: Record<string, boolean>
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface Member {
|
||||
id: string
|
||||
famId: string
|
||||
name: string
|
||||
color: string
|
||||
deviceToken: string
|
||||
deviceTokenHint: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface ChoreTemplate {
|
||||
id: string
|
||||
famId: string
|
||||
name: string
|
||||
description?: string
|
||||
defaultFrequency: Frequency
|
||||
defaultType: RewardType
|
||||
defaultValue: number
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface AssignedChore {
|
||||
id: string
|
||||
famId: string
|
||||
memberId: string
|
||||
templateId: string
|
||||
frequency: Frequency
|
||||
type: RewardType
|
||||
value: number
|
||||
customName?: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface Completion {
|
||||
id: string
|
||||
famId: string
|
||||
memberId: string
|
||||
assignedChoreId: string
|
||||
date: string
|
||||
completedAt: string
|
||||
}
|
||||
|
||||
export interface WeeklyHistory {
|
||||
id: string
|
||||
famId: string
|
||||
memberId: string
|
||||
weekStart: string
|
||||
pointsEarned: number
|
||||
moneyEarned: number
|
||||
choresCompleted: number
|
||||
bonusEarned: number
|
||||
}
|
||||
|
||||
export interface BonusConfig {
|
||||
id: string
|
||||
famId: string
|
||||
name: string
|
||||
description?: string
|
||||
target: BonusTarget
|
||||
memberId?: string
|
||||
type: BonusType
|
||||
occurrence: BonusOccurrence
|
||||
rewardType: BonusRewardType
|
||||
rewardValue: string
|
||||
criteriaValue?: number
|
||||
period?: BonusPeriod
|
||||
status: BonusStatus
|
||||
phase?: 'template' | 'ready' | 'active' | 'completed'
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface Reward {
|
||||
id: string
|
||||
famId: string
|
||||
memberId: string
|
||||
bonusConfigId?: string
|
||||
label: string
|
||||
value: number
|
||||
rewardType: BonusRewardType
|
||||
claimed: boolean
|
||||
claimedAt?: string
|
||||
date: string
|
||||
created: string
|
||||
updated: string
|
||||
}
|
||||
|
||||
export interface BonusProgress {
|
||||
memberId: string
|
||||
memberName: string
|
||||
memberColor: string
|
||||
current: number
|
||||
criteriaValue: number
|
||||
reward: { id: string; claimed: boolean } | null
|
||||
state: BonusState
|
||||
achieved: boolean
|
||||
}
|
||||
|
||||
export interface BonusConfigWithProgress {
|
||||
config: BonusConfig
|
||||
progress: BonusProgress[]
|
||||
periodStart: string
|
||||
periodEnd: string
|
||||
}
|
||||
|
||||
export interface Settings {
|
||||
id: string
|
||||
famId: string
|
||||
webhookUrl?: string
|
||||
}
|
||||
|
||||
export interface Session {
|
||||
famId: string
|
||||
userId: string
|
||||
famSlug: string
|
||||
memberName?: string
|
||||
role?: string
|
||||
}
|
||||
Reference in New Issue
Block a user