update ui and other frontend updates
This commit is contained in:
Vendored
-3
@@ -1,3 +0,0 @@
|
||||
// this file is generated — do not edit it
|
||||
|
||||
/// <reference types="@sveltejs/kit" />
|
||||
Vendored
-11
@@ -1,11 +0,0 @@
|
||||
// This file is generated from ../src/env.ts.
|
||||
// See https://svelte.dev/docs/kit/environment-variables for more information
|
||||
|
||||
declare module '$app/env/private' {
|
||||
export const DEBUG_RECORD_ID: string;
|
||||
}
|
||||
|
||||
declare module '$app/env/public' {
|
||||
export const SERVER_IP: string;
|
||||
export const PB_PORT: string;
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
export { matchers } from './matchers.js';
|
||||
|
||||
export const nodes = [
|
||||
() => import('./nodes/0'),
|
||||
() => import('./nodes/1'),
|
||||
() => import('./nodes/2'),
|
||||
() => import('./nodes/3')
|
||||
];
|
||||
|
||||
export const server_loads = [];
|
||||
|
||||
export const dictionary = {
|
||||
"/": [2],
|
||||
"/debug": [~3]
|
||||
};
|
||||
|
||||
export const hooks = {
|
||||
handleError: (({ error }) => { console.error(error) }),
|
||||
|
||||
reroute: (() => {}),
|
||||
transport: {}
|
||||
};
|
||||
|
||||
export const decoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.decode]));
|
||||
export const encoders = Object.fromEntries(Object.entries(hooks.transport).map(([k, v]) => [k, v.encode]));
|
||||
|
||||
export const hash = false;
|
||||
|
||||
export const decode = (type, value) => decoders[type](value);
|
||||
|
||||
export { default as root } from '../root.js';
|
||||
@@ -1 +0,0 @@
|
||||
export const matchers = {};
|
||||
@@ -1 +0,0 @@
|
||||
export { default as component } from "../../../../src/routes/+layout.svelte";
|
||||
@@ -1 +0,0 @@
|
||||
export { default as component } from "../../../../../node_modules/.pnpm/@sveltejs+kit@3.0.0-next.4_@sveltejs+vite-plugin-svelte@7.1.2_svelte@5.56.3_vite@8.0.16_831df308509456bf75377b19fdb5b8d4/node_modules/@sveltejs/kit/src/runtime/components/error.svelte";
|
||||
@@ -1 +0,0 @@
|
||||
export { default as component } from "../../../../src/routes/+page.svelte";
|
||||
@@ -1 +0,0 @@
|
||||
export { default as component } from "../../../../src/routes/debug/+page.svelte";
|
||||
@@ -1,3 +0,0 @@
|
||||
import { asClassComponent } from 'svelte/legacy';
|
||||
import Root from './root.svelte';
|
||||
export default asClassComponent(Root);
|
||||
@@ -1,75 +0,0 @@
|
||||
<!-- This file is generated by @sveltejs/kit — do not edit it! -->
|
||||
<svelte:options runes={true} />
|
||||
<script>
|
||||
import { setContext, onMount, tick } from 'svelte';
|
||||
import { browser } from '$app/env';
|
||||
|
||||
// stores
|
||||
let { stores, page, constructors, components = [], form, errors = [], error, data_0 = null, data_1 = null } = $props();
|
||||
let data = $derived({'0': data_0, '1': data_1})
|
||||
|
||||
if (browser) {
|
||||
$effect.pre(() => stores.page.set(page));
|
||||
} else {
|
||||
// svelte-ignore state_referenced_locally
|
||||
setContext('__svelte__', stores);
|
||||
// svelte-ignore state_referenced_locally
|
||||
stores.page.set(page);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
stores;page;constructors;components;form;errors;error;data_0;data_1;
|
||||
stores.page.notify();
|
||||
});
|
||||
|
||||
let mounted = $state(false);
|
||||
let navigated = $state(false);
|
||||
let title = $state(null);
|
||||
|
||||
onMount(() => {
|
||||
const unsubscribe = stores.page.subscribe(() => {
|
||||
if (mounted) {
|
||||
navigated = true;
|
||||
tick().then(() => {
|
||||
title = document.title || 'untitled page';
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
mounted = true;
|
||||
return unsubscribe;
|
||||
});
|
||||
|
||||
const Pyramid_1 = $derived(constructors[1]);
|
||||
</script>
|
||||
|
||||
{#snippet pyramid(depth)}
|
||||
{@const Pyramid = constructors[depth]}
|
||||
{#snippet failed(error)}
|
||||
{@const ErrorPage = errors[depth]}
|
||||
<ErrorPage {error} />
|
||||
{/snippet}
|
||||
<svelte:boundary failed={errors[depth] ? failed : undefined}>
|
||||
{#if constructors[depth + 1]}
|
||||
{@const d = data[depth]}
|
||||
<!-- svelte-ignore binding_property_non_reactive -->
|
||||
<Pyramid bind:this={components[depth]} data={d} {form} params={page.params}>
|
||||
{@render pyramid(depth + 1)}
|
||||
</Pyramid>
|
||||
{:else}
|
||||
{@const d = data[depth]}
|
||||
<!-- svelte-ignore binding_property_non_reactive -->
|
||||
<Pyramid bind:this={components[depth]} data={d} {form} params={page.params} {error} />
|
||||
{/if}
|
||||
</svelte:boundary>
|
||||
{/snippet}
|
||||
|
||||
{@render pyramid(0)}
|
||||
|
||||
{#if mounted}
|
||||
<div id="svelte-announcer" aria-live="assertive" aria-atomic="true" style="position: absolute; left: 0; top: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; width: 1px; height: 1px">
|
||||
{#if navigated}
|
||||
{title}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -1,52 +0,0 @@
|
||||
|
||||
import root from '../root.js';
|
||||
import { set_building, set_prerendering } from '$app/env/internal';
|
||||
import { set_assets } from '$app/paths/internal/server';
|
||||
import { set_manifest, set_read_implementation } from '__sveltekit/server';
|
||||
import { set_env } from '__sveltekit/env';
|
||||
|
||||
export const options = {
|
||||
app_template_contains_nonce: false,
|
||||
async: true,
|
||||
csp: {"mode":"auto","directives":{"upgrade-insecure-requests":false,"block-all-mixed-content":false},"reportOnly":{"upgrade-insecure-requests":false,"block-all-mixed-content":false}},
|
||||
csrf_check_origin: false,
|
||||
csrf_trusted_origins: ["*"],
|
||||
embedded: false,
|
||||
hash_routing: false,
|
||||
hooks: null, // added lazily, via `get_hooks`
|
||||
link_header_preload: false,
|
||||
root,
|
||||
service_worker: false,
|
||||
service_worker_options: undefined,
|
||||
server_error_boundaries: true,
|
||||
templates: {
|
||||
app: ({ head, body, assets, nonce, env }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />\n\t\t<meta name=\"text-scale\" content=\"scale\" />\n\t\t" + head + "\n\t</head>\n\t<body data-sveltekit-preload-data=\"hover\">\n\t\t<div style=\"display: contents\">" + body + "</div>\n\t</body>\n</html>\n",
|
||||
error: ({ status, message }) => "<!doctype html>\n<html lang=\"en\">\n\t<head>\n\t\t<meta charset=\"utf-8\" />\n\t\t<title>" + message + "</title>\n\n\t\t<style>\n\t\t\tbody {\n\t\t\t\t--bg: white;\n\t\t\t\t--fg: #222;\n\t\t\t\t--divider: #ccc;\n\t\t\t\tbackground: var(--bg);\n\t\t\t\tcolor: var(--fg);\n\t\t\t\tfont-family:\n\t\t\t\t\tsystem-ui,\n\t\t\t\t\t-apple-system,\n\t\t\t\t\tBlinkMacSystemFont,\n\t\t\t\t\t'Segoe UI',\n\t\t\t\t\tRoboto,\n\t\t\t\t\tOxygen,\n\t\t\t\t\tUbuntu,\n\t\t\t\t\tCantarell,\n\t\t\t\t\t'Open Sans',\n\t\t\t\t\t'Helvetica Neue',\n\t\t\t\t\tsans-serif;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tjustify-content: center;\n\t\t\t\theight: 100vh;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t.error {\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t\tmax-width: 32rem;\n\t\t\t\tmargin: 0 1rem;\n\t\t\t}\n\n\t\t\t.status {\n\t\t\t\tfont-weight: 200;\n\t\t\t\tfont-size: 3rem;\n\t\t\t\tline-height: 1;\n\t\t\t\tposition: relative;\n\t\t\t\ttop: -0.05rem;\n\t\t\t}\n\n\t\t\t.message {\n\t\t\t\tborder-left: 1px solid var(--divider);\n\t\t\t\tpadding: 0 0 0 1rem;\n\t\t\t\tmargin: 0 0 0 1rem;\n\t\t\t\tmin-height: 2.5rem;\n\t\t\t\tdisplay: flex;\n\t\t\t\talign-items: center;\n\t\t\t}\n\n\t\t\t.message h1 {\n\t\t\t\tfont-weight: 400;\n\t\t\t\tfont-size: 1em;\n\t\t\t\tmargin: 0;\n\t\t\t}\n\n\t\t\t@media (prefers-color-scheme: dark) {\n\t\t\t\tbody {\n\t\t\t\t\t--bg: #222;\n\t\t\t\t\t--fg: #ddd;\n\t\t\t\t\t--divider: #666;\n\t\t\t\t}\n\t\t\t}\n\t\t</style>\n\t</head>\n\t<body>\n\t\t<div class=\"error\">\n\t\t\t<span class=\"status\">" + status + "</span>\n\t\t\t<div class=\"message\">\n\t\t\t\t<h1>" + message + "</h1>\n\t\t\t</div>\n\t\t</div>\n\t</body>\n</html>\n"
|
||||
},
|
||||
version_hash: "p7lyxi"
|
||||
};
|
||||
|
||||
export async function get_hooks() {
|
||||
let handle;
|
||||
let handleFetch;
|
||||
let handleError;
|
||||
let handleValidationError;
|
||||
let init;
|
||||
|
||||
|
||||
let reroute;
|
||||
let transport;
|
||||
|
||||
|
||||
return {
|
||||
handle,
|
||||
handleFetch,
|
||||
handleError,
|
||||
handleValidationError,
|
||||
init,
|
||||
reroute,
|
||||
transport
|
||||
};
|
||||
}
|
||||
|
||||
export { set_assets, set_building, set_env, set_manifest, set_prerendering, set_read_implementation };
|
||||
-44
@@ -1,44 +0,0 @@
|
||||
|
||||
// this file is generated — do not edit it
|
||||
|
||||
|
||||
declare module "svelte/elements" {
|
||||
export interface HTMLAttributes<T> {
|
||||
'data-sveltekit-keepfocus'?: true | false | '' | undefined | null;
|
||||
'data-sveltekit-noscroll'?: true | false | '' | undefined | null;
|
||||
'data-sveltekit-preload-code'?:
|
||||
| true
|
||||
| false
|
||||
| ''
|
||||
| 'eager'
|
||||
| 'viewport'
|
||||
| 'hover'
|
||||
| 'tap'
|
||||
| undefined
|
||||
| null;
|
||||
'data-sveltekit-preload-data'?: true | false | '' | 'hover' | 'tap' | undefined | null;
|
||||
'data-sveltekit-reload'?: true | false | '' | undefined | null;
|
||||
'data-sveltekit-replacestate'?: true | false | '' | undefined | null;
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
|
||||
declare module "$app/types" {
|
||||
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
|
||||
|
||||
export interface AppTypes {
|
||||
RouteId(): "/" | "/debug";
|
||||
RouteParams(): {
|
||||
|
||||
};
|
||||
LayoutParams(): {
|
||||
"/": Record<string, never>;
|
||||
"/debug": Record<string, never>
|
||||
};
|
||||
Pathname(): "/" | "/debug";
|
||||
ResolvedPathname(): `${"" | `/${string}`}${ReturnType<AppTypes['Pathname']>}`;
|
||||
Asset(): "/robots.txt" | string & {};
|
||||
}
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"$lib": [
|
||||
"../src/lib"
|
||||
],
|
||||
"$lib/*": [
|
||||
"../src/lib/*"
|
||||
],
|
||||
"$app/types": [
|
||||
"./types/index.d.ts"
|
||||
]
|
||||
},
|
||||
"rootDirs": [
|
||||
"..",
|
||||
"./types"
|
||||
],
|
||||
"verbatimModuleSyntax": true,
|
||||
"isolatedModules": true,
|
||||
"lib": [
|
||||
"esnext",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"moduleResolution": "bundler",
|
||||
"module": "esnext",
|
||||
"noEmit": true,
|
||||
"target": "esnext",
|
||||
"types": [
|
||||
"node"
|
||||
]
|
||||
},
|
||||
"include": [
|
||||
"ambient.d.ts",
|
||||
"env.d.ts",
|
||||
"non-ambient.d.ts",
|
||||
"./types/**/$types.d.ts",
|
||||
"../svelte.config.js",
|
||||
"../vite.config.js",
|
||||
"../vite.config.ts",
|
||||
"../src/**/*.js",
|
||||
"../src/**/*.ts",
|
||||
"../src/**/*.svelte",
|
||||
"../test/**/*.js",
|
||||
"../test/**/*.ts",
|
||||
"../test/**/*.svelte",
|
||||
"../tests/**/*.js",
|
||||
"../tests/**/*.ts",
|
||||
"../tests/**/*.svelte"
|
||||
],
|
||||
"exclude": [
|
||||
"../node_modules/**",
|
||||
"../src/service-worker.js",
|
||||
"../src/service-worker/**/*.js",
|
||||
"../src/service-worker.ts",
|
||||
"../src/service-worker/**/*.ts",
|
||||
"../src/service-worker.d.ts",
|
||||
"../src/service-worker/**/*.d.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"/": [],
|
||||
"/debug": [
|
||||
"src/routes/debug/+page.server.ts"
|
||||
]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
|
||||
type RouteParams = { };
|
||||
type RouteId = '/';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
||||
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
||||
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
||||
type PageParentData = EnsureDefined<LayoutData>;
|
||||
type LayoutRouteId = RouteId | "/" | "/debug" | null
|
||||
type LayoutParams = RouteParams & { }
|
||||
type LayoutParentData = EnsureDefined<{}>;
|
||||
|
||||
export type PageServerData = null;
|
||||
export type PageData = Expand<PageParentData>;
|
||||
export type PageProps = { params: RouteParams; data: PageData }
|
||||
export type LayoutServerData = null;
|
||||
export type LayoutData = Expand<LayoutParentData>;
|
||||
export type LayoutProps = { params: LayoutParams; data: LayoutData; children: import("svelte").Snippet }
|
||||
@@ -1,24 +0,0 @@
|
||||
import type * as Kit from '@sveltejs/kit';
|
||||
|
||||
type Expand<T> = T extends infer O ? { [K in keyof O]: O[K] } : never;
|
||||
type MatcherParam<M> = M extends (param : string) => param is (infer U extends string) ? U : string;
|
||||
type RouteParams = { };
|
||||
type RouteId = '/debug';
|
||||
type MaybeWithVoid<T> = {} extends T ? T | void : T;
|
||||
export type RequiredKeys<T> = { [K in keyof T]-?: {} extends { [P in K]: T[K] } ? never : K; }[keyof T];
|
||||
type OutputDataShape<T> = MaybeWithVoid<Omit<App.PageData, RequiredKeys<T>> & Partial<Pick<App.PageData, keyof T & keyof App.PageData>> & Record<string, any>>
|
||||
type EnsureDefined<T> = T extends null | undefined ? {} : T;
|
||||
type OptionalUnion<U extends Record<string, any>, A extends keyof U = U extends U ? keyof U : never> = U extends unknown ? { [P in Exclude<A, keyof U>]?: never } & U : never;
|
||||
export type Snapshot<T = any> = Kit.Snapshot<T>;
|
||||
type PageServerParentData = EnsureDefined<import('../$types.js').LayoutServerData>;
|
||||
type PageParentData = EnsureDefined<import('../$types.js').LayoutData>;
|
||||
|
||||
export type PageServerLoad<OutputData extends OutputDataShape<PageServerParentData> = OutputDataShape<PageServerParentData>> = Kit.ServerLoad<RouteParams, PageServerParentData, OutputData, RouteId>;
|
||||
export type PageServerLoadEvent = Parameters<PageServerLoad>[0];
|
||||
export type ActionData = unknown;
|
||||
export type PageServerData = Expand<OptionalUnion<EnsureDefined<Kit.LoadProperties<Awaited<ReturnType<typeof import('../../../../../src/routes/debug/+page.server.js').load>>>>>>;
|
||||
export type PageData = Expand<Omit<PageParentData, keyof PageServerData> & EnsureDefined<PageServerData>>;
|
||||
export type Action<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Action<RouteParams, OutputData, RouteId>
|
||||
export type Actions<OutputData extends Record<string, any> | void = Record<string, any> | void> = Kit.Actions<RouteParams, OutputData, RouteId>
|
||||
export type PageProps = { params: RouteParams; data: PageData; form: ActionData }
|
||||
export type RequestEvent = Kit.RequestEvent<RouteParams, RouteId>;
|
||||
+2
-1
@@ -3,5 +3,6 @@ import { defineEnvVars } from '@sveltejs/kit/hooks';
|
||||
export const variables = defineEnvVars({
|
||||
DEBUG_RECORD_ID: {},
|
||||
SERVER_IP: {public: true},
|
||||
PB_PORT: {public: true}
|
||||
PB_PORT: {public: true},
|
||||
PUBLIC_PB_URL: {public: true}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
|
||||
const COOKIE_NAME = 'session';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const raw = event.cookies.get(COOKIE_NAME);
|
||||
if (raw) {
|
||||
try {
|
||||
event.locals.session = JSON.parse(raw);
|
||||
} catch {
|
||||
event.cookies.delete(COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
}
|
||||
|
||||
return resolve(event);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
let { error } = $props();
|
||||
</script>
|
||||
|
||||
<div class="error-page">
|
||||
<h1>Something went wrong</h1>
|
||||
<p>{error?.message || 'An unexpected error occurred.'}</p>
|
||||
<p class="hint">Try <a href="/logout">logging out</a> and signing in again.</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.error-page { max-width: 480px; margin: 4rem auto; text-align: center; padding: 2rem; }
|
||||
h1 { font-size: 1.5rem; color: #111827; margin-bottom: 0.5rem; }
|
||||
p { color: #6b7280; }
|
||||
.hint { margin-top: 1.5rem; font-size: 0.9rem; }
|
||||
a { color: #6366f1; }
|
||||
</style>
|
||||
@@ -0,0 +1,4 @@
|
||||
export function load(event) {
|
||||
const session = event.locals.session || null;
|
||||
return { session, isParent: session !== null, role: session?.role || 'child' };
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { onMount } from 'svelte';
|
||||
import { initPbFromCookie } from '$lib/pocketbase';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { Sidebar, TopNav, Footer } from '$lib/components';
|
||||
import type { Session } from '$lib/types';
|
||||
|
||||
let { children, data } = $props();
|
||||
let session = $state<Session | null>(data.session);
|
||||
let isParent = $state(data.isParent);
|
||||
let role = $state(data.role);
|
||||
let famName = $derived(famStore.initialized ? (famStore.fam as any)?.name || page.params.fam : page.params.fam);
|
||||
|
||||
// Claim toast watcher (admin only)
|
||||
let claimToast = $state('');
|
||||
let prevClaimedIds = new Set<string>();
|
||||
let mounted = false;
|
||||
|
||||
$effect(() => {
|
||||
if (!session || !famStore.initialized) return;
|
||||
const rewards = famStore.rewards;
|
||||
if (!mounted) {
|
||||
mounted = true;
|
||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
|
||||
return;
|
||||
}
|
||||
for (const r of rewards) {
|
||||
if (r.claimed && !prevClaimedIds.has(r.id)) {
|
||||
const m = famStore.memberMap().get(r.memberId);
|
||||
const name = m?.name || 'Unknown';
|
||||
claimToast = `Claim made by ${name}`;
|
||||
setTimeout(() => claimToast = '', 5000);
|
||||
}
|
||||
}
|
||||
prevClaimedIds = new Set(rewards.filter((r: any) => r.claimed).map((r: any) => r.id));
|
||||
});
|
||||
|
||||
onMount(() => {
|
||||
initPbFromCookie();
|
||||
if (session) {
|
||||
famStore.init(session.famId);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="app-shell">
|
||||
<Sidebar {famName} session={data.session} {isParent} {role} />
|
||||
<TopNav />
|
||||
<main class="app-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
{#if claimToast}
|
||||
<div class="claim-toast">{claimToast}</div>
|
||||
{/if}
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.claim-toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #059669; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; }
|
||||
.app-shell { min-height: 100vh; display: flex; flex-direction: column; }
|
||||
.app-main {
|
||||
margin-left: 220px;
|
||||
margin-top: 48px;
|
||||
padding: 1.5rem 2rem;
|
||||
flex: 1;
|
||||
transition: margin-left 0.2s;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session;
|
||||
if (!session) return {};
|
||||
|
||||
const famId = session.famId;
|
||||
|
||||
try {
|
||||
const [members, templates, assigned, summary] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.weeklySummary(event, famId),
|
||||
]);
|
||||
return { members, templates, assigned, summary };
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
let { data } = $props();
|
||||
let famSlug = $derived(page.params.fam);
|
||||
|
||||
let summary = $state(data.summary);
|
||||
let donutCanvases = $state<Record<string, HTMLCanvasElement | undefined>>({});
|
||||
let donutCharts: Record<string, Chart> = {};
|
||||
let choresCanvas: HTMLCanvasElement | undefined;
|
||||
let choresChart: Chart | undefined;
|
||||
|
||||
function captureCanvas(node: HTMLCanvasElement, memberId: string) {
|
||||
donutCanvases[memberId] = node;
|
||||
return {
|
||||
destroy() { delete donutCanvases[memberId]; }
|
||||
};
|
||||
}
|
||||
|
||||
let members = $state(data.members);
|
||||
let templates = $state(data.templates);
|
||||
|
||||
$effect(() => {
|
||||
if (data.members?.length) members = data.members;
|
||||
if (data.templates?.length) templates = data.templates;
|
||||
if (famStore.initialized) {
|
||||
if (famStore.members.length) members = famStore.members;
|
||||
if (famStore.templates.length) templates = famStore.templates;
|
||||
}
|
||||
})
|
||||
|
||||
onMount(() => {
|
||||
if (!summary?.summaries?.length) return;
|
||||
|
||||
if (choresCanvas && summary.daysInWeek?.length) {
|
||||
const days = summary.daysInWeek;
|
||||
const labels = days.map((d: string) =>
|
||||
new Date(d + 'T12:00:00').toLocaleDateString('en-GB', { weekday: 'short' })
|
||||
);
|
||||
const datasets = summary.summaries.map((s: any) => ({
|
||||
label: s.memberName,
|
||||
data: days.map((d: string) => s.dayCompletions?.[d] || 0),
|
||||
backgroundColor: s.memberColor || '#6366f1',
|
||||
borderRadius: 4,
|
||||
}));
|
||||
choresChart = new Chart(choresCanvas, {
|
||||
type: 'bar',
|
||||
data: { labels, datasets },
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { display: true }, title: { display: true, text: 'Chores per day' } },
|
||||
scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } } },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
for (const s of summary.summaries) {
|
||||
const canvas = donutCanvases[s.memberId];
|
||||
if (!canvas) continue;
|
||||
const done = s.choresCompleted || 0;
|
||||
const total = s.totalChores || 0;
|
||||
const remaining = Math.max(0, total - done);
|
||||
donutCharts[s.memberId] = new Chart(canvas, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Done', 'Remaining'],
|
||||
datasets: [{
|
||||
data: [done, remaining],
|
||||
backgroundColor: [s.memberColor || '#22c55e', '#e5e7eb'],
|
||||
borderWidth: 0,
|
||||
}],
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
cutout: '70%',
|
||||
plugins: {
|
||||
legend: { display: false },
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: (ctx) => `${ctx.label}: ${ctx.parsed} chores`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
choresChart?.destroy();
|
||||
for (const c of Object.values(donutCharts)) c?.destroy();
|
||||
famStore.cleanup();
|
||||
});
|
||||
</script>
|
||||
|
||||
<h1>🏠 {famSlug}</h1>
|
||||
|
||||
{#if summary?.summaries?.length}
|
||||
<h2>This week</h2>
|
||||
<table>
|
||||
<thead><tr><th>Member</th><th>Points</th><th>Done</th></tr></thead>
|
||||
<tbody>
|
||||
{#each summary.summaries as s}
|
||||
<tr>
|
||||
<td><span style="color:{s.memberColor}">■</span> {s.memberName}</td>
|
||||
<td>{s.pointsEarned}</td>
|
||||
<td>{s.choresCompleted}/{s.totalChores}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="card">
|
||||
<h2>Chores per day</h2>
|
||||
<canvas bind:this={choresCanvas} height="200"></canvas>
|
||||
</div>
|
||||
|
||||
<div class="donut-grid">
|
||||
{#each summary.summaries as s}
|
||||
<div class="donut-card">
|
||||
<canvas use:captureCanvas={s.memberId} height="150" width="150"></canvas>
|
||||
<div class="donut-label" style="color:{s.memberColor}">{s.memberName}</div>
|
||||
<div class="donut-points">{s.pointsEarned} pts</div>
|
||||
<div class="donut-hint">{s.choresCompleted}/{s.totalChores} chores</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<h2>Members ({members?.length || 0})</h2>
|
||||
<div class="member-grid">
|
||||
{#each members || [] as m}
|
||||
<a href="/{famSlug}/{m.name}" class="member-card" style="border-left: 4px solid {m.color}">
|
||||
<strong>{m.name}</strong>
|
||||
<span class="hint">{m.deviceTokenHint || '—'}</span>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if templates?.length}
|
||||
<h2>Templates ({templates.length})</h2>
|
||||
<ul>
|
||||
{#each templates as t}
|
||||
<li>{t.name} — {t.defaultFrequency} — {t.defaultValue} {t.defaultType}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
nav { display: flex; gap: 0.5rem; margin: 1rem 0; }
|
||||
nav a { padding: 0.4rem 0.8rem; background: #6366f1; color: white; border-radius: 4px; text-decoration: none; font-size: 0.9rem; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||
th, td { padding: 0.4rem; border: 1px solid #e5e7eb; text-align: left; }
|
||||
th { background: #f9fafb; font-weight: 600; }
|
||||
.card { margin: 1rem 0; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 8px; background: #f9fafb; }
|
||||
.card h2 { margin: 0 0 0.75rem; font-size: 1rem; }
|
||||
.card canvas { max-height: 50vh; }
|
||||
.donut-grid { display: flex; gap: 1rem; flex-wrap: wrap; margin: 1rem 0; justify-content: center; }
|
||||
.donut-card { display: flex; flex-direction: column; align-items: center; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; min-width: 160px; }
|
||||
.donut-card canvas { max-height: 150px; max-width: 150px; }
|
||||
.donut-label { font-weight: 600; font-size: 0.9rem; margin-top: 0.5rem; }
|
||||
.donut-points { font-size: 1.1rem; font-weight: 700; }
|
||||
.donut-hint { font-size: 0.75rem; color: #9ca3af; }
|
||||
.member-grid { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.member-card { flex: 1; min-width: 160px; padding: 0.8rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 6px; text-decoration: none; color: inherit; display: block; }
|
||||
.member-card strong { display: block; }
|
||||
.hint { font-size: 0.8rem; color: #9ca3af; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { padding: 0.25rem 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,122 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { memberApi } from '$lib/client/api';
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../../config.ts';
|
||||
|
||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session;
|
||||
|
||||
// Parent (session auth) → admin overview
|
||||
if (session) {
|
||||
const famId = session.famId;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
if (session.memberName && session.memberName !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.memberName}`);
|
||||
}
|
||||
const [members, templates, assigned, summary, fam, rewards, bonusConfigs, completions] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
hono.admin.weeklySummary(event, famId),
|
||||
hono.admin.fam(event, famId),
|
||||
hono.admin.rewards(event, famId),
|
||||
hono.admin.bonusConfigs(event, famId),
|
||||
hono.admin.completions(event, famId),
|
||||
]);
|
||||
return { role: 'parent', members, templates, assigned, summary, fam, famSlug, rewards, bonusConfigs, completions };
|
||||
}
|
||||
|
||||
// Child (device token) → kanban
|
||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
|
||||
if (!deviceToken) {
|
||||
return { role: 'child', token: '', memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceToken, famSlug }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.name !== username) {
|
||||
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', memberName: '', memberColor: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
|
||||
}
|
||||
|
||||
let chores: any = {};
|
||||
try {
|
||||
chores = await memberApi.myChores(deviceToken, data.famId);
|
||||
} catch {}
|
||||
|
||||
return {
|
||||
role: 'child',
|
||||
token: deviceToken,
|
||||
memberId: data.memberId,
|
||||
famId: data.famId,
|
||||
verified: true,
|
||||
memberName: data.name,
|
||||
memberColor: data.color,
|
||||
templates: chores.templates || [],
|
||||
assigned: chores.assigned || [],
|
||||
completions: chores.completions || [],
|
||||
rewards: chores.rewards || [],
|
||||
bonusConfigs: chores.bonusConfigs || [],
|
||||
tallies: chores.tallies || {},
|
||||
};
|
||||
} catch {
|
||||
return { role: 'child', token: deviceToken, memberId: '', verified: false, famId: '', templates: [], assigned: [], completions: [], rewards: [], bonusConfigs: [], tallies: {} };
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
claim: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
const message = fd.get('message') as string;
|
||||
try {
|
||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
||||
if (message) {
|
||||
await hono.admin.sendMessage(event, famId, record.memberId, message);
|
||||
}
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||
}
|
||||
},
|
||||
|
||||
revoke: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const completionId = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.revokeCompletion(event, famId, completionId);
|
||||
return { revoked: true, id: completionId };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to revoke' };
|
||||
}
|
||||
},
|
||||
|
||||
trigger: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const configId = fd.get('configId') as string;
|
||||
const memberId = fd.get('memberId') as string;
|
||||
if (!configId) return { error: 'Config ID required' };
|
||||
try {
|
||||
const result = await hono.admin.triggerBonusConfig(event, famId, configId, memberId || undefined);
|
||||
return { records: result.records || [] };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to trigger' };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,813 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { onMount } from 'svelte';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { memberApi } from '$lib/client/api';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import type { AssignedChore, Completion, ChoreTemplate, BonusConfig, Reward } from '$lib/types';
|
||||
|
||||
interface Notification {
|
||||
id: string
|
||||
famId: string
|
||||
memberId: string
|
||||
message: string
|
||||
read: boolean
|
||||
created: string
|
||||
}
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let role = $state(data.role || 'child')
|
||||
let famSlug = $derived(page.params.fam)
|
||||
let username = $derived(page.params.username)
|
||||
|
||||
// ─── Parent View (admin overview) ───
|
||||
let summary = $state(data.summary)
|
||||
let today = $state(new Date().toISOString().slice(0, 10))
|
||||
|
||||
const responseTags = ['👏 well done', '😊 really pleased', '🎯 you deserved that', '💪 great effort', '🙏 thanks']
|
||||
let selectedMessage = $state<Record<string, string>>({})
|
||||
let customMessage = $state<Record<string, string>>({})
|
||||
let toast = $state('')
|
||||
|
||||
let parentMembers = $derived(famStore.initialized ? famStore.members : (data.members || []))
|
||||
let parentTemplates = $derived(famStore.initialized ? famStore.templates : (data.templates || []))
|
||||
let parentAssigned = $derived(famStore.initialized ? famStore.assigned : (data.assigned || []))
|
||||
let parentRewards = $derived(famStore.initialized ? famStore.rewards : (data.rewards || []))
|
||||
let parentCompletions = $derived(famStore.initialized ? famStore.completions : (data.completions || []))
|
||||
let parentBonusConfigs = $derived(famStore.initialized ? famStore.bonusConfigs : (data.bonusConfigs || []))
|
||||
|
||||
function totalChoresFor(memberId: string): number {
|
||||
return parentAssigned.filter((a: any) => a.memberId === memberId).length
|
||||
}
|
||||
|
||||
function todayCompletionsFor(memberId: string) {
|
||||
return parentCompletions.filter((c: any) => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) === today)
|
||||
}
|
||||
|
||||
function choreNameFor(assignedChoreId: string): string {
|
||||
const a = parentAssigned.find((x: any) => x.id === assignedChoreId)
|
||||
if (!a) return '?'
|
||||
const t = parentTemplates.find((x: any) => x.id === a.templateId)
|
||||
return a.customName || t?.name || '?'
|
||||
}
|
||||
|
||||
function memberInSummary(memberId: string) {
|
||||
return summary?.summaries?.find((s: any) => s.memberId === memberId)
|
||||
}
|
||||
|
||||
function claimableRewards() {
|
||||
return parentRewards.filter((r: any) => !r.claimed)
|
||||
}
|
||||
|
||||
function memberClaimable(memberId: string) {
|
||||
return claimableRewards().filter((r: any) => r.memberId === memberId)
|
||||
}
|
||||
|
||||
let _confetti: any;
|
||||
async function fire() {
|
||||
if (!_confetti) _confetti = await import('@hiseb/confetti').then(m => m.default);
|
||||
_confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast = msg;
|
||||
setTimeout(() => toast = '', 4000);
|
||||
}
|
||||
|
||||
function handleResult({ result }, onSuccess = null) {
|
||||
const d = result.data || {};
|
||||
if (d.error) showToast(d.error);
|
||||
else if (result.type === 'success') {
|
||||
if (onSuccess) onSuccess(d);
|
||||
fire();
|
||||
}
|
||||
}
|
||||
|
||||
function rewardLabel(r: any): string {
|
||||
if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`
|
||||
if (r.rewardType === 'points') return `${r.value} pts`
|
||||
return r.label
|
||||
}
|
||||
|
||||
function manualConfigs() {
|
||||
return parentBonusConfigs.filter((b: any) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.type === 'manual')
|
||||
}
|
||||
|
||||
// ─── Child View (kanban) ───
|
||||
let memberId = $state(data.memberId || '')
|
||||
let deviceToken = $state(data.token || '')
|
||||
let famId = $state(data.famId || '')
|
||||
let memberName = $state(data.memberName || '')
|
||||
let memberColor = $state(data.memberColor || '#6366f1')
|
||||
let editingName = $state(false)
|
||||
let nameInput = $state('')
|
||||
let showColorPicker = $state(false)
|
||||
|
||||
let templates = $state(data.templates as ChoreTemplate[])
|
||||
let assigned = $state(data.assigned as AssignedChore[])
|
||||
let completions = $state(data.completions as Completion[])
|
||||
let rewards = $state(data.rewards as Reward[])
|
||||
let bonusConfigs = $state(data.bonusConfigs as BonusConfig[])
|
||||
|
||||
let loading = $state(true)
|
||||
let error = $state('')
|
||||
|
||||
let dailyPending = $state<AssignedChore[]>([])
|
||||
let weeklyPending = $state<AssignedChore[]>([])
|
||||
let completedToday = $state<Completion[]>([])
|
||||
let claimableRewardsChild = $state<Reward[]>([])
|
||||
let requestedRewards = $state<string[]>([])
|
||||
let claimError = $state('')
|
||||
let todayChild = $state(new Date().toISOString().slice(0, 10))
|
||||
let togglingIds = $state<string>('')
|
||||
|
||||
let bonusNotices = $state<Reward[]>([])
|
||||
let totalCash = $state(0)
|
||||
let pendingCash = $state(0)
|
||||
let totalPoints = $state(0)
|
||||
let weeklyTotal = $state(0)
|
||||
let weeklyDone = $state(0)
|
||||
let bonusProgresses = $state<{ config: BonusConfig; current: number; criteria: number; periodStart: string; periodEnd: string }[]>([])
|
||||
|
||||
function weekStartStr(): string {
|
||||
const d = new Date()
|
||||
const day = d.getDay()
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1)
|
||||
d.setDate(diff)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function monthStartStr(): string {
|
||||
const d = new Date()
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
if (role === 'parent') return;
|
||||
|
||||
if (!deviceToken) {
|
||||
deviceToken = localStorage.getItem('deviceToken') || ''
|
||||
} else {
|
||||
localStorage.setItem('deviceToken', deviceToken)
|
||||
}
|
||||
|
||||
if (!deviceToken) {
|
||||
error = 'No device token found. Use the link from your invite.'
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
if (data.verified && data.famId && data.memberId) {
|
||||
memberId = data.memberId
|
||||
famId = data.famId
|
||||
loading = false
|
||||
} else {
|
||||
error = 'Invalid device token. Use the link from your invite.'
|
||||
loading = false
|
||||
return
|
||||
}
|
||||
|
||||
await loadAndDismiss()
|
||||
})
|
||||
|
||||
async function loadAndDismiss() {
|
||||
if (!deviceToken || !famId) return
|
||||
try {
|
||||
const res = await fetch('/api/members/notifications', {
|
||||
headers: {
|
||||
'x-device-token': deviceToken,
|
||||
'x-device-famid': famId,
|
||||
},
|
||||
})
|
||||
if (!res.ok) return
|
||||
const all: Notification[] = await res.json()
|
||||
for (const n of all.filter(n => !n.read)) {
|
||||
await fetch(`/api/members/notifications/${n.id}/dismiss`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-device-token': deviceToken,
|
||||
'x-device-famid': famId,
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (role === 'parent' || !memberId) return
|
||||
const memberChores = assigned.filter(a => a.memberId === memberId)
|
||||
dailyPending = memberChores.filter(a => a.frequency === 'daily' && !isCompleted(a.id, todayChild))
|
||||
weeklyPending = memberChores.filter(a => a.frequency === 'weekly' && !isCompleted(a.id, todayChild))
|
||||
completedToday = completions.filter(c => c.memberId === memberId && c.date?.slice(0, 10) === todayChild)
|
||||
claimableRewardsChild = rewards.filter(r =>
|
||||
r.memberId === memberId && !r.claimed
|
||||
)
|
||||
|
||||
const ws = weekStartStr()
|
||||
const dailyChores = memberChores.filter(a => a.frequency === 'daily')
|
||||
const weeklyChores = memberChores.filter(a => a.frequency === 'weekly')
|
||||
weeklyTotal = dailyChores.length * 7 + weeklyChores.length
|
||||
weeklyDone = completions.filter(
|
||||
c => c.memberId === memberId && (c.date?.slice(0, 10) || c.date) >= ws
|
||||
).length
|
||||
|
||||
const myRewards = rewards.filter(r => r.memberId === memberId)
|
||||
const myCompletions = completions.filter(c => c.memberId === memberId)
|
||||
|
||||
bonusNotices = myRewards.filter(r => r.claimed && r.claimedAt?.slice(0, 10) === r.date)
|
||||
|
||||
totalCash = myCompletions.reduce((sum, c) => {
|
||||
const chore = assigned.find(a => a.id === c.assignedChoreId)
|
||||
return sum + (chore?.type === 'money' ? Number(chore.value) : 0)
|
||||
}, 0) + myRewards.filter(r => r.rewardType === 'cash').reduce((sum, r) => sum + Number(r.value), 0)
|
||||
|
||||
pendingCash = myRewards.filter(r => r.rewardType === 'cash' && !r.claimed).reduce((sum, r) => sum + Number(r.value), 0)
|
||||
|
||||
totalPoints = myCompletions.reduce((sum, c) => {
|
||||
const chore = assigned.find(a => a.id === c.assignedChoreId)
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0)
|
||||
}, 0) + myRewards.filter(r => r.rewardType === 'points').reduce((sum, r) => sum + Number(r.value), 0)
|
||||
|
||||
const activeConfigs = bonusConfigs.filter(
|
||||
(b: BonusConfig) => (b.phase === 'active' || (!b.phase && b.status === 'active')) && b.target === 'individual' && (!b.memberId || b.memberId === memberId)
|
||||
)
|
||||
bonusProgresses = activeConfigs.map(cfg => {
|
||||
const pStart = cfg.period === 'weekly' ? ws : cfg.period === 'monthly' ? monthStartStr() : ''
|
||||
const pEnd = pStart ? (() => {
|
||||
if (cfg.period === 'weekly') {
|
||||
const d = new Date(pStart)
|
||||
d.setDate(d.getDate() + 6)
|
||||
return d.toISOString().slice(0, 10)
|
||||
}
|
||||
if (cfg.period === 'monthly') {
|
||||
const [y, m] = pStart.split('-').map(Number)
|
||||
const lastDay = new Date(y, m, 0).getDate()
|
||||
return `${pStart.slice(0, 7)}-${String(lastDay).padStart(2, '0')}`
|
||||
}
|
||||
return ''
|
||||
})() : ''
|
||||
|
||||
const periodCompletions = pStart
|
||||
? myCompletions.filter(c => (c.date?.slice(0, 10) || c.date) >= pStart && (c.date?.slice(0, 10) || c.date) <= pEnd)
|
||||
: myCompletions
|
||||
|
||||
let current = 0
|
||||
if (cfg.type === 'threshold') {
|
||||
current = periodCompletions.reduce((sum, c) => {
|
||||
const chore = assigned.find(a => a.id === c.assignedChoreId)
|
||||
return sum + (chore?.type === 'points' ? Number(chore.value) : 0)
|
||||
}, 0)
|
||||
} else if (cfg.type === 'count') {
|
||||
current = periodCompletions.length
|
||||
}
|
||||
|
||||
return { config: cfg, current, criteria: cfg.criteriaValue || 0, periodStart: pStart, periodEnd: pEnd }
|
||||
})
|
||||
})
|
||||
|
||||
function isCompleted(assignedChoreId: string, date: string): boolean {
|
||||
return completions.some(c => c.assignedChoreId === assignedChoreId && c.date?.slice(0, 10) === date)
|
||||
}
|
||||
|
||||
function findCompletion(assignedChoreId: string) {
|
||||
const match = (c: Completion) => c.assignedChoreId === assignedChoreId && (c.date?.slice(0, 10) || c.date) === todayChild
|
||||
const optimistic = completions.find(c => match(c) && c.id.startsWith('optimistic-'))
|
||||
return optimistic || completions.find(match)
|
||||
}
|
||||
|
||||
async function toggle(chore: AssignedChore) {
|
||||
if (togglingIds) return
|
||||
togglingIds = chore.id
|
||||
|
||||
const wasCompleted = isCompleted(chore.id, todayChild)
|
||||
if (wasCompleted) {
|
||||
const existing = findCompletion(chore.id)
|
||||
if (existing) {
|
||||
const idx = completions.indexOf(existing)
|
||||
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
|
||||
}
|
||||
} else {
|
||||
completions = [...completions, {
|
||||
id: 'optimistic-' + chore.id,
|
||||
famId,
|
||||
memberId,
|
||||
assignedChoreId: chore.id,
|
||||
date: todayChild,
|
||||
completedAt: new Date().toISOString(),
|
||||
} as Completion]
|
||||
}
|
||||
|
||||
try {
|
||||
await memberApi.toggleCompletion(deviceToken, famId, chore.id, todayChild)
|
||||
completions = completions.filter(c =>
|
||||
c.assignedChoreId !== chore.id || (c.date?.slice(0, 10) || c.date) !== todayChild || c.id.startsWith('optimistic-')
|
||||
)
|
||||
} catch (e) {
|
||||
if (wasCompleted) {
|
||||
completions = [...completions, {
|
||||
id: 'revert-' + chore.id + '-' + Date.now(),
|
||||
famId,
|
||||
memberId,
|
||||
assignedChoreId: chore.id,
|
||||
date: todayChild,
|
||||
completedAt: new Date().toISOString(),
|
||||
} as Completion]
|
||||
} else {
|
||||
const existing = findCompletion(chore.id)
|
||||
if (existing && existing.id.startsWith('optimistic-')) {
|
||||
const idx = completions.indexOf(existing)
|
||||
completions = [...completions.slice(0, idx), ...completions.slice(idx + 1)]
|
||||
}
|
||||
}
|
||||
console.error('Toggle failed:', e)
|
||||
} finally {
|
||||
togglingIds = ''
|
||||
}
|
||||
}
|
||||
|
||||
function templateFor(chore: AssignedChore): ChoreTemplate | undefined {
|
||||
return templates.find(t => t.id === chore.templateId)
|
||||
}
|
||||
|
||||
function choreName(chore: AssignedChore): string {
|
||||
return chore.customName || templateFor(chore)?.name || 'Chore'
|
||||
}
|
||||
|
||||
function rewardLabelChild(r: Reward): string {
|
||||
if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`
|
||||
if (r.rewardType === 'points') return `${r.value} pts`
|
||||
return r.label
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if role === 'parent'}
|
||||
<!-- Parent View: Admin Overview -->
|
||||
<ViewHeader title={famStore.fam?.name || 'Dashboard'} subtitle={summary?.weekStart ? `Week of ${summary.weekStart}` : ''} />
|
||||
|
||||
{#if toast}
|
||||
<div class="toast">{toast}</div>
|
||||
{/if}
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={1} title="Members">
|
||||
{#each parentMembers as m}
|
||||
{@const total = totalChoresFor(m.id)}
|
||||
{@const todays = todayCompletionsFor(m.id)}
|
||||
{@const done = todays.length}
|
||||
{@const pct = total > 0 ? Math.round((done / total) * 100) : 0}
|
||||
{@const s = memberInSummary(m.id)}
|
||||
<div class="member-card">
|
||||
<div class="card-header">
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
<span class="member-name">{m.name}</span>
|
||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||||
</div>
|
||||
<div class="stats">
|
||||
<span>Points: {s?.pointsEarned ?? 0}</span>
|
||||
<span>Money: £{(s?.moneyEarned ?? 0).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="progress-row">
|
||||
<span class="label">Today:</span>
|
||||
<div class="bar-wrap">
|
||||
<div class="bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
<span class="count">{done}/{total}</span>
|
||||
</div>
|
||||
{#if done > 0}
|
||||
<ul class="done-list">
|
||||
{#each todays as c}
|
||||
<li>
|
||||
✅ {choreNameFor(c.assignedChoreId)}
|
||||
<form method="POST" action="?/revoke" use:enhance={() => { return async (args) => handleResult(args, (d) => { if (d.revoked) famStore.applyRecord('completions', { id: d.id }, 'delete'); }); }} class="revoke-form">
|
||||
<input type="hidden" name="id" value={c.id} />
|
||||
<button type="submit" class="revoke-btn" title="Revoke">↩</button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Triggers">
|
||||
{#if manualConfigs().length === 0}
|
||||
<p class="empty">No manual bonus configs.</p>
|
||||
{:else}
|
||||
{#each manualConfigs() as bc}
|
||||
{@const val = bc.rewardType === 'cash' ? `£${(Number(bc.rewardValue) / 100).toFixed(2)}` : bc.rewardType === 'points' ? `${bc.rewardValue} pts` : bc.rewardValue}
|
||||
{@const targeted = bc.memberId ? parentMembers.find((m: any) => m.id === bc.memberId) : null}
|
||||
<div class="trigger-card">
|
||||
<div class="trigger-head">
|
||||
<span class="trigger-name">{bc.name}</span>
|
||||
<span class="trigger-value">{val}</span>
|
||||
</div>
|
||||
{#if targeted}
|
||||
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<input type="hidden" name="memberId" value={targeted.id} />
|
||||
<div class="trigger-row">
|
||||
<span class="trigger-target" style="color:{targeted.color}">■ {targeted.name}</span>
|
||||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<form method="POST" action="?/trigger" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.records) { d.records.forEach((r: any) => famStore.applyRecord('rewards', r, 'create')); } }); }}>
|
||||
<input type="hidden" name="configId" value={bc.id} />
|
||||
<div class="trigger-row">
|
||||
<select name="memberId" class="trigger-select">
|
||||
<option value="">Select member</option>
|
||||
{#each parentMembers as m}
|
||||
<option value={m.id}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Button type="submit" size="sm" variant="primary">Award</Button>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Claims" accent="#f59e0b">
|
||||
{#if claimableRewards().length === 0}
|
||||
<p class="empty">No outstanding claims</p>
|
||||
{:else}
|
||||
{#each parentMembers as m}
|
||||
{@const mc = memberClaimable(m.id)}
|
||||
{#if mc.length > 0}
|
||||
<div class="member-claims">
|
||||
<h4><span class="dot" style="background:{m.color}"></span> {m.name}</h4>
|
||||
{#each mc as r}
|
||||
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => handleResult(args, (d: any) => { if (d.record) { famStore.applyRecord('rewards', d.record, 'update'); } }); }}>
|
||||
<input type="hidden" name="id" value={r.id} />
|
||||
<input type="hidden" name="message" value={selectedMessage[m.id] || ''} />
|
||||
<div class="payment-row">
|
||||
<span>{r.label}</span>
|
||||
<div class="payment-right">
|
||||
<span class="value">{rewardLabel(r)}</span>
|
||||
<Button type="submit" size="sm" variant="primary">Claim</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{/each}
|
||||
<div class="response-area">
|
||||
<p class="respond-label">Response:</p>
|
||||
<div class="tags">
|
||||
{#each responseTags as tag}
|
||||
<button
|
||||
type="button"
|
||||
class="tag"
|
||||
class:selected={selectedMessage[m.id] === tag}
|
||||
onclick={() => selectedMessage[m.id] = selectedMessage[m.id] === tag ? '' : tag}
|
||||
>{tag}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
class="custom-msg"
|
||||
placeholder="Or type your own..."
|
||||
bind:value={customMessage[m.id]}
|
||||
oninput={() => { if (customMessage[m.id]) selectedMessage[m.id] = customMessage[m.id]; }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</Card>
|
||||
</CardGrid>
|
||||
{:else}
|
||||
<!-- Child View: Member Kanban -->
|
||||
<ViewHeader title={memberName || username} />
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={3}>
|
||||
|
||||
<h1>
|
||||
<span class="color-dot" style="background:{memberColor}" role="button" tabindex="0"
|
||||
onclick={e => { e.stopPropagation(); showColorPicker = !showColorPicker }}
|
||||
onkeydown={e => e.key === 'Enter' && (showColorPicker = !showColorPicker)}></span>
|
||||
{#if editingName}
|
||||
<input type="text" bind:value={nameInput} class="name-edit"
|
||||
onkeydown={async (e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
editingName = false;
|
||||
if (nameInput && nameInput !== memberName) {
|
||||
const old = memberName;
|
||||
memberName = nameInput;
|
||||
try {
|
||||
const res = await fetch('/api/members/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId },
|
||||
body: JSON.stringify({ name: nameInput }),
|
||||
});
|
||||
if (!res.ok) memberName = old;
|
||||
} catch { memberName = old; }
|
||||
}
|
||||
} else if (e.key === 'Escape') {
|
||||
editingName = false;
|
||||
nameInput = memberName;
|
||||
}
|
||||
}}
|
||||
onblur={() => { editingName = false; nameInput = memberName; }}
|
||||
autofocus />
|
||||
{:else}
|
||||
<span class="name-display" role="button" tabindex="0"
|
||||
onclick={() => { nameInput = memberName; editingName = true; }}
|
||||
onkeydown={e => e.key === 'Enter' && (nameInput = memberName, editingName = true)}>
|
||||
{memberName || username}
|
||||
</span>
|
||||
{/if}
|
||||
</h1>
|
||||
|
||||
{#if showColorPicker}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="color-picker-overlay" onclick={() => showColorPicker = false}>
|
||||
<div class="color-picker-popup" onclick={(e) => e.stopPropagation()}>
|
||||
<p>Pick a color:</p>
|
||||
<div class="color-swatches">
|
||||
{#each ['#ef4444','#f97316','#eab308','#22c55e','#14b8a6','#3b82f6','#6366f1','#a855f7','#ec4899','#78716c'] as color}
|
||||
<button class="swatch" style="background:{color}" aria-label={color}
|
||||
onclick={async () => {
|
||||
const old = memberColor;
|
||||
memberColor = color;
|
||||
showColorPicker = false;
|
||||
try {
|
||||
const res = await fetch('/api/members/me', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', 'x-device-token': deviceToken, 'x-device-famid': famId },
|
||||
body: JSON.stringify({ color }),
|
||||
});
|
||||
if (!res.ok) memberColor = old;
|
||||
} catch { memberColor = old; }
|
||||
}}></button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<p>Loading...</p>
|
||||
{:else if error}
|
||||
<p class="error">{error}</p>
|
||||
{:else}
|
||||
|
||||
<!-- Badge Cards -->
|
||||
<div class="badges-row">
|
||||
<div class="badge-card notice">
|
||||
<span class="badge-label">Bonus Notices</span>
|
||||
<span class="badge-value">{bonusNotices.length}</span>
|
||||
{#if bonusNotices.length > 0}
|
||||
<span class="badge-sub">+{bonusNotices.reduce((s, r) => s + Number(r.value), 0)} pts</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="badge-card cash">
|
||||
<span class="badge-label">Total Cash</span>
|
||||
<span class="badge-value">£{(totalCash / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="badge-card pending">
|
||||
<span class="badge-label">Pending Cash</span>
|
||||
<span class="badge-value">£{(pendingCash / 100).toFixed(2)}</span>
|
||||
</div>
|
||||
<div class="badge-card points">
|
||||
<span class="badge-label">Total Points</span>
|
||||
<span class="badge-value">{totalPoints}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Weekly Progress -->
|
||||
{#if weeklyTotal > 0}
|
||||
{@const wpct = Math.round((weeklyDone / weeklyTotal) * 100)}
|
||||
<div class="week-card">
|
||||
<div class="week-head">
|
||||
<span class="week-label">This Week</span>
|
||||
<span class="week-stat">{weeklyDone}/{weeklyTotal} done</span>
|
||||
</div>
|
||||
<div class="bar-wrap lg">
|
||||
<div class="bar-fill" style="width:{wpct}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Bonus Progress -->
|
||||
{#if bonusProgresses.length > 0}
|
||||
<div class="bonus-grid">
|
||||
{#each bonusProgresses as bp}
|
||||
{@const pct = bp.criteria > 0 ? Math.min(100, (bp.current / bp.criteria) * 100) : 0}
|
||||
{@const hasReward = bp.periodStart ? rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id && r.date >= bp.periodStart && r.date <= bp.periodEnd) : rewards.some(r => r.memberId === memberId && r.bonusConfigId === bp.config.id)}
|
||||
{@const typeClass = bp.config.type === 'threshold' ? 'type-threshold' : bp.config.type === 'count' ? 'type-count' : 'type-manual'}
|
||||
<div class="bonus-badge {typeClass}" class:achieved={hasReward}>
|
||||
<div class="bonus-head">
|
||||
<span class="bonus-name">{bp.config.name}</span>
|
||||
<span class="bonus-type">{bp.config.type}</span>
|
||||
</div>
|
||||
<div class="bonus-body">
|
||||
{#if bp.criteria > 0}
|
||||
<div class="bar-wrap">
|
||||
<div class="bar-fill" style="width:{pct}%"></div>
|
||||
</div>
|
||||
<span class="bonus-stat">{bp.current}/{bp.criteria} {bp.config.type === 'threshold' ? 'pts' : 'chores'}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if hasReward}
|
||||
<span class="bonus-done">Reward earned 🎉</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Kanban -->
|
||||
<div class="kanban">
|
||||
<div class="column">
|
||||
<h2>Daily Pending ({dailyPending.length})</h2>
|
||||
{#if dailyPending.length === 0}
|
||||
<p class="empty">All done!</p>
|
||||
{:else}
|
||||
{#each dailyPending as chore}
|
||||
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
|
||||
<span class="checkbox">⬜</span>
|
||||
<span class="chore-name">{choreName(chore)}</span>
|
||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<h2>Weekly Pending ({weeklyPending.length})</h2>
|
||||
{#if weeklyPending.length === 0}
|
||||
<p class="empty">All done!</p>
|
||||
{:else}
|
||||
{#each weeklyPending as chore}
|
||||
<button class="chore" onclick={() => toggle(chore)} disabled={!!togglingIds}>
|
||||
<span class="checkbox">⬜</span>
|
||||
<span class="chore-name">{choreName(chore)}</span>
|
||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="column">
|
||||
<h2>Completed Today ({completedToday.length})</h2>
|
||||
{#if completedToday.length === 0}
|
||||
<p class="empty">Nothing yet</p>
|
||||
{:else}
|
||||
{#each completedToday as c}
|
||||
{@const chore = assigned.find(a => a.id === c.assignedChoreId)}
|
||||
{#if chore}
|
||||
<button class="chore done" onclick={() => toggle(chore)} disabled={!!togglingIds}>
|
||||
<span class="checkbox">✅</span>
|
||||
<span class="chore-name">{choreName(chore)}</span>
|
||||
<span class="chore-value">{chore.value} {chore.type}</span>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="column claim-col">
|
||||
<h2>Claimable ({claimableRewardsChild.length})</h2>
|
||||
{#if claimableRewardsChild.length === 0}
|
||||
<p class="empty">Nothing to claim</p>
|
||||
{:else}
|
||||
{#each claimableRewardsChild as r}
|
||||
<div class="claim-card">
|
||||
<div class="claim-body">
|
||||
<span class="claim-label">{r.label}</span>
|
||||
<span class="claim-value">{rewardLabelChild(r)}</span>
|
||||
</div>
|
||||
<div class="claim-actions">
|
||||
<span class="badge">{r.rewardType}</span>
|
||||
<button class="request-btn" onclick={async () => { try { await memberApi.claimReward(deviceToken, famId, r.id); requestedRewards = [...requestedRewards, r.id]; } catch (e) { claimError = e instanceof Error ? e.message : 'Claim failed'; } }} disabled={requestedRewards.includes(r.id)}>
|
||||
{requestedRewards.includes(r.id) ? 'Requested' : 'Request'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/if}
|
||||
</Card>
|
||||
</CardGrid>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); animation: fadein 0.2s; }
|
||||
@keyframes fadein { from { opacity: 0; transform: translateY(-10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.empty { color: #9ca3af; font-size: 0.85rem; text-align: center; padding: 1.5rem; }
|
||||
|
||||
.member-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem; background: white; margin-bottom: 0.6rem; }
|
||||
.member-card:last-child { margin-bottom: 0; }
|
||||
.card-header { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.4rem; }
|
||||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.member-name { font-weight: 600; flex: 1; font-size: 0.9rem; }
|
||||
.link { font-size: 0.8rem; color: #6366f1; text-decoration: none; }
|
||||
.stats { display: flex; gap: 0.75rem; font-size: 0.8rem; color: #6b7280; margin-bottom: 0.4rem; }
|
||||
.progress-row { display: flex; align-items: center; gap: 0.4rem; margin-bottom: 0.3rem; }
|
||||
.label { font-size: 0.75rem; color: #6b7280; min-width: 2.5rem; }
|
||||
.bar-wrap { flex: 1; height: 8px; background: #e5e7eb; border-radius: 4px; overflow: hidden; }
|
||||
.bar-fill { height: 100%; background: #6366f1; border-radius: 4px; transition: width 0.3s; }
|
||||
.count { font-size: 0.75rem; color: #6b7280; min-width: 3rem; text-align: right; }
|
||||
.done-list { list-style: none; padding: 0; margin: 0; }
|
||||
.done-list li { font-size: 0.75rem; padding: 0.05rem 0; color: #059669; display: flex; align-items: center; gap: 0.25rem; }
|
||||
.revoke-form { display: inline; }
|
||||
.revoke-btn { background: none; border: 1px solid transparent; border-radius: 3px; cursor: pointer; padding: 0 2px; font-size: 0.75rem; line-height: 1.2; color: #9ca3af; }
|
||||
.revoke-btn:hover { color: #dc2626; border-color: #fca5a5; background: #fef2f2; }
|
||||
|
||||
.trigger-card { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.65rem; background: #f0fdf4; margin-bottom: 0.6rem; border-left: 3px solid #22c55e; }
|
||||
.trigger-card:last-child { margin-bottom: 0; }
|
||||
.trigger-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 0.4rem; }
|
||||
.trigger-name { font-weight: 600; font-size: 0.9rem; }
|
||||
.trigger-value { font-size: 0.85rem; color: #059669; font-weight: 600; }
|
||||
.trigger-row { display: flex; gap: 0.4rem; }
|
||||
.trigger-select { flex: 1; padding: 0.35rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.8rem; }
|
||||
|
||||
.member-claims { margin-bottom: 0.75rem; }
|
||||
.member-claims:last-child { margin-bottom: 0; }
|
||||
.member-claims h4 { display: flex; align-items: center; gap: 0.3rem; margin: 0 0 0.2rem; font-size: 0.85rem; }
|
||||
.payment-row { display: flex; justify-content: space-between; font-size: 0.8rem; padding: 0.1rem 0; }
|
||||
.payment-row .value { font-weight: 600; color: #059669; }
|
||||
.payment-right { display: flex; align-items: center; gap: 0.4rem; }
|
||||
.respond-label { font-size: 0.8rem; color: #6b7280; margin: 0.4rem 0 0.3rem; }
|
||||
.tags { display: flex; gap: 0.3rem; flex-wrap: wrap; }
|
||||
.tag { font-size: 0.75rem; padding: 0.2rem 0.5rem; border: 1px solid #d1d5db; border-radius: 999px; background: white; cursor: pointer; }
|
||||
.tag:hover { background: #f3f4f6; }
|
||||
.tag.selected { background: #6366f1; color: white; border-color: #6366f1; }
|
||||
.custom-msg { margin-top: 0.3rem; padding: 0.3rem 0.5rem; border: 1px solid #d1d5db; border-radius: 4px; font-size: 0.8rem; width: 100%; box-sizing: border-box; }
|
||||
|
||||
.error { color: #dc2626; padding: 1rem; text-align: center; }
|
||||
|
||||
.badges-row { display: flex; gap: 0.75rem; margin-bottom: 1rem; flex-wrap: wrap; }
|
||||
.badge-card { flex: 1; min-width: 120px; padding: 0.75rem; border-radius: 8px; background: #f9fafb; border: 1px solid #e5e7eb; }
|
||||
.badge-label { font-size: 0.75rem; color: #6b7280; display: block; }
|
||||
.badge-value { font-size: 1.4rem; font-weight: 700; display: block; }
|
||||
.badge-sub { font-size: 0.75rem; color: #059669; }
|
||||
.badge-card.notice { border-left: 3px solid #f59e0b; }
|
||||
.badge-card.cash { border-left: 3px solid #10b981; }
|
||||
.badge-card.pending { border-left: 3px solid #3b82f6; }
|
||||
.badge-card.points { border-left: 3px solid #8b5cf6; }
|
||||
|
||||
|
||||
.week-card { background: #f0fdf4; border: 1px solid #bbf7d0; border-radius: 10px; padding: 0.85rem 1rem; margin-bottom: 1rem; }
|
||||
.week-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||
.week-label { font-weight: 700; font-size: 0.95rem; color: #166534; }
|
||||
.week-stat { font-size: 0.8rem; color: #16a34a; font-weight: 600; }
|
||||
.bar-wrap.lg { height: 14px; }
|
||||
|
||||
.bonus-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.6rem; margin-bottom: 1rem; }
|
||||
.bonus-badge { border-radius: 10px; padding: 0.7rem; background: white; border: 1px solid #e5e7eb; border-left: 4px solid #9ca3af; }
|
||||
.bonus-badge.type-threshold { border-left-color: #8b5cf6; }
|
||||
.bonus-badge.type-count { border-left-color: #3b82f6; }
|
||||
.bonus-badge.type-manual { border-left-color: #f59e0b; }
|
||||
.bonus-badge.achieved { background: #f0fdf4; border-color: #bbf7d0; border-left-color: #22c55e; }
|
||||
.bonus-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.3rem; }
|
||||
.bonus-name { font-size: 0.8rem; font-weight: 600; }
|
||||
.bonus-type { font-size: 0.6rem; padding: 1px 6px; border-radius: 6px; background: #f3f4f6; color: #6b7280; text-transform: capitalize; }
|
||||
.bonus-body { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.bonus-body .bar-wrap { flex: 1; height: 8px; }
|
||||
.bonus-stat { font-size: 0.7rem; color: #6b7280; white-space: nowrap; font-weight: 600; min-width: 70px; text-align: right; }
|
||||
.bonus-done { font-size: 0.7rem; color: #16a34a; font-weight: 600; margin-top: 0.2rem; display: block; }
|
||||
|
||||
.kanban { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; gap: 0.75rem; margin-top: 0.5rem; }
|
||||
.column { border: 1px solid #e5e7eb; border-radius: 8px; padding: 0.75rem; background: #f9fafb; }
|
||||
.column h2 { margin: 0 0 0.75rem; font-size: 0.95rem; }
|
||||
|
||||
.chore { display: flex; align-items: center; gap: 0.4rem; width: 100%; padding: 0.5rem 0.6rem; margin-bottom: 0.4rem; border: 1px solid #e5e7eb; border-radius: 6px; background: white; cursor: pointer; text-align: left; font-size: 0.85rem; }
|
||||
.chore:hover { background: #f3f4f6; }
|
||||
.chore:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.done { opacity: 0.65; }
|
||||
.checkbox { font-size: 1rem; flex-shrink: 0; }
|
||||
.chore-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.chore-value { font-size: 0.75rem; color: #6b7280; font-weight: 600; white-space: nowrap; }
|
||||
|
||||
.claim-col { border-left: 3px solid #10b981; }
|
||||
.claim-card { display: flex; align-items: center; gap: 0.4rem; padding: 0.5rem; margin-bottom: 0.4rem; border: 1px solid #e5e7eb; border-radius: 6px; background: white; }
|
||||
.claim-body { flex: 1; }
|
||||
.claim-label { font-size: 0.8rem; display: block; }
|
||||
.claim-value { font-size: 0.9rem; font-weight: 700; color: #059669; }
|
||||
.claim-actions { display: flex; flex-direction: column; align-items: flex-end; gap: 0.25rem; }
|
||||
.badge { font-size: 0.65rem; padding: 1px 6px; border-radius: 8px; background: #d1fae5; color: #059669; white-space: nowrap; }
|
||||
.request-btn { font-size: 0.7rem; padding: 2px 8px; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.request-btn:disabled { background: #9ca3af; cursor: default; }
|
||||
|
||||
h1 { display: flex; align-items: center; gap: 0.5rem; }
|
||||
.color-dot { display: inline-block; width: 18px; height: 18px; border-radius: 50%; border: 2px solid #e5e7eb; cursor: pointer; flex-shrink: 0; }
|
||||
.name-display { cursor: pointer; border-bottom: 1px dashed #d1d5db; }
|
||||
.name-edit { font-size: 1.2rem; padding: 0.1rem 0.3rem; border: 1px solid #6366f1; border-radius: 4px; outline: none; font-weight: 700; }
|
||||
.color-picker-overlay { position: fixed; inset: 0; z-index: 100; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.3); }
|
||||
.color-picker-popup { background: white; padding: 1.25rem; border-radius: 12px; box-shadow: 0 4px 24px rgba(0,0,0,0.15); }
|
||||
.color-picker-popup p { margin: 0 0 0.75rem; font-weight: 600; font-size: 0.9rem; }
|
||||
.color-swatches { display: flex; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.swatch { width: 36px; height: 36px; border-radius: 50%; border: 2px solid #e5e7eb; cursor: pointer; }
|
||||
.swatch:hover { border-color: #6366f1; transform: scale(1.1); }
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const [configs, members, progress, rewards] = await Promise.all([
|
||||
hono.admin.bonusConfigs(event, famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.bonusConfigProgress(event, famId),
|
||||
hono.admin.rewards(event, famId),
|
||||
]);
|
||||
return { configs, members, progress, rewards };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
createConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const phase = fd.get('phase') as string;
|
||||
const data: Record<string, unknown> = {
|
||||
name: fd.get('name'),
|
||||
target: fd.get('target'),
|
||||
type: fd.get('type'),
|
||||
occurrence: fd.get('occurrence'),
|
||||
rewardType: fd.get('rewardType'),
|
||||
rewardValue: fd.get('rewardValue'),
|
||||
phase: phase || 'ready',
|
||||
};
|
||||
const criteriaValue = fd.get('criteriaValue');
|
||||
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
|
||||
const period = fd.get('period');
|
||||
if (period !== null) data.period = period;
|
||||
const description = fd.get('description');
|
||||
if (description) data.description = description;
|
||||
const memberId = fd.get('memberId');
|
||||
if (memberId) data.memberId = memberId;
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create config' });
|
||||
}
|
||||
},
|
||||
|
||||
updateConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const data: Record<string, unknown> = {};
|
||||
const name = fd.get('name');
|
||||
if (name) data.name = name;
|
||||
const target = fd.get('target');
|
||||
if (target) data.target = target;
|
||||
const type = fd.get('type');
|
||||
if (type) data.type = type;
|
||||
const occurrence = fd.get('occurrence');
|
||||
if (occurrence) data.occurrence = occurrence;
|
||||
const rewardType = fd.get('rewardType');
|
||||
if (rewardType) data.rewardType = rewardType;
|
||||
const rewardValue = fd.get('rewardValue');
|
||||
if (rewardValue) data.rewardValue = rewardValue;
|
||||
const criteriaValue = fd.get('criteriaValue');
|
||||
if (criteriaValue) data.criteriaValue = parseInt(criteriaValue as string, 10) || 0;
|
||||
const period = fd.get('period');
|
||||
if (period !== null) data.period = period;
|
||||
const description = fd.get('description');
|
||||
if (description) data.description = description;
|
||||
const memberId = fd.get('memberId');
|
||||
if (memberId !== null) data.memberId = memberId || null;
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'bonus-configs', famId, id, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update config' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.remove(event, 'bonus-configs', famId, id);
|
||||
return { deleted: true };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to archive config' });
|
||||
}
|
||||
},
|
||||
|
||||
createFromTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const templateId = fd.get('templateId') as string;
|
||||
try {
|
||||
const templates = await hono.admin.bonusConfigs(event, famId);
|
||||
const tmpl = (Array.isArray(templates) ? templates : []).find((c: any) => c.id === templateId);
|
||||
if (!tmpl) return fail(400, { error: 'Template not found' });
|
||||
const data: Record<string, unknown> = {
|
||||
name: tmpl.name,
|
||||
description: tmpl.description || '',
|
||||
target: tmpl.target,
|
||||
type: tmpl.type,
|
||||
occurrence: tmpl.occurrence,
|
||||
rewardType: tmpl.rewardType,
|
||||
rewardValue: tmpl.rewardValue,
|
||||
criteriaValue: tmpl.criteriaValue || 0,
|
||||
period: tmpl.period || '',
|
||||
memberId: tmpl.memberId || '',
|
||||
phase: 'ready',
|
||||
};
|
||||
const record = await hono.admin.create(event, 'bonus-configs', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create bonus from template' });
|
||||
}
|
||||
},
|
||||
|
||||
assignConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const target = fd.get('target') as string;
|
||||
const memberId = fd.get('memberId') as string;
|
||||
try {
|
||||
const record = await hono.admin.assignBonusConfig(event, famId, id, { target, memberId: memberId || null });
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign bonus' });
|
||||
}
|
||||
},
|
||||
|
||||
completeConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const phase = fd.get('phase') as string;
|
||||
try {
|
||||
const record = await hono.admin.completeBonusConfig(event, famId, id, phase || 'completed');
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update bonus phase' });
|
||||
}
|
||||
},
|
||||
|
||||
destroyConfig: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.destroyBonusConfig(event, famId, id);
|
||||
return { destroyed: true };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete bonus' });
|
||||
}
|
||||
},
|
||||
|
||||
claimReward: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
try {
|
||||
await hono.admin.claimReward(event, famId, rewardId);
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||
}
|
||||
},
|
||||
|
||||
evaluate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
try {
|
||||
await hono.admin.evaluateBonusConfig(event, famId);
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to evaluate' };
|
||||
}
|
||||
},
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,140 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const [templates, members, assigned] = await Promise.all([
|
||||
hono.admin.list(event, 'chore-templates', famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.list(event, 'assigned-chores', famId),
|
||||
]);
|
||||
return { templates, members, assigned };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
createTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const data = {
|
||||
name: fd.get('name'),
|
||||
defaultFrequency: fd.get('defaultFrequency'),
|
||||
defaultType: fd.get('defaultType'),
|
||||
defaultValue: parseFloat(fd.get('defaultValue') as string) || 0,
|
||||
};
|
||||
if (!data.name || !data.defaultFrequency || !data.defaultType) {
|
||||
return fail(400, { error: 'Name, frequency, and type are required' });
|
||||
}
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'chore-templates', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to create template' });
|
||||
}
|
||||
},
|
||||
|
||||
updateTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const defaultFrequency = fd.get('defaultFrequency') as string;
|
||||
const defaultType = fd.get('defaultType') as string;
|
||||
const defaultValue = parseFloat(fd.get('defaultValue') as string) || 0;
|
||||
const data = {
|
||||
name: fd.get('name'),
|
||||
defaultFrequency,
|
||||
defaultType,
|
||||
defaultValue,
|
||||
};
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'chore-templates', famId, id, data);
|
||||
const allAssigned = await hono.admin.list(event, 'assigned-chores', famId);
|
||||
if (Array.isArray(allAssigned)) {
|
||||
const toUpdate = allAssigned.filter((a: any) => a.templateId === id);
|
||||
await Promise.all(toUpdate.map((a: any) =>
|
||||
hono.admin.update(event, 'assigned-chores', famId, a.id, {
|
||||
frequency: defaultFrequency,
|
||||
type: defaultType,
|
||||
value: defaultValue,
|
||||
})
|
||||
));
|
||||
}
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update template' });
|
||||
}
|
||||
},
|
||||
|
||||
deleteTemplate: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.remove(event, 'chore-templates', famId, id);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to delete template' });
|
||||
}
|
||||
},
|
||||
|
||||
assignChore: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const data = {
|
||||
memberId: fd.get('memberId'),
|
||||
templateId: fd.get('templateId'),
|
||||
frequency: fd.get('frequency'),
|
||||
type: fd.get('type'),
|
||||
value: parseFloat(fd.get('value') as string) || 0,
|
||||
customName: fd.get('customName') || undefined,
|
||||
};
|
||||
if (!data.memberId || !data.templateId || !data.frequency || !data.type) {
|
||||
return fail(400, { error: 'Member, template, frequency, and type are required' });
|
||||
}
|
||||
try {
|
||||
const record = await hono.admin.create(event, 'assigned-chores', famId, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to assign chore' });
|
||||
}
|
||||
},
|
||||
|
||||
updateAssignedChore: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
const data: Record<string, unknown> = {};
|
||||
const frequency = fd.get('frequency');
|
||||
const type = fd.get('type');
|
||||
const value = fd.get('value');
|
||||
const customName = fd.get('customName');
|
||||
if (frequency) data.frequency = frequency;
|
||||
if (type) data.type = type;
|
||||
if (value) data.value = parseFloat(value as string) || 0;
|
||||
data.customName = (customName as string) || undefined;
|
||||
try {
|
||||
const record = await hono.admin.update(event, 'assigned-chores', famId, id, data);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to update assigned chore' });
|
||||
}
|
||||
},
|
||||
|
||||
unassignChore: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.remove(event, 'assigned-chores', famId, id);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Failed to unassign chore' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,402 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { page } from '$app/state';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import type { ChoreTemplate, AssignedChore, Member } from '$lib/types';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let showCreateModal = $state(false)
|
||||
let showEditModal = $state(false)
|
||||
let showEditTemplateModal = $state(false)
|
||||
let editingAssignment = $state<AssignedChore | null>(null)
|
||||
let editingTemplate = $state<ChoreTemplate | null>(null)
|
||||
let editMemberId = $state('')
|
||||
let draggedTemplateId = $state<string | null>(null)
|
||||
|
||||
let createName = $state('')
|
||||
let createFreq = $state('daily')
|
||||
let createType = $state('points')
|
||||
let createValue = $state(10)
|
||||
|
||||
let editFreq = $state('daily')
|
||||
let editType = $state('points')
|
||||
let editValue = $state(10)
|
||||
let editCustomName = $state('')
|
||||
|
||||
let editTplName = $state('')
|
||||
let editTplFreq = $state('daily')
|
||||
let editTplType = $state('points')
|
||||
let editTplValue = $state(10)
|
||||
|
||||
let templates = $state(famStore.initialized ? (famStore.templates as ChoreTemplate[]) : (data.templates as ChoreTemplate[] || []))
|
||||
let members = $state(famStore.initialized ? (famStore.members as Member[]) : (data.members as Member[] || []))
|
||||
let assigned = $state(famStore.initialized ? (famStore.assigned as AssignedChore[]) : (data.assigned as AssignedChore[] || []))
|
||||
|
||||
function assignedForMember(memberId: string): AssignedChore[] {
|
||||
return assigned.filter(a => a.memberId === memberId)
|
||||
}
|
||||
|
||||
function templateName(templateId: string): string {
|
||||
return famStore.templateMap().get(templateId)?.name || '?'
|
||||
}
|
||||
|
||||
function getDefault(templateId: string): { frequency: string; type: string; value: number } {
|
||||
const t = famStore.templateMap().get(templateId)
|
||||
if (!t) return { frequency: 'daily', type: 'points', value: 10 }
|
||||
return { frequency: t.defaultFrequency, type: t.defaultType, value: t.defaultValue }
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent, templateId: string) {
|
||||
draggedTemplateId = templateId
|
||||
e.dataTransfer?.setData('text/plain', templateId)
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'copy'
|
||||
}
|
||||
|
||||
function handleDragOver(e: DragEvent) {
|
||||
e.preventDefault()
|
||||
if (e.dataTransfer) e.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
async function handleDrop(e: DragEvent, memberId: string) {
|
||||
e.preventDefault()
|
||||
const tid = e.dataTransfer?.getData('text/plain') || draggedTemplateId
|
||||
if (!tid) return
|
||||
draggedTemplateId = null
|
||||
|
||||
const def = getDefault(tid)
|
||||
const fd = new FormData()
|
||||
fd.set('memberId', memberId)
|
||||
fd.set('templateId', tid)
|
||||
fd.set('frequency', def.frequency)
|
||||
fd.set('type', def.type)
|
||||
fd.set('value', String(def.value))
|
||||
|
||||
const res = await fetch(`/${page.params.fam}/${page.params.username}/chores?/assignChore`, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
})
|
||||
if (res.ok) {
|
||||
const body = await res.json()
|
||||
if (body.record) {
|
||||
assigned = [body.record as AssignedChore, ...assigned]
|
||||
famStore.applyRecord('assigned_chores', body.record, 'create')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openEdit(a: AssignedChore) {
|
||||
editingAssignment = a
|
||||
editFreq = a.frequency
|
||||
editType = a.type
|
||||
editValue = a.value
|
||||
editCustomName = a.customName || ''
|
||||
showEditModal = true
|
||||
}
|
||||
|
||||
function closeEdit() {
|
||||
showEditModal = false
|
||||
editingAssignment = null
|
||||
}
|
||||
|
||||
function openEditTemplate(t: ChoreTemplate) {
|
||||
editingTemplate = t
|
||||
editTplName = t.name
|
||||
editTplFreq = t.defaultFrequency
|
||||
editTplType = t.defaultType
|
||||
editTplValue = t.defaultValue
|
||||
showEditTemplateModal = true
|
||||
}
|
||||
|
||||
function closeEditTemplate() {
|
||||
showEditTemplateModal = false
|
||||
editingTemplate = null
|
||||
}
|
||||
|
||||
function enhanceCreate() {
|
||||
return async ({ result, formData }: any) => {
|
||||
if (result.type === 'success') {
|
||||
showCreateModal = false
|
||||
const rec = result.data?.record
|
||||
if (rec) {
|
||||
templates = [rec as ChoreTemplate, ...templates]
|
||||
famStore.applyRecord('chore_templates', rec, 'create')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceUpdateAssigned() {
|
||||
return async ({ result }: any) => {
|
||||
if (result.type === 'success' && editingAssignment) {
|
||||
const idx = assigned.findIndex((a: any) => a.id === editingAssignment!.id)
|
||||
if (idx !== -1) {
|
||||
assigned[idx] = {
|
||||
...assigned[idx],
|
||||
frequency: editFreq,
|
||||
type: editType,
|
||||
value: editValue,
|
||||
customName: editCustomName || undefined,
|
||||
} as AssignedChore
|
||||
famStore.applyRecord('assigned_chores', assigned[idx], 'update')
|
||||
}
|
||||
}
|
||||
closeEdit()
|
||||
}
|
||||
}
|
||||
|
||||
function enhanceUpdateTemplate() {
|
||||
return async ({ result, formData }: any) => {
|
||||
if (result.type === 'success' && editingTemplate) {
|
||||
const tid = editingTemplate!.id
|
||||
const idx = templates.findIndex((t: any) => t.id === tid)
|
||||
if (idx !== -1) {
|
||||
templates[idx] = {
|
||||
...templates[idx],
|
||||
name: editTplName,
|
||||
defaultFrequency: editTplFreq,
|
||||
defaultType: editTplType,
|
||||
defaultValue: editTplValue,
|
||||
} as ChoreTemplate
|
||||
}
|
||||
assigned = assigned.map((a: any) =>
|
||||
a.templateId === tid
|
||||
? { ...a, frequency: editTplFreq, type: editTplType, value: editTplValue }
|
||||
: a
|
||||
) as AssignedChore[]
|
||||
famStore.applyRecord('chore_templates', templates[idx], 'update')
|
||||
closeEditTemplate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<ViewHeader title="Chore Assignment" />
|
||||
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={3}>
|
||||
<div style="margin-bottom:0.75rem">
|
||||
<Button onclick={() => showCreateModal = true}>+ New Chore Template</Button>
|
||||
</div>
|
||||
<div class="kanban">
|
||||
<div class="column templates-col">
|
||||
<h3>Templates</h3>
|
||||
{#each templates as t}
|
||||
<div
|
||||
class="card template"
|
||||
draggable="true"
|
||||
ondragstart={(e) => handleDragStart(e, t.id)}
|
||||
>
|
||||
<div class="card-body" onclick={() => openEditTemplate(t)} role="button" tabindex="0">
|
||||
<strong>{t.name}</strong>
|
||||
<span class="badge">{t.defaultFrequency}</span>
|
||||
<span class="badge type">{t.defaultType}</span>
|
||||
</div>
|
||||
<div class="card-value">
|
||||
{t.defaultType === 'money' ? `£${Number(t.defaultValue).toFixed(2)}` : t.defaultValue}
|
||||
</div>
|
||||
<div class="card-actions">
|
||||
<button onclick={() => openEditTemplate(t)} class="edit-btn" title="Edit template">✎</button>
|
||||
<form method="POST" action="?/deleteTemplate" use:enhance={() => { return async ({ result, formData }) => { if (result.type === 'success') { const id = formData.get('id'); templates = templates.filter((t) => t.id !== id) as ChoreTemplate[]; const rec = result.data.record; if (rec) famStore.applyRecord('chore_templates', { ...rec, id }, 'delete'); } }; }}>
|
||||
<input type="hidden" name="id" value={t.id} />
|
||||
<button type="submit" class="del-btn" title="Delete template">×</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if templates.length === 0}
|
||||
<p class="empty">No templates yet</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#each members as m}
|
||||
<div
|
||||
class="column member-col"
|
||||
style="border-top-color: {m.color}"
|
||||
ondragover={handleDragOver}
|
||||
ondrop={(e) => handleDrop(e, m.id)}
|
||||
>
|
||||
<h3>
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
{m.name}
|
||||
</h3>
|
||||
{#each assignedForMember(m.id) as a}
|
||||
{@const tName = templateName(a.templateId)}
|
||||
<div class="card assigned" onclick={() => openEdit(a)} role="button" tabindex="0">
|
||||
<div class="card-body">
|
||||
<strong>{a.customName || tName}</strong>
|
||||
<span class="badge">{a.frequency}</span>
|
||||
<span class="badge type">{a.type}</span>
|
||||
</div>
|
||||
<div class="card-value">{a.type === 'money' ? `£${Number(a.value).toFixed(2)}` : a.value}</div>
|
||||
<form method="POST" action="?/unassignChore" onclick={(e) => e.stopPropagation()} use:enhance={() => { return async ({ result }) => { if (result.type === 'success') { const id = result.data.record?.id; if (id) { assigned = assigned.filter((x) => x.id !== id) as AssignedChore[]; famStore.applyRecord('assigned_chores', { id }, 'delete'); } } }; }}>
|
||||
<input type="hidden" name="id" value={a.id} />
|
||||
<button type="submit" class="del-btn" title="Remove assignment">×</button>
|
||||
</form>
|
||||
</div>
|
||||
{/each}
|
||||
{#if assignedForMember(m.id).length === 0}
|
||||
<p class="empty">Drop a chore here</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
</Card></CardGrid>
|
||||
|
||||
<!-- Create Template Modal -->
|
||||
{#if showCreateModal}
|
||||
<div class="overlay" onclick={() => showCreateModal = false} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>New Chore Template</h3>
|
||||
<form method="POST" action="?/createTemplate" use:enhance={enhanceCreate}>
|
||||
<label>
|
||||
Name
|
||||
<input name="name" bind:value={createName} required />
|
||||
</label>
|
||||
<label>
|
||||
Frequency
|
||||
<select name="defaultFrequency" bind:value={createFreq}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select name="defaultType" bind:value={createType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Default Value
|
||||
<input name="defaultValue" type="number" step="any" bind:value={createValue} required />
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={() => showCreateModal = false}>Cancel</button>
|
||||
<button type="submit">Create</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Template Modal -->
|
||||
{#if showEditTemplateModal && editingTemplate}
|
||||
<div class="overlay" onclick={closeEditTemplate} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Edit Template: {editingTemplate.name}</h3>
|
||||
<p class="hint">Changes will also update all existing assigned chores using this template.</p>
|
||||
<form method="POST" action="?/updateTemplate" use:enhance={enhanceUpdateTemplate}>
|
||||
<input name="id" type="hidden" value={editingTemplate.id} />
|
||||
<label>
|
||||
Name
|
||||
<input name="name" bind:value={editTplName} required />
|
||||
</label>
|
||||
<label>
|
||||
Frequency
|
||||
<select name="defaultFrequency" bind:value={editTplFreq}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select name="defaultType" bind:value={editTplType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Default Value
|
||||
<input name="defaultValue" type="number" step="any" bind:value={editTplValue} required />
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={closeEditTemplate}>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Edit Assignment Modal -->
|
||||
{#if showEditModal && editingAssignment}
|
||||
<div class="overlay" onclick={closeEdit} role="presentation">
|
||||
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog">
|
||||
<h3>Edit: {editingAssignment.customName || templateName(editingAssignment.templateId)}</h3>
|
||||
<form method="POST" action="?/updateAssignedChore" use:enhance={enhanceUpdateAssigned}>
|
||||
<input name="id" type="hidden" value={editingAssignment.id} />
|
||||
<label>
|
||||
Custom Name
|
||||
<input name="customName" bind:value={editCustomName} placeholder="Override template name" />
|
||||
</label>
|
||||
<label>
|
||||
Frequency
|
||||
<select name="frequency" bind:value={editFreq}>
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Type
|
||||
<select name="type" bind:value={editType}>
|
||||
<option value="points">Points</option>
|
||||
<option value="money">Money</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Value
|
||||
<input name="value" type="number" step="any" bind:value={editValue} required />
|
||||
</label>
|
||||
<div class="modal-actions">
|
||||
<button type="button" onclick={closeEdit}>Cancel</button>
|
||||
<button type="submit">Save</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; margin: 0.5rem 0; }
|
||||
|
||||
.kanban { display: flex; gap: 1rem; overflow-x: auto; align-items: flex-start; }
|
||||
.column { flex: 1; min-width: 200px; max-width: 280px; border: 2px solid #e5e7eb; border-top-width: 4px; border-radius: 8px; padding: 0.75rem; background: #fafafa; min-height: 200px; }
|
||||
.column h3 { margin: 0 0 0.75rem; font-size: 1rem; display: flex; align-items: center; gap: 0.4rem; }
|
||||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.empty { color: #9ca3af; font-size: 0.85rem; text-align: center; padding: 1rem; border: 1px dashed #d1d5db; border-radius: 6px; }
|
||||
|
||||
.card { background: white; border: 1px solid #e5e7eb; border-radius: 6px; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; display: flex; align-items: center; gap: 0.5rem; position: relative; }
|
||||
.card-body { flex: 1; min-width: 0; }
|
||||
.card-body strong { display: block; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.card-value { font-size: 0.85rem; color: #6b7280; font-weight: 600; white-space: nowrap; }
|
||||
.badge { font-size: 0.7rem; background: #e0e7ff; color: #4338ca; padding: 1px 6px; border-radius: 10px; margin-right: 4px; }
|
||||
.badge.type { background: #fce7f3; color: #be185d; }
|
||||
.card-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; }
|
||||
.edit-btn, .del-btn { background: none; border: none; color: #9ca3af; cursor: pointer; font-size: 1rem; padding: 0 4px; line-height: 1; flex-shrink: 0; }
|
||||
.edit-btn:hover { color: #6366f1; }
|
||||
.del-btn:hover { color: #dc2626; }
|
||||
.hint { color: #6b7280; font-size: 0.85rem; margin-bottom: 1rem; }
|
||||
|
||||
.template { cursor: grab; border-left: 3px solid #6366f1; }
|
||||
.template:active { cursor: grabbing; opacity: 0.8; }
|
||||
.assigned { cursor: pointer; border-left: 3px solid #10b981; }
|
||||
.assigned:hover { background: #f3f4f6; }
|
||||
|
||||
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.4); display: flex; align-items: center; justify-content: center; z-index: 100; }
|
||||
.modal { background: white; border-radius: 12px; padding: 1.5rem; min-width: 320px; max-width: 440px; box-shadow: 0 10px 25px rgba(0,0,0,0.15); }
|
||||
.modal h3 { margin: 0 0 1rem; }
|
||||
.modal label { display: block; margin-bottom: 0.75rem; font-size: 0.9rem; color: #374151; }
|
||||
.modal input, .modal select { display: block; width: 100%; margin-top: 0.25rem; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 6px; font-size: 0.9rem; box-sizing: border-box; }
|
||||
.modal-actions { display: flex; gap: 0.5rem; justify-content: flex-end; margin-top: 1rem; }
|
||||
.modal-actions button { padding: 0.5rem 1rem; border: none; border-radius: 6px; cursor: pointer; font-size: 0.9rem; }
|
||||
.modal-actions button[type="button"] { background: #e5e7eb; color: #374151; }
|
||||
.modal-actions button[type="submit"] { background: #6366f1; color: white; }
|
||||
</style>
|
||||
@@ -0,0 +1,99 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
|
||||
|
||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||
|
||||
export async function load(event) {
|
||||
const session = event.locals.session;
|
||||
const famSlug = event.params.fam;
|
||||
const username = event.params.username;
|
||||
|
||||
// Parent (session auth) — profile lives in fam_admins
|
||||
if (session) {
|
||||
const famId = session.famId;
|
||||
if (session.memberName && session.memberName !== username) {
|
||||
throw redirect(303, `/${famSlug}/${session.memberName}/preferences`);
|
||||
}
|
||||
try {
|
||||
const me = await hono.admin.getProfile(event, famId);
|
||||
return {
|
||||
verified: true, token: '', memberId: me.id, famId,
|
||||
memberName: me.name, memberColor: me.color, email: me.email || '',
|
||||
session: true,
|
||||
};
|
||||
} catch {
|
||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
}
|
||||
|
||||
// Child (device token) — profile lives in members
|
||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||
if (!deviceToken) {
|
||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceToken, famSlug }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.name !== username) {
|
||||
return { verified: false, token: deviceToken, memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
return {
|
||||
verified: true, token: deviceToken, memberId: data.memberId, famId: data.famId,
|
||||
memberName: data.name, memberColor: data.color, email: data.email || '', session: false,
|
||||
};
|
||||
} catch {
|
||||
return { verified: false, token: '', memberId: '', famId: '', memberName: '', memberColor: '', email: '' };
|
||||
}
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
update: async (event) => {
|
||||
const session = event.locals.session;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
const color = fd.get('color') as string;
|
||||
const email = fd.get('email') as string;
|
||||
|
||||
if (session) {
|
||||
const famId = session.famId;
|
||||
try {
|
||||
const data: Record<string, string> = {};
|
||||
if (name) data.name = name;
|
||||
if (color) data.color = color;
|
||||
data.email = email || '';
|
||||
const me = await hono.admin.updateProfile(event, famId, data);
|
||||
return { success: true, name: me.name, color: me.color, email: me.email };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||
}
|
||||
}
|
||||
|
||||
const deviceToken = event.cookies.get('device_token') || event.url.searchParams.get('token') || '';
|
||||
const famSlug = event.params.fam;
|
||||
if (!deviceToken) return { error: 'Not authenticated' };
|
||||
|
||||
try {
|
||||
const verifyRes = await fetch(`${HONO_URL}/api/members/verify-token`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ deviceToken, famSlug }),
|
||||
});
|
||||
const verify = await verifyRes.json();
|
||||
if (!verifyRes.ok) return { error: 'Verification failed' };
|
||||
const res = await fetch(`${HONO_URL}/api/members/me`, {
|
||||
method: 'PATCH', headers: { 'x-device-token': deviceToken, 'x-device-famid': verify.famId, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, color }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return { error: data.error || 'Update failed' };
|
||||
return { success: true, name: data.name, color: data.color };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Update failed' };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
|
||||
let { data, form } = $props();
|
||||
|
||||
let memberName = $state(data.memberName || '');
|
||||
let memberColor = $state(data.memberColor || '#6366f1');
|
||||
let memberEmail = $state(data.email || '');
|
||||
|
||||
const colors = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#ef4444', '#8b5cf6', '#14b8a6'];
|
||||
</script>
|
||||
|
||||
<ViewHeader title="Preferences" subtitle={data.verified ? data.memberName : ''} />
|
||||
|
||||
{#if !data.verified}
|
||||
<CardGrid>
|
||||
<Card cols={3}>
|
||||
<p style="color:#9ca3af;text-align:center;padding:2rem">
|
||||
Not authenticated. Use the link from your invite.
|
||||
</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
{:else}
|
||||
<CardGrid>
|
||||
<Card cols={2} title="Appearance">
|
||||
<form method="POST" action="?/update" use:enhance>
|
||||
<div style="margin-bottom:1rem">
|
||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||
Display Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
bind:value={memberName}
|
||||
required
|
||||
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||
Color
|
||||
</label>
|
||||
<div style="display:flex;gap:0.4rem;flex-wrap:wrap">
|
||||
{#each colors as c}
|
||||
<button
|
||||
type="button"
|
||||
style="width:2rem;height:2rem;border-radius:50%;border:{c === memberColor ? '3px solid #111827' : '2px solid transparent'};background:{c};cursor:pointer"
|
||||
onclick={() => memberColor = c}
|
||||
></button>
|
||||
{/each}
|
||||
</div>
|
||||
<input type="hidden" name="color" value={memberColor} />
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:1rem">
|
||||
<label style="display:block;font-size:0.85rem;color:#374151;margin-bottom:0.25rem">
|
||||
Email
|
||||
</label>
|
||||
{#if data.session}
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
bind:value={memberEmail}
|
||||
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box"
|
||||
/>
|
||||
{:else}
|
||||
<input
|
||||
type="email"
|
||||
name="email"
|
||||
value={memberEmail}
|
||||
disabled
|
||||
style="padding:0.5rem;border:1px solid #d1d5db;border-radius:6px;font-size:0.9rem;width:100%;box-sizing:border-box;background:#f3f4f6;color:#9ca3af"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if form?.error}
|
||||
<p style="color:#dc2626;font-size:0.85rem;margin-bottom:0.5rem">{form.error}</p>
|
||||
{/if}
|
||||
{#if form?.success}
|
||||
<p style="color:#059669;font-size:0.85rem;margin-bottom:0.5rem">Saved!</p>
|
||||
{/if}
|
||||
|
||||
<Button type="submit" variant="primary">Save</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card cols={1} title="Account">
|
||||
<p style="font-size:0.85rem;color:#6b7280">
|
||||
To change your device or sign in on a new device, use your invite link.
|
||||
</p>
|
||||
<p style="font-size:0.85rem;color:#6b7280;margin-top:0.5rem">
|
||||
Contact your family admin to change your token or reset your account.
|
||||
</p>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
{/if}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
|
||||
export async function load(event) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const [rewards, members] = await Promise.all([
|
||||
hono.admin.rewards(event, famId),
|
||||
hono.admin.list(event, 'members', famId),
|
||||
]);
|
||||
return { rewards, members };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
claim: async (event) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const rewardId = fd.get('id') as string;
|
||||
try {
|
||||
const record = await hono.admin.claimReward(event, famId, rewardId);
|
||||
return { record };
|
||||
} catch (e) {
|
||||
return { error: e instanceof Error ? e.message : 'Failed to claim reward' };
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,137 @@
|
||||
<script lang="ts">
|
||||
import { enhance } from '$app/forms';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let rewards = $state(famStore.initialized ? famStore.rewards : (data.rewards || []))
|
||||
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
|
||||
|
||||
function memberName(memberId: string): string {
|
||||
return famStore.memberMap().get(memberId)?.name || members.find((m: any) => m.id === memberId)?.name || '?';
|
||||
}
|
||||
|
||||
function memberColor(memberId: string): string {
|
||||
return famStore.memberMap().get(memberId)?.color || members.find((m: any) => m.id === memberId)?.color || '#6366f1';
|
||||
}
|
||||
|
||||
function rewardAmount(r: any): string {
|
||||
if (r.rewardType === 'cash') return `£${(Number(r.value) / 100).toFixed(2)}`;
|
||||
if (r.rewardType === 'points') return `${r.value} pts`;
|
||||
return r.label;
|
||||
}
|
||||
|
||||
function rewardStatus(r: any): string {
|
||||
if (!r.claimed) return 'Outstanding';
|
||||
if (r.claimedAt?.slice(0, 10) === r.date) return 'Auto';
|
||||
return 'Claimed';
|
||||
}
|
||||
|
||||
function isOutstanding(r: any): boolean { return !r.claimed; }
|
||||
|
||||
let sorted = $derived([...rewards].sort((a: any, b: any) => {
|
||||
const da = a.date || a.id || '';
|
||||
const db = b.date || b.id || '';
|
||||
return da < db ? 1 : da > db ? -1 : 0;
|
||||
}))
|
||||
|
||||
let toast = $state('')
|
||||
|
||||
async function fire() {
|
||||
const { default: confetti } = await import('@hiseb/confetti');
|
||||
confetti({ count: 80, size: 4, velocity: 500, fade: true, position: { x: window.innerWidth / 2, y: 0 } });
|
||||
}
|
||||
|
||||
function showToast(msg: string) {
|
||||
toast = msg;
|
||||
setTimeout(() => toast = '', 4000);
|
||||
}
|
||||
|
||||
let totalOutstanding = $derived(
|
||||
rewards
|
||||
.filter((r: any) => isOutstanding(r))
|
||||
.filter((r: any) => r.rewardType === 'cash')
|
||||
.reduce((sum: number, r: any) => sum + Number(r.value), 0)
|
||||
)
|
||||
</script>
|
||||
|
||||
<ViewHeader title="Reward Ledger" subtitle="All rewards earned by members" />
|
||||
|
||||
{#if toast}
|
||||
<div class="toast">{toast}</div>
|
||||
{/if}
|
||||
|
||||
<CardGrid>
|
||||
<Card cols={3}>
|
||||
{#if rewards.length === 0}
|
||||
<p style="color:#9ca3af;text-align:center;padding:2rem">No rewards yet. Points earned will automatically create bonus rewards when thresholds are met.</p>
|
||||
{:else}
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Member</th>
|
||||
<th>Description</th>
|
||||
<th class="num">Amount</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sorted as r}
|
||||
{@const outstanding = isOutstanding(r)}
|
||||
<tr class:claimed={!outstanding}>
|
||||
<td class="date">{r.date?.slice(0, 10) || '—'}</td>
|
||||
<td>
|
||||
<span class="dot" style="background:{memberColor(r.memberId)}"></span>
|
||||
{memberName(r.memberId)}
|
||||
</td>
|
||||
<td>{r.label}</td>
|
||||
<td class="num">{rewardAmount(r)}</td>
|
||||
<td>
|
||||
<span class="status-badge" class:outstanding class:claimed-status={!outstanding}>
|
||||
{rewardStatus(r)}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{#if outstanding}
|
||||
<form method="POST" action="?/claim" use:enhance={() => { return async (args: any) => { const d = args.result.data || {}; if (d.error) showToast(d.error); else if (args.result.type === 'success') { if (d.record) famStore.applyRecord('rewards', d.record, 'update'); fire(); } }; }}>
|
||||
<input name="id" type="hidden" value={r.id} />
|
||||
<Button type="submit" size="sm">Claim</Button>
|
||||
</form>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td></td>
|
||||
<td></td>
|
||||
<td class="num"><strong>Outstanding cash</strong></td>
|
||||
<td class="num"><strong>£{(totalOutstanding / 100).toFixed(2)}</strong></td>
|
||||
<td></td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
{/if}
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<style>
|
||||
.toast { position: fixed; top: 3.5rem; right: 1.5rem; background: #dc2626; color: white; padding: 0.6rem 1rem; border-radius: 8px; font-size: 0.85rem; z-index: 999; box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
|
||||
th, td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #e5e7eb; text-align: left; }
|
||||
th { background: #f9fafb; font-weight: 600; position: sticky; top: 0; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.date { font-family: monospace; font-size: 0.85rem; color: #6b7280; white-space: nowrap; }
|
||||
tr.claimed { opacity: 0.5; }
|
||||
tr.claimed td { color: #9ca3af; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 0.3rem; }
|
||||
.status-badge { font-size: 0.75rem; padding: 2px 8px; border-radius: 10px; }
|
||||
.status-badge.outstanding { background: #fef3c7; color: #92400e; }
|
||||
.status-badge.claimed-status { background: #d1fae5; color: #065f46; }
|
||||
tfoot td { border-top: 2px solid #d1d5db; padding-top: 0.6rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,57 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { hono } from '$lib/server/hono';
|
||||
import type { RequestEvent } from '@sveltejs/kit';
|
||||
|
||||
export async function load(event: RequestEvent) {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const [members, fam] = await Promise.all([
|
||||
hono.admin.list(event, 'members', famId),
|
||||
hono.admin.fam(event, famId),
|
||||
]);
|
||||
return { members, fam };
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
regenInvite: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
return { newCode: (await hono.admin.regenInvite(event, famId)).inviteCode };
|
||||
},
|
||||
|
||||
addMember: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
if (!name) return { error: 'Name required' };
|
||||
await hono.admin.create(event, 'members', famId, { name, color: '#6366f1' });
|
||||
},
|
||||
|
||||
renameFam: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
if (!name) return { error: 'Name required' };
|
||||
return await hono.admin.renameFam(event, famId, name);
|
||||
},
|
||||
|
||||
deleteMember: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const id = fd.get('id') as string;
|
||||
if (!id) return { error: 'Member ID required' };
|
||||
await hono.admin.remove(event, 'members', famId, id);
|
||||
},
|
||||
|
||||
updatePayday: async (event: RequestEvent) => {
|
||||
if (!event.locals.session) throw redirect(303, '/login');
|
||||
const famId = event.locals.session.famId;
|
||||
const fd = await event.request.formData();
|
||||
const payday = parseInt(fd.get('payday') as string, 10);
|
||||
if (isNaN(payday) || payday < 0 || payday > 6) return { error: 'Payday must be 0-6' };
|
||||
return await hono.admin.updatePayday(event, famId, payday);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,171 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
import { famStore } from '$lib/stores/fam.svelte';
|
||||
import { ViewHeader, CardGrid, Card, Button } from '$lib/components';
|
||||
import { onDestroy } from 'svelte';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
let { data } = $props();
|
||||
|
||||
let fam = $state(data.fam)
|
||||
let famSlug = $state(page.params.fam)
|
||||
|
||||
let childInviteUrl = $derived(`${page.url.origin}/join/${fam?.inviteCode}`)
|
||||
let addName = $state('')
|
||||
let rename = $state('')
|
||||
let selectedMember = $state('')
|
||||
let payday = $state(fam?.payday != null ? Number(fam.payday) : 1)
|
||||
let showQR = $state(false)
|
||||
let qrDataUrl = $state('')
|
||||
let copied = $state(false)
|
||||
let parentInviteEmail = $state('')
|
||||
|
||||
let selectedInviteUrl = $derived(
|
||||
selectedMember
|
||||
? `${page.url.origin}/join/${fam?.inviteCode}/${selectedMember}`
|
||||
: childInviteUrl
|
||||
)
|
||||
|
||||
let members = $state(famStore.initialized ? famStore.members : (data.members || []))
|
||||
|
||||
function copy(url: string) {
|
||||
navigator.clipboard.writeText(url)
|
||||
copied = true
|
||||
setTimeout(() => copied = false, 2000)
|
||||
}
|
||||
|
||||
async function generateQR(url: string) {
|
||||
qrDataUrl = await QRCode.toDataURL(url, { width: 200, margin: 1 })
|
||||
}
|
||||
|
||||
function handleParentInvite() {
|
||||
alert('Parent invite coming soon — email would be sent to ' + parentInviteEmail)
|
||||
}
|
||||
</script>
|
||||
|
||||
<ViewHeader title="Settings" />
|
||||
|
||||
<CardGrid>
|
||||
<Card title="Family Name">
|
||||
<form method="POST" action="?/renameFam" use:enhance>
|
||||
<input name="name" bind:value={rename} placeholder={fam?.name || 'Family name'} required />
|
||||
<Button type="submit" size="sm">Rename</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
<Card title="Members ({members.length})" cols={2}>
|
||||
<form method="POST" action="?/addMember" use:enhance>
|
||||
<input name="name" bind:value={addName} placeholder="Member name" required />
|
||||
<Button type="submit" size="sm">Add</Button>
|
||||
</form>
|
||||
|
||||
<ul>
|
||||
{#each members as m}
|
||||
<li>
|
||||
<span class="dot" style="background:{m.color}"></span>
|
||||
{m.name}
|
||||
{m.deviceToken ? '' : ' (pending join)'}
|
||||
<a href="/{famSlug}/{m.name}" class="link">Kanban</a>
|
||||
<form method="POST" action="?/deleteMember" use:enhance class="inline">
|
||||
<input type="hidden" name="id" value={m.id} />
|
||||
<Button type="submit" variant="danger" size="sm" onclick={() => confirm('Remove {m.name}?')}>Remove</Button>
|
||||
</form>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
<Card title="Payday" cols={1}>
|
||||
<form method="POST" action="?/updatePayday" use:enhance>
|
||||
<select name="payday" bind:value={payday}>
|
||||
<option value={0}>Sunday</option>
|
||||
<option value={1}>Monday</option>
|
||||
<option value={2}>Tuesday</option>
|
||||
<option value={3}>Wednesday</option>
|
||||
<option value={4}>Thursday</option>
|
||||
<option value={5}>Friday</option>
|
||||
<option value={6}>Saturday</option>
|
||||
</select>
|
||||
<Button type="submit" size="sm">Set payday</Button>
|
||||
</form>
|
||||
<p class="hint">The week starts on this day for tracking weekly chores and bonus cycles.</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Invite Children" cols={1}>
|
||||
<p class="code">{fam?.inviteCode || '...'}</p>
|
||||
|
||||
<div class="invite-row">
|
||||
<label>Member:</label>
|
||||
<select bind:value={selectedMember}>
|
||||
<option value="">— General link —</option>
|
||||
{#each members as m}
|
||||
<option value={m.name}>{m.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<p class="hint">Re-joining a member will disconnect their old device.</p>
|
||||
|
||||
<p class="invite-url">{selectedInviteUrl}</p>
|
||||
|
||||
<div class="actions">
|
||||
<Button onclick={() => copy(selectedInviteUrl)} size="sm">
|
||||
{copied ? 'Copied!' : 'Copy link'}
|
||||
</Button>
|
||||
|
||||
<Button onclick={async () => { showQR = !showQR; if (!showQR) { qrDataUrl = ''; } else { await generateQR(selectedInviteUrl); } }} size="sm">
|
||||
{showQR ? 'Hide QR' : 'Show QR'}
|
||||
</Button>
|
||||
|
||||
<form method="POST" action="?/regenInvite" use:enhance class="inline">
|
||||
<Button type="submit" size="sm">Renew code</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{#if showQR && qrDataUrl}
|
||||
<img src={qrDataUrl} alt="QR Code" class="qr" />
|
||||
{/if}
|
||||
</Card>
|
||||
|
||||
<Card title="Invite Parent" cols={1}>
|
||||
<p class="hint">Send an email invitation for another parent to join as an admin.</p>
|
||||
<div class="invite-row">
|
||||
<input
|
||||
type="email"
|
||||
bind:value={parentInviteEmail}
|
||||
placeholder="parent@example.com"
|
||||
/>
|
||||
<Button onclick={handleParentInvite} size="sm">Send invite</Button>
|
||||
</div>
|
||||
<p class="hint">They will set up their own password on first login.</p>
|
||||
</Card>
|
||||
|
||||
<Card title="Data" cols={1}>
|
||||
<div class="actions">
|
||||
<Button variant="ghost" size="sm" disabled>Download CSV (coming soon)</Button>
|
||||
<Button variant="danger" size="sm" disabled>Delete Family (coming soon)</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</CardGrid>
|
||||
|
||||
<style>
|
||||
.section { margin: 0.5rem 0; }
|
||||
.code { font-family: monospace; font-size: 1.2rem; padding: 0.5rem; background: #f3f4f6; border-radius: 4px; display: inline-block; }
|
||||
.invite-url { font-family: monospace; font-size: 0.9rem; word-break: break-all; background: #f9fafb; padding: 0.4rem; border-radius: 4px; }
|
||||
.invite-row { display: flex; gap: 0.5rem; align-items: center; margin: 0.5rem 0; }
|
||||
.invite-row select { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
|
||||
.invite-row input { padding: 0.4rem; border: 1px solid #ccc; border-radius: 4px; flex: 1; }
|
||||
.hint { font-size: 0.85rem; color: #9ca3af; }
|
||||
.actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; flex-wrap: wrap; }
|
||||
.qr { margin-top: 0.5rem; border: 1px solid #e5e7eb; border-radius: 4px; }
|
||||
ul { list-style: none; padding: 0; }
|
||||
li { padding: 0.3rem 0; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||
.dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
|
||||
.link { font-size: 0.85rem; color: #6366f1; }
|
||||
form { display: flex; gap: 0.5rem; margin-bottom: 0.5rem; }
|
||||
input, select, button { padding: 0.4rem 0.7rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
button { background: #6366f1; color: white; border: none; cursor: pointer; }
|
||||
button:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.danger { background: #dc2626; font-size: 0.8rem; padding: 0.2rem 0.5rem; }
|
||||
.inline { display: inline; margin: 0; }
|
||||
</style>
|
||||
@@ -0,0 +1,23 @@
|
||||
import { pbAdmin } from '$lib/server/pb-admin';
|
||||
|
||||
export async function load() {
|
||||
const fams = await pbAdmin.getList('fams');
|
||||
const famsWithStats = await Promise.all(fams.map(async (fam: any) => {
|
||||
const [members, rewards] = await Promise.all([
|
||||
pbAdmin.getList('members', `famId = '${fam.id}'`),
|
||||
pbAdmin.getList('rewards', `famId = '${fam.id}'`),
|
||||
]);
|
||||
return {
|
||||
id: fam.id, name: fam.name, slug: fam.slug, inviteCode: fam.inviteCode,
|
||||
memberCount: members.length,
|
||||
claimedRewards: (rewards as any[]).filter((r: any) => r.claimed).length,
|
||||
totalRewards: rewards.length,
|
||||
};
|
||||
}));
|
||||
|
||||
const totalFams = fams.length;
|
||||
const totalMembers = famsWithStats.reduce((s: number, f: any) => s + f.memberCount, 0);
|
||||
const totalRewards = famsWithStats.reduce((s: number, f: any) => s + f.totalRewards, 0);
|
||||
|
||||
return { fams: famsWithStats, totalFams, totalMembers, totalRewards };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<h1>Super Admin</h1>
|
||||
|
||||
<div class="stats">
|
||||
<div class="card"><strong>{data.totalFams}</strong> Families</div>
|
||||
<div class="card"><strong>{data.totalMembers}</strong> Members</div>
|
||||
<div class="card"><strong>{data.totalRewards}</strong> Rewards</div>
|
||||
</div>
|
||||
|
||||
<h2>Families</h2>
|
||||
<table>
|
||||
<thead><tr><th>Name</th><th>Slug</th><th>Members</th><th>Rewards</th><th>Invite Code</th></tr></thead>
|
||||
<tbody>
|
||||
{#each data.fams as fam}
|
||||
<tr>
|
||||
<td><a href="/{fam.slug}/admin">{fam.name}</a></td>
|
||||
<td>{fam.slug}</td>
|
||||
<td>{fam.memberCount}</td>
|
||||
<td>{fam.claimedRewards}/{fam.totalRewards}</td>
|
||||
<td><code>{fam.inviteCode}</code></td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<style>
|
||||
.stats { display: flex; gap: 1rem; margin: 1rem 0; }
|
||||
.card { padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; flex: 1; text-align: center; }
|
||||
.card strong { font-size: 1.5rem; display: block; }
|
||||
table { width: 100%; border-collapse: collapse; margin: 1rem 0; }
|
||||
th, td { padding: 0.5rem; border: 1px solid #e5e7eb; text-align: left; }
|
||||
th { background: #f9fafb; font-weight: 600; }
|
||||
code { background: #eee; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.85em; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { joinMember, setDeviceTokenCookie } from '$lib/server/auth';
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
const code = event.params.code;
|
||||
const fd = await event.request.formData();
|
||||
const name = fd.get('name') as string;
|
||||
|
||||
if (!name) {
|
||||
return fail(400, { error: 'Name is required' });
|
||||
}
|
||||
|
||||
const deviceToken = crypto.randomUUID();
|
||||
|
||||
try {
|
||||
const result = await joinMember(code, name, deviceToken);
|
||||
setDeviceTokenCookie(event, deviceToken);
|
||||
throw redirect(303, `/${result.famSlug}/${result.name}`);
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Invalid invite code' });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let { form } = $props();
|
||||
let name = $state('');
|
||||
</script>
|
||||
|
||||
<h1>Join family</h1>
|
||||
|
||||
<p>Enter your name to join. It must match a member slot created by your admin.</p>
|
||||
|
||||
<form method="POST">
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
Your name
|
||||
<input name="name" bind:value={name} required placeholder="Enter your exact name" />
|
||||
</label>
|
||||
|
||||
<button type="submit">Join</button>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 2rem auto; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
button { padding: 0.75rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { setDeviceTokenCookie } from '$lib/server/auth';
|
||||
import { SERVER_IP, PROXY_PORT } from '../../../../../../config.ts';
|
||||
|
||||
const HONO_URL = `http://${SERVER_IP}:${PROXY_PORT}`;
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
const code = event.params.code;
|
||||
const name = event.params.member;
|
||||
|
||||
const res = await fetch(`${HONO_URL}/api/members/direct-join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inviteCode: code, name }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) return fail(400, { error: data.error || 'Join failed' });
|
||||
|
||||
setDeviceTokenCookie(event, data.deviceToken);
|
||||
|
||||
throw redirect(303, `/${data.famSlug}/${data.name}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { enhance } from '$app/forms';
|
||||
|
||||
let { form } = $props();
|
||||
let memberName = $derived(page.params.member);
|
||||
</script>
|
||||
|
||||
<h1>Join family</h1>
|
||||
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{:else}
|
||||
<p>Click below to join as <strong>{memberName}</strong></p>
|
||||
{/if}
|
||||
|
||||
<form method="POST" use:enhance>
|
||||
<button type="submit">Join</button>
|
||||
</form>
|
||||
|
||||
<style>
|
||||
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
|
||||
button { padding: 0.75rem 1.5rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; font-size: 1rem; }
|
||||
</style>
|
||||
@@ -0,0 +1,33 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { login, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
|
||||
const fd = await event.request.formData();
|
||||
const email = fd.get('email') as string;
|
||||
const password = fd.get('password') as string;
|
||||
|
||||
if (!email || !password) {
|
||||
return fail(400, { error: 'Email and password required', email });
|
||||
}
|
||||
|
||||
let result: any;
|
||||
try {
|
||||
result = await login(email, password);
|
||||
} catch (e) {
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Login failed', email });
|
||||
}
|
||||
|
||||
setSessionCookie(event, {
|
||||
famId: result.famId,
|
||||
userId: result.userId,
|
||||
famSlug: result.famSlug,
|
||||
memberName: result.memberName,
|
||||
role: result.role,
|
||||
});
|
||||
setPbTokenCookie(event, result.token);
|
||||
|
||||
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
let { form } = $props();
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
</script>
|
||||
|
||||
<h1>Log in</h1>
|
||||
|
||||
<form method="POST">
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" bind:value={email} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" bind:value={password} required />
|
||||
</label>
|
||||
|
||||
<button type="submit">Log in</button>
|
||||
</form>
|
||||
|
||||
<p>Don't have a family yet? <a href="/signup">Create one</a></p>
|
||||
|
||||
<style>
|
||||
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 2rem auto; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
button { padding: 0.75rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
|
||||
</style>
|
||||
@@ -0,0 +1,14 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { clearSession } from '$lib/server/auth';
|
||||
|
||||
export function load(event) {
|
||||
clearSession(event);
|
||||
throw redirect(303, '/');
|
||||
}
|
||||
|
||||
export const actions = {
|
||||
default: (event) => {
|
||||
clearSession(event);
|
||||
throw redirect(303, '/');
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,35 @@
|
||||
import { fail, redirect } from '@sveltejs/kit';
|
||||
import { signup, setSessionCookie, setPbTokenCookie } from '$lib/server/auth';
|
||||
|
||||
export const actions = {
|
||||
default: async (event) => {
|
||||
const fd = await event.request.formData();
|
||||
const email = fd.get('email') as string;
|
||||
const password = fd.get('password') as string;
|
||||
const famName = fd.get('famName') as string;
|
||||
const parentName = fd.get('parentName') as string;
|
||||
|
||||
if (!email || !password || !famName) {
|
||||
return fail(400, { error: 'All fields required', email, famName });
|
||||
}
|
||||
if (password.length < 8) {
|
||||
return fail(400, { error: 'Password must be at least 8 characters', email, famName });
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await signup(email, password, famName, parentName || email.split('@')[0]);
|
||||
setSessionCookie(event, {
|
||||
famId: result.famId,
|
||||
userId: result.userId,
|
||||
famSlug: result.famSlug,
|
||||
memberName: result.memberName,
|
||||
role: result.role,
|
||||
});
|
||||
setPbTokenCookie(event, result.token);
|
||||
throw redirect(303, `/${result.famSlug}/${result.memberName}`);
|
||||
} catch (e) {
|
||||
if (e instanceof redirect) throw e;
|
||||
return fail(400, { error: e instanceof Error ? e.message : 'Signup failed', email, famName });
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
<script lang="ts">
|
||||
let { form } = $props();
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let famName = $state('');
|
||||
let parentName = $state('');
|
||||
</script>
|
||||
|
||||
<h1>Create your family</h1>
|
||||
|
||||
<form method="POST">
|
||||
{#if form?.error}
|
||||
<p class="error">{form.error}</p>
|
||||
{/if}
|
||||
|
||||
<label>
|
||||
Family name
|
||||
<input name="famName" bind:value={famName} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Your name (parent)
|
||||
<input name="parentName" bind:value={parentName} placeholder="Mum / Dad" required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Email
|
||||
<input type="email" name="email" bind:value={email} required />
|
||||
</label>
|
||||
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="password" bind:value={password} minlength={8} required />
|
||||
</label>
|
||||
|
||||
<button type="submit">Create family</button>
|
||||
</form>
|
||||
|
||||
<p>Already have an account? <a href="/login">Log in</a></p>
|
||||
|
||||
<style>
|
||||
form { display: flex; flex-direction: column; gap: 1rem; max-width: 400px; margin: 2rem auto; }
|
||||
label { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
input { padding: 0.5rem; border: 1px solid #ccc; border-radius: 4px; }
|
||||
button { padding: 0.75rem; background: #6366f1; color: white; border: none; border-radius: 4px; cursor: pointer; }
|
||||
.error { color: #dc2626; padding: 0.5rem; background: #fef2f2; border-radius: 4px; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user