feat: add EN/DE internationalization to cospend section

Move cospend routes to parameterized [cospendRoot=cospendRoot] supporting
both /cospend (DE) and /expenses (EN). Add cospendI18n.ts with 100+
translation keys covering all pages, components, categories, frequency
descriptions, and error messages. Translate BarChart legend, ImageUpload,
UsersList, SplitMethodSelector, DebtBreakdown, EnhancedBalance, and
PaymentModal. Update LanguageSelector and hooks.server.ts for /expenses.
This commit is contained in:
2026-04-08 15:46:00 +02:00
parent b7c7b37c94
commit 9af36b0c14
35 changed files with 991 additions and 458 deletions
@@ -0,0 +1,9 @@
import type { LayoutServerLoad } from "./$types"
import { detectCospendLang } from '$lib/js/cospendI18n';
export const load : LayoutServerLoad = async ({locals, url}) => {
return {
session: locals.session ?? await locals.auth(),
lang: detectCospendLang(url.pathname) as 'en' | 'de'
}
};
@@ -7,10 +7,16 @@
import PaymentModal from '$lib/components/cospend/PaymentModal.svelte';
import Header from '$lib/components/Header.svelte';
import UserHeader from '$lib/components/UserHeader.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { LayoutDashboard, Wallet, RefreshCw, ShoppingCart } from '@lucide/svelte';
import { detectCospendLang, cospendRoot, cospendLabels } from '$lib/js/cospendI18n';
let { data, children } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const labels = $derived(cospendLabels(lang));
let showModal = $state(false);
/** @type {string | null} */
let paymentId = $state(null);
@@ -19,14 +25,14 @@
$effect(() => {
// Check if URL contains payment view route OR if we have paymentId in state
const match = $page.url.pathname.match(/\/cospend\/payments\/view\/([^\/]+)/);
const match = $page.url.pathname.match(/\/(cospend|expenses)\/payments\/view\/([^\/]+)/);
const statePaymentId = $page.state?.paymentId;
const isOnDashboard = $page.route.id === '/cospend/dash';
const isOnDashboard = $page.route.id === '/[cospendRoot=cospendRoot]/dash';
// Only show modal if we're on the dashboard AND have a payment to show
if (isOnDashboard && (match || statePaymentId)) {
showModal = true;
paymentId = match ? match[1] : statePaymentId ?? null;
paymentId = match ? match[2] : statePaymentId ?? null;
} else {
showModal = false;
paymentId = null;
@@ -39,7 +45,7 @@
paymentId = null;
// Dispatch a custom event to trigger dashboard refresh
if ($page.route.id === '/cospend/dash') {
if ($page.route.id === '/[cospendRoot=cospendRoot]/dash') {
window.dispatchEvent(new CustomEvent('dashboardRefresh'));
}
}
@@ -47,9 +53,9 @@
/** @param {string} path */
function isActive(path) {
const currentPath = $page.url.pathname;
// Exact match for cospend root
if (path === '/cospend/dash') {
return currentPath === '/cospend/dash' || currentPath === '/cospend/dash/';
// Exact match for dash
if (path.endsWith('/dash')) {
return currentPath === path || currentPath === path + '/';
}
// For other paths, check if current path starts with the link path
return currentPath.startsWith(path);
@@ -60,17 +66,18 @@
{#snippet links()}
<ul class="site_header">
{#if !isGuest}
<li style="--active-fill: var(--nord9)"><a href="/cospend/dash" class:active={isActive('/cospend/dash')}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Dashboard</span></a></li>
<li style="--active-fill: var(--nord9)"><a href="/{root}/dash" class:active={isActive(`/${root}/dash`)}><LayoutDashboard size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.dash}</span></a></li>
{/if}
<li style="--active-fill: var(--nord13)"><a href="/cospend/list" class:active={isActive('/cospend/list')}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">Liste</span></a></li>
<li style="--active-fill: var(--nord13)"><a href="/{root}/list" class:active={isActive(`/${root}/list`)}><ShoppingCart size={16} strokeWidth={1.5} class="nav-icon" /><span class="nav-label">{labels.list}</span></a></li>
{#if !isGuest}
<li style="--active-fill: var(--nord14)"><a href="/cospend/payments" class:active={isActive('/cospend/payments')}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">All Payments</span></a></li>
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/cospend/recurring" class:active={isActive('/cospend/recurring')}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">Recurring</span></a></li>
<li style="--active-fill: var(--nord14)"><a href="/{root}/payments" class:active={isActive(`/${root}/payments`)}><span class="nav-icon-wrap nav-icon-wallet"><Wallet size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">{labels.payments}</span></a></li>
<li style="--active-fill: var(--nord12); --active-shape: circle(50%)"><a href="/{root}/recurring" class:active={isActive(`/${root}/recurring`)}><span class="nav-icon-wrap"><RefreshCw size={16} strokeWidth={1.5} class="nav-icon" /></span><span class="nav-label">{labels.recurring}</span></a></li>
{/if}
</ul>
{/snippet}
{#snippet right_side()}
<LanguageSelector lang={lang} />
<UserHeader {user}></UserHeader>
{/snippet}
@@ -0,0 +1,7 @@
import { redirect } from '@sveltejs/kit';
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
export function load({ url }) {
const lang = detectCospendLang(url.pathname);
redirect(302, `/${cospendRoot(lang)}/list`);
}
@@ -7,14 +7,18 @@
import EnhancedBalance from '$lib/components/cospend/EnhancedBalance.svelte';
import DebtBreakdown from '$lib/components/cospend/DebtBreakdown.svelte';
import BarChart from '$lib/components/cospend/BarChart.svelte';
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
import { getCategoryEmoji } from '$lib/utils/categories';
import { isSettlementPayment, getSettlementIcon, getSettlementClasses, getSettlementReceiver } from '$lib/utils/settlements';
import AddButton from '$lib/components/AddButton.svelte';
import { formatCurrency } from '$lib/utils/formatters';
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName } from '$lib/js/cospendI18n';
let { data } = $props(); // Contains session data and balance from server
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
// Use server-side data, with fallback for progressive enhancement
let balance = $derived(data.balance || {
@@ -79,7 +83,7 @@
}
function formatDate(/** @type {string} */ dateString) {
return new Date(dateString).toLocaleDateString('de-CH');
return new Date(dateString).toLocaleDateString(loc);
}
function truncateDescription(/** @type {string} */ description, maxLength = 100) {
@@ -92,7 +96,7 @@
// Progressive enhancement: if JavaScript is available, use pushState for modal behavior
if (typeof pushState !== 'undefined') {
event.preventDefault();
pushState(`/cospend/payments/view/${paymentId}`, { paymentId });
pushState(`/${root}/payments/view/${paymentId}`, { paymentId });
}
// Otherwise, let the regular link navigation happen (no preventDefault)
}
@@ -120,11 +124,11 @@
</script>
<svelte:head>
<title>Cospend - Expense Sharing</title>
<title>{t('cospend_title', lang)}</title>
</svelte:head>
<main class="cospend-main">
<h1>Cospend</h1>
<h1>{t('cospend', lang)}</h1>
<!-- Responsive layout for balance and chart -->
<div class="dashboard-layout">
@@ -133,7 +137,7 @@
<div class="actions">
{#if balance.netBalance !== 0}
<a href="/cospend/settle" class="btn btn-settlement">Settle Debts</a>
<a href="/{root}/settle" class="btn btn-settlement">{t('settle_debts', lang)}</a>
{/if}
</div>
@@ -143,12 +147,13 @@
<!-- Monthly Expenses Chart -->
<div class="chart-section">
{#if expensesLoading}
<div class="loading">Loading monthly expenses chart...</div>
<div class="loading">{t('loading_monthly', lang)}</div>
{:else if monthlyExpensesData.datasets && monthlyExpensesData.datasets.length > 0}
<BarChart
data={monthlyExpensesData}
title="Monthly Expenses by Category"
title={t('monthly_expenses_chart', lang)}
height="400px"
{lang}
onFilterChange={(/** @type {string[] | null} */ categories) => categoryFilter = categories}
/>
{:else}
@@ -162,26 +167,26 @@
</div>
{#if loading}
<div class="loading">Loading recent activity...</div>
<div class="loading">{t('loading_recent', lang)}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if balance.recentSplits && balance.recentSplits.length > 0}
<div class="recent-activity">
<div class="recent-activity-header">
<h2>Recent Activity{#if categoryFilter} <span class="filter-label">{categoryFilter.map((/** @type {any} */ c) => getCategoryName(c)).join(', ')}</span>{/if}</h2>
<h2>{t('recent_activity', lang)}{#if categoryFilter} <span class="filter-label">{categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ')}</span>{/if}</h2>
{#if categoryFilter}
<button class="clear-filter" onclick={() => categoryFilter = null}>Clear filter</button>
<button class="clear-filter" onclick={() => categoryFilter = null}>{t('clear_filter', lang)}</button>
{/if}
</div>
{#if filteredSplits.length === 0}
<p class="no-results">No recent activity in {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => getCategoryName(c)).join(', ') : ''}.</p>
<p class="no-results">{t('no_recent_in', lang)} {categoryFilter ? categoryFilter.map((/** @type {any} */ c) => paymentCategoryName(c, lang)).join(', ') : ''}.</p>
{/if}
<div class="activity-dialog">
{#each filteredSplits as split}
{#if isSettlementPayment(split.paymentId)}
<!-- Settlement Payment Display - User -> User Flow -->
<a
href="/cospend/payments/view/{split.paymentId?._id}"
href="/{root}/payments/view/{split.paymentId?._id}"
class="settlement-flow-activity"
onclick={(e) => handlePaymentClick(split.paymentId?._id, e)}
>
@@ -193,7 +198,7 @@
</div>
<div class="settlement-arrow-section">
<div class="settlement-amount-large">
{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
{formatCurrency(Math.abs(split.amount), 'CHF', loc)}
</div>
<div class="settlement-flow-arrow"></div>
<div class="settlement-date">{formatDate(split.paymentId?.date || split.paymentId?.createdAt)}</div>
@@ -212,7 +217,7 @@
<div class="message-content">
<ProfilePicture username={split.paymentId?.paidBy || 'Unknown'} size={36} />
<a
href="/cospend/payments/view/{split.paymentId?._id}"
href="/{root}/payments/view/{split.paymentId?._id}"
class="activity-bubble"
onclick={(e) => handlePaymentClick(split.paymentId?._id, e)}
>
@@ -220,20 +225,20 @@
<div class="user-info">
<div class="payment-title-row">
<span class="category-emoji">{getCategoryEmoji(split.paymentId?.category || 'groceries')}</span>
<strong class="payment-title">{split.paymentId?.title || 'Payment'}</strong>
<strong class="payment-title">{split.paymentId?.title || t('payment', lang)}</strong>
</div>
<span class="username">Paid by {split.paymentId?.paidBy || 'Unknown'}</span>
<span class="category-name">{getCategoryName(split.paymentId?.category || 'groceries')}</span>
<span class="username">{t('paid_by', lang)} {split.paymentId?.paidBy || 'Unknown'}</span>
<span class="category-name">{paymentCategoryName(split.paymentId?.category || 'groceries', lang)}</span>
</div>
<div class="activity-amount"
class:positive={split.amount < 0}
class:negative={split.amount > 0}>
{#if split.amount > 0}
-{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
-{formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else if split.amount < 0}
+{formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
+{formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else}
{formatCurrency(split.amount, 'CHF', 'de-CH')}
{formatCurrency(split.amount, 'CHF', loc)}
{/if}
</div>
</div>
@@ -257,7 +262,7 @@
{/if}
</main>
<AddButton href="/cospend/payments/add" />
<AddButton href="/{root}/payments/add" />
<style>
.cospend-main {
@@ -11,6 +11,8 @@
import iconCategoriesData from '$lib/data/shoppingIconCategories.json';
import { Share2, X, Copy, Check } from '@lucide/svelte';
import { page } from '$app/stores';
import { detectCospendLang, t, locale, categoryName, formatTTL as formatTTLi18n, ttlOptions } from '$lib/js/cospendI18n';
let { data } = $props();
let user = $derived(data.session?.user?.nickname || 'guest');
@@ -18,6 +20,9 @@
let isGuest = $derived(!data.session);
const sync = getShoppingSync();
const lang = $derived(detectCospendLang($page.url.pathname));
const loc = $derived(locale(lang));
/** @type {Record<string, { icon: typeof Plus, color: string }>} */
const categoryMeta = {
'Obst & Gemüse': { icon: Apple, color: 'var(--nord14)' },
@@ -266,23 +271,10 @@
/** @param {string} expiresAt */
function formatTTL(expiresAt) {
const diff = new Date(expiresAt).getTime() - Date.now();
if (diff <= 0) return 'abgelaufen';
const mins = Math.round(diff / 60000);
if (mins < 60) return `${mins} Min.`;
const hours = Math.round(diff / 3600000);
if (hours < 24) return `${hours} Std.`;
const days = Math.round(diff / 86400000);
return `${days} Tag${days > 1 ? 'e' : ''}`;
return formatTTLi18n(expiresAt, lang);
}
const TTL_OPTIONS = [
{ label: '1 Stunde', ms: 1 * 60 * 60 * 1000 },
{ label: '6 Stunden', ms: 6 * 60 * 60 * 1000 },
{ label: '24 Stunden', ms: 24 * 60 * 60 * 1000 },
{ label: '3 Tage', ms: 3 * 24 * 60 * 60 * 1000 },
{ label: '7 Tage', ms: 7 * 24 * 60 * 60 * 1000 },
];
let TTL_OPTIONS = $derived(ttlOptions(lang));
/**
* @param {string} id
@@ -303,12 +295,13 @@
}
}
/** @param {{ id: string, token: string }} t */
async function copyTokenLink(t) {
const url = new URL('/cospend/list', window.location.origin);
url.searchParams.set('token', t.token);
/** @param {{ id: string, token: string }} tok */
async function copyTokenLink(tok) {
const root = $page.url.pathname.split('/')[1];
const url = new URL(`/${root}/list`, window.location.origin);
url.searchParams.set('token', tok.token);
await navigator.clipboard.writeText(url.toString());
copiedId = t.id;
copiedId = tok.id;
showCopyToast = true;
setTimeout(() => { copiedId = null; showCopyToast = false; }, 2000);
}
@@ -321,7 +314,7 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
shareTokens = shareTokens.filter(t => t.id !== id);
shareTokens = shareTokens.filter(tok => tok.id !== id);
} catch (err) {
console.error('[shopping] Delete token error:', err);
}
@@ -338,8 +331,8 @@
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, expiresAt: newExpiry })
});
shareTokens = shareTokens.map(t =>
t.id === id ? { ...t, expiresAt: newExpiry } : t
shareTokens = shareTokens.map(tok =>
tok.id === id ? { ...tok, expiresAt: newExpiry } : tok
);
} catch (err) {
console.error('[shopping] Update token error:', err);
@@ -369,15 +362,15 @@
<div class="shopping-page">
<header class="page-header">
<div class="header-row">
<h1>Einkaufsliste <SyncIndicator status={sync.status} /></h1>
<h1>{t('shopping_list_title', lang)} <SyncIndicator status={sync.status} /></h1>
{#if !isGuest}
<button class="btn-share" onclick={openShareModal} title="Teilen">
<button class="btn-share" onclick={openShareModal} title={t('share', lang)}>
<Share2 size={16} />
</button>
{/if}
</div>
{#if totalCount > 0}
<p class="subtitle">{checkedCount} / {totalCount} erledigt</p>
<p class="subtitle">{checkedCount} / {totalCount} {t('items_done', lang)}</p>
{/if}
<div class="store-picker">
<Store size={13} />
@@ -397,7 +390,7 @@
bind:value={newItemName}
onkeydown={onKeydown}
type="text"
placeholder="Artikel hinzufügen..."
placeholder={t('add_item_placeholder', lang)}
autocomplete="off"
/>
<button class="btn-add" onclick={addItem} disabled={!newItemName.trim()}>
@@ -406,7 +399,7 @@
</div>
{#if totalCount === 0}
<p class="empty-state">Die Einkaufsliste ist leer</p>
<p class="empty-state">{t('empty_list', lang)}</p>
{:else}
<div class="item-list">
{#each groupedItems as group (group.category)}
@@ -418,7 +411,7 @@
<div class="category-icon">
<CategoryIcon size={14} />
</div>
<h2>{group.category}</h2>
<h2>{categoryName(group.category, lang)}</h2>
<span class="category-count">{group.items.filter(i => !i.checked).length}</span>
</div>
</div>
@@ -459,7 +452,7 @@
{#if checkedCount > 0}
<button class="btn-clear-checked" onclick={() => sync.clearChecked()}>
<ListX size={16} />
Erledigte entfernen ({checkedCount})
{t('clear_checked', lang)} ({checkedCount})
</button>
{/if}
{/if}
@@ -476,7 +469,7 @@
<h3>{parseQuantity(editingItem.name).name}</h3>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">Kategorie</label>
<label class="edit-label">{t('kategorie', lang)}</label>
<div class="category-picker">
{#each SHOPPING_CATEGORIES as cat}
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
@@ -488,22 +481,22 @@
onclick={() => { editCategory = cat; }}
>
<CatIcon size={14} />
<span>{cat}</span>
<span>{categoryName(cat, lang)}</span>
</button>
{/each}
</div>
<!-- svelte-ignore a11y_label_has_associated_control -->
<label class="edit-label">Icon</label>
<label class="edit-label">{t('icon', lang)}</label>
<div class="icon-search">
<Search size={14} />
<input bind:value={iconSearch} type="text" placeholder="Icon suchen..." />
<input bind:value={iconSearch} type="text" placeholder={t('search_icon', lang)} />
</div>
<div class="icon-picker">
{#each filteredIconGroups as [cat, icons]}
{@const meta = categoryMeta[cat] || categoryMeta['Sonstiges']}
<div class="icon-group">
<span class="icon-group-label" style="color: {meta.color}">{cat}</span>
<span class="icon-group-label" style="color: {meta.color}">{categoryName(cat, lang)}</span>
<div class="icon-group-grid">
{#each icons as [name, file]}
<button
@@ -521,9 +514,9 @@
</div>
<div class="edit-actions">
<button class="btn-cancel" onclick={closeEdit}>Abbrechen</button>
<button class="btn-cancel" onclick={closeEdit}>{t('cancel', lang)}</button>
<button class="btn-save" onclick={saveEdit} disabled={editSaving}>
{editSaving ? 'Speichern...' : 'Speichern'}
{editSaving ? t('saving', lang) : t('save', lang)}
</button>
</div>
</div>
@@ -538,27 +531,27 @@
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="edit-modal share-modal" onclick={(e) => e.stopPropagation()}>
<div class="share-header">
<h3>Geteilte Links</h3>
<h3>{t('shared_links', lang)}</h3>
<button class="close-button" onclick={() => { showShareModal = false; }}>
<X size={18} />
</button>
</div>
<p class="share-desc">Jeder mit einem aktiven Link kann die Einkaufsliste bearbeiten.</p>
<p class="share-desc">{t('share_desc', lang)}</p>
{#if shareLoading}
<p class="share-loading">Laden...</p>
<p class="share-loading">{t('loading', lang)}</p>
{:else if shareTokens.length === 0}
<p class="share-empty">Keine aktiven Links.</p>
<p class="share-empty">{t('no_active_links', lang)}</p>
{:else}
<div class="token-list">
{#each shareTokens as t (t.id)}
{#each shareTokens as tok (tok.id)}
<div class="token-item">
<div class="token-info">
<span class="token-created-by">{t.createdBy}</span>
<span class="token-created-by">{tok.createdBy}</span>
<div class="token-expiry-row">
<span class="token-ttl">noch {formatTTL(t.expiresAt)}</span>
<select class="token-ttl-select" onchange={(e) => onTTLChange(t.id, e)}>
<option value="" disabled selected>Ändern</option>
<span class="token-ttl">{formatTTL(tok.expiresAt)}</span>
<select class="token-ttl-select" onchange={(e) => onTTLChange(tok.id, e)}>
<option value="" disabled selected>{t('change', lang)}</option>
{#each TTL_OPTIONS as opt}
<option value={opt.ms}>{opt.label}</option>
{/each}
@@ -566,10 +559,10 @@
</div>
</div>
<div class="token-actions">
<button class="btn-token-copy" onclick={() => copyTokenLink(t)} title="Link kopieren">
{#if copiedId === t.id}<Check size={14} />{:else}<Copy size={14} />{/if}
<button class="btn-token-copy" onclick={() => copyTokenLink(tok)} title={t('copy_link', lang)}>
{#if copiedId === tok.id}<Check size={14} />{:else}<Copy size={14} />{/if}
</button>
<button class="btn-token-delete" onclick={() => deleteToken(t.id)} title="Löschen">
<button class="btn-token-delete" onclick={() => deleteToken(tok.id)} title={t('delete_', lang)}>
<X size={14} />
</button>
</div>
@@ -580,7 +573,7 @@
<button class="btn-new-token" onclick={createNewToken}>
<Plus size={14} />
Neuen Link erstellen
{t('create_new_link', lang)}
</button>
</div>
</div>
@@ -588,7 +581,7 @@
{#if showCopyToast}
<div class="copy-toast" transition:slide={{ duration: 150 }}>
<Check size={14} /> Kopiert
<Check size={14} /> {t('copied', lang)}
</div>
{/if}
@@ -1,16 +1,20 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
import { getCategoryEmoji } from '$lib/utils/categories';
import { toast } from '$lib/js/toast.svelte';
import { isSettlementPayment, getSettlementIcon, getSettlementReceiver } from '$lib/utils/settlements';
import AddButton from '$lib/components/AddButton.svelte';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters';
let { data } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
// Use server-side data with progressive enhancement
// svelte-ignore state_referenced_locally
@@ -78,7 +82,7 @@
}
async function deletePayment(/** @type {string} */ paymentId) {
if (!confirm('Are you sure you want to delete this payment?')) {
if (!confirm(t('delete_payment_confirm', lang))) {
return;
}
@@ -100,14 +104,14 @@
function formatAmountWithCurrency(/** @type {any} */ payment) {
if (payment.currency === 'CHF' || !payment.originalAmount) {
return formatCurrency(payment.amount, 'CHF', 'de-CH');
return formatCurrency(payment.amount, 'CHF', loc);
}
return `${formatCurrency(payment.originalAmount, payment.currency, 'de-CH')} ≈ ${formatCurrency(payment.amount, 'CHF', 'de-CH')}`;
return `${formatCurrency(payment.originalAmount, payment.currency, loc)} ≈ ${formatCurrency(payment.amount, 'CHF', loc)}`;
}
function formatDate(/** @type {string} */ dateString) {
return new Date(dateString).toLocaleDateString('de-CH');
return new Date(dateString).toLocaleDateString(loc);
}
function getUserSplitAmount(/** @type {any} */ payment, /** @type {string} */ username) {
@@ -115,34 +119,24 @@
return split ? split.amount : 0;
}
function getSplitDescription(/** @type {any} */ payment) {
if (!payment.splits || payment.splits.length === 0) return 'No splits';
if (payment.splitMethod === 'equal') {
return `Split equally among ${payment.splits.length} people`;
} else if (payment.splitMethod === 'full') {
return `Paid in full by ${payment.paidBy}`;
} else if (payment.splitMethod === 'personal_equal') {
return `Personal amounts + equal split among ${payment.splits.length} people`;
} else {
return `Custom split among ${payment.splits.length} people`;
}
function getSplitDescription(/** @type {any} */ p) {
return splitDescription(p, lang);
}
</script>
<svelte:head>
<title>All Payments - Cospend</title>
<title>{t('all_payments_title', lang)} - {t('cospend', lang)}</title>
</svelte:head>
<main class="payments-list">
<div class="header">
<div class="header-content">
<h1>All Payments</h1>
<h1>{t('all_payments_title', lang)}</h1>
</div>
</div>
{#if loading && payments.length === 0}
<div class="loading">Loading payments...</div>
<div class="loading">{t('loading_payments', lang)}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if payments.length === 0}
@@ -151,9 +145,9 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/>
</svg>
<h2>No payments yet</h2>
<p>Start by adding your first shared expense</p>
<a href="/cospend/payments/add" class="btn btn-primary">Add Your First Payment</a>
<h2>{t('no_payments_yet', lang)}</h2>
<p>{t('start_first_expense', lang)}</p>
<a href="/{root}/payments/add" class="btn btn-primary">{t('add_first_payment', lang)}</a>
</div>
</div>
{:else}
@@ -161,11 +155,11 @@
{#each payments as payment}
{#if isSettlementPayment(payment)}
<!-- Settlement Card - Distinct Layout -->
<a href="/cospend/payments/view/{payment._id}" class="payment-card settlement-card">
<a href="/{root}/payments/view/{payment._id}" class="payment-card settlement-card">
<div class="settlement-header">
<div class="settlement-badge">
<span class="settlement-icon">💸</span>
<span class="settlement-label">Settlement</span>
<span class="settlement-label">{t('settlement', lang)}</span>
</div>
<span class="settlement-date">{formatDate(payment.date)}</span>
</div>
@@ -195,7 +189,7 @@
</a>
{:else}
<!-- Regular Payment Card -->
<a href="/cospend/payments/view/{payment._id}" class="payment-card">
<a href="/{root}/payments/view/{payment._id}" class="payment-card">
<div class="payment-header">
<div class="payment-title-section">
<ProfilePicture username={payment.paidBy} size={40} />
@@ -205,7 +199,7 @@
<h3>{payment.title}</h3>
</div>
<div class="payment-meta">
<span class="category-name">{getCategoryName(payment.category || 'groceries')}</span>
<span class="category-name">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
<span class="date">{formatDate(payment.date)}</span>
<span class="amount">{formatAmountWithCurrency(payment)}</span>
</div>
@@ -222,29 +216,29 @@
<div class="payment-details">
<div class="detail-row">
<span class="label">Paid by:</span>
<span class="label">{t('paid_by_label', lang)}</span>
<span class="value">{payment.paidBy}</span>
</div>
<div class="detail-row">
<span class="label">Split:</span>
<span class="label">{t('split_method_label', lang)}</span>
<span class="value">{getSplitDescription(payment)}</span>
</div>
</div>
{#if payment.splits && payment.splits.length > 0}
<div class="splits-summary">
<h4>Split Details</h4>
<h4>{t('split_details', lang)}</h4>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item">
<span class="split-user">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0}
owed {formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
{t('owed', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else}
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{/if}
</span>
</div>
@@ -262,14 +256,14 @@
{#if data.currentOffset > 0}
<a href="?offset={Math.max(0, data.currentOffset - data.limit)}&limit={data.limit}"
class="btn btn-secondary">
← Previous
{t('previous', lang)}
</a>
{/if}
{#if hasMore}
<a href="?offset={data.currentOffset + data.limit}&limit={data.limit}"
class="btn btn-secondary">
Next →
{t('next', lang)}
</a>
{/if}
@@ -277,14 +271,14 @@
{#if hasMore}
<button class="btn btn-secondary js-only" onclick={loadMore} disabled={loading}
style="display: none;">
{loading ? 'Loading...' : 'Load More (JS)'}
{loading ? t('loading_ellipsis', lang) : t('load_more', lang)}
</button>
{/if}
</div>
{/if}
</main>
<AddButton href="/cospend/payments/add" />
<AddButton href="/{root}/payments/add" />
<style>
.payments-list {
@@ -1,10 +1,11 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { enhance } from '$app/forms';
import { getCategoryOptions } from '$lib/utils/categories';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring';
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import SplitMethodSelector from '$lib/components/cospend/SplitMethodSelector.svelte';
import UsersList from '$lib/components/cospend/UsersList.svelte';
@@ -14,6 +15,10 @@
let { data, form } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
// Initialize form data with server values if available (for error handling)
/** @type {Record<string, any>} */
// svelte-ignore state_referenced_locally
@@ -82,36 +87,36 @@
personalAmounts[user] = 0;
});
let categoryOptions = $derived(getCategoryOptions());
let categoryOptions = $derived(getCategoryOptionsI18n(lang));
// Reactive text for "Paid in Full" option
let paidInFullText = $derived.by(() => {
// No-JS fallback text - always generic
if (!jsEnhanced) {
if (predefinedMode) {
return users.length === 2 ? 'Paid in Full for other' : 'Paid in Full for others';
return t('paid_in_full', lang);
} else {
return 'Paid in Full for others';
return t('paid_in_full', lang);
}
}
// JavaScript-enhanced reactive text
if (!formData.paidBy) {
return 'Paid in Full';
return t('paid_in_full', lang);
}
// Special handling for 2-user predefined setup
if (predefinedMode && users.length === 2) {
const otherUser = users.find(user => user !== formData.paidBy);
// Always show "for" the other user (who benefits) regardless of who pays
return otherUser ? `Paid in Full for ${otherUser}` : 'Paid in Full';
return otherUser ? `${t('paid_in_full_for', lang)} ${otherUser}` : t('paid_in_full', lang);
}
// General case with JS
if (formData.paidBy === data.currentUser) {
return 'Paid in Full by You';
return t('paid_in_full_by_you', lang);
} else {
return `Paid in Full by ${formData.paidBy}`;
return `${t('paid_in_full_by', lang)} ${formData.paidBy}`;
}
});
@@ -292,7 +297,7 @@
}
const result = await response.json();
await goto('/cospend/dash');
await goto(`/${root}/dash`);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
@@ -318,7 +323,7 @@
startDate: new Date(recurringData.startDate)
});
const nextDate = calculateNextExecutionDate(recurringPayment, new Date(recurringData.startDate));
nextExecutionPreview = nextDate.toLocaleString('de-CH', {
nextExecutionPreview = nextDate.toLocaleString(loc, {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -357,44 +362,44 @@
</script>
<svelte:head>
<title>Add Payment - Cospend</title>
<title>{t('add_payment_title', lang)} - {t('cospend', lang)}</title>
</svelte:head>
<main class="add-payment">
<div class="header">
<h1>Add New Payment</h1>
<p>Create a new shared expense or recurring payment</p>
<h1>{t('add_payment_title', lang)}</h1>
<p>{t('add_payment_subtitle', lang)}</p>
</div>
<form method="POST" use:enhance class="payment-form">
<div class="form-section">
<h2>Payment Details</h2>
<h2>{t('payment_details_section', lang)}</h2>
<div class="form-group">
<label for="title">Title *</label>
<label for="title">{t('title_label', lang)}</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
required
placeholder="e.g., Dinner at restaurant"
placeholder={t('title_placeholder', lang)}
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<label for="description">{t('description_label', lang)}</label>
<textarea
id="description"
name="description"
value={formData.description}
placeholder="Additional details..."
placeholder={t('description_placeholder', lang)}
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="category">Category *</label>
<label for="category">{t('category_star', lang)}</label>
<select id="category" name="category" value={formData.category} required>
{#each categoryOptions as option}
<option value={option.value}>{option.label}</option>
@@ -404,7 +409,7 @@
<div class="form-row">
<div class="form-group">
<label for="amount">Amount *</label>
<label for="amount">{t('amount_label', lang)}</label>
<div class="amount-currency">
<input
type="number"
@@ -424,11 +429,11 @@
</div>
{#if formData.currency !== 'CHF'}
<div class="conversion-info">
<small class="help-text">Amount will be converted to CHF using exchange rates for the payment date</small>
<small class="help-text">{t('conversion_hint', lang)}</small>
{#if loadingExchangeRate}
<div class="conversion-preview loading">
<small>🔄 Fetching exchange rate...</small>
<small>🔄 {t('fetching_rate', lang)}</small>
</div>
{:else if exchangeRateError}
<div class="conversion-preview error">
@@ -448,7 +453,7 @@
</div>
<div class="form-group">
<label for="date">Payment Date</label>
<label for="date">{t('payment_date', lang)}</label>
<input
type="date"
id="date"
@@ -457,13 +462,13 @@
required
/>
{#if formData.currency !== 'CHF'}
<small class="help-text">Exchange rate will be fetched for this date</small>
<small class="help-text">{t('exchange_rate_date', lang)}</small>
{/if}
</div>
</div>
<div class="form-group">
<label for="paidBy">Paid by</label>
<label for="paidBy">{t('paid_by_form', lang)}</label>
<select id="paidBy" name="paidBy" bind:value={formData.paidBy} required>
{#each users as user}
<option value={user}>{user}</option>
@@ -474,7 +479,7 @@
<div class="form-group">
<label class="checkbox-label">
<Toggle bind:checked={formData.isRecurring} />
<span>Make this a recurring payment</span>
<span>{t('make_recurring', lang)}</span>
<input type="hidden" name="isRecurring" value={formData.isRecurring ? 'true' : 'false'} />
</label>
</div>
@@ -482,24 +487,24 @@
{#if formData.isRecurring}
<div class="form-section">
<h2>Recurring Payment</h2>
<h2>{t('recurring_section', lang)}</h2>
<div class="recurring-options">
<div class="form-row">
<div class="form-group">
<label for="frequency">Frequency *</label>
<label for="frequency">{t('frequency_label', lang)}</label>
<select id="frequency" name="recurringFrequency" bind:value={recurringData.frequency} required>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="quarterly">Quarterly</option>
<option value="yearly">Yearly</option>
<option value="custom">Custom (Cron)</option>
<option value="daily">{t('freq_daily', lang)}</option>
<option value="weekly">{t('freq_weekly', lang)}</option>
<option value="monthly">{t('freq_monthly', lang)}</option>
<option value="quarterly">{t('freq_quarterly', lang)}</option>
<option value="yearly">{t('freq_yearly', lang)}</option>
<option value="custom">{t('freq_custom', lang)}</option>
</select>
</div>
<div class="form-group">
<label for="recurringStartDate">Start Date *</label>
<label for="recurringStartDate">{t('start_date', lang)}</label>
<input
type="date"
id="recurringStartDate"
@@ -539,7 +544,7 @@
{/if}
<div class="form-group">
<label for="recurringEndDate">End Date (optional)</label>
<label for="recurringEndDate">{t('end_date_optional', lang)}</label>
<input
type="date"
id="recurringEndDate"
@@ -547,15 +552,15 @@
bind:value={recurringData.endDate}
min={recurringData.startDate}
/>
<small class="help-text">Leave empty for indefinite recurring</small>
<small class="help-text">{t('end_date_hint', lang)}</small>
</div>
{#if nextExecutionPreview}
<div class="execution-preview">
<h3>Next Execution</h3>
<h3>{t('next_execution_preview', lang)}</h3>
<p class="next-execution">{nextExecutionPreview}</p>
<p class="frequency-description">{getFrequencyDescription(/** @type {any} */ (recurringData))}</p>
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (recurringData), lang)}</p>
</div>
{/if}
</div>
@@ -566,17 +571,19 @@
bind:imagePreview={imagePreview}
bind:imageFile={imageFile}
bind:uploading={uploading}
{lang}
onimageSelected={(file) => { imageFile = file; }}
onimageRemoved={handleImageRemoved}
onerror={(message) => { error = message; }}
/>
<UsersList
<UsersList
bind:users={users}
bind:newUser={newUser}
currentUser={data.session?.user?.nickname || data.currentUser}
predefinedMode={predefinedMode}
canRemoveUsers={!predefinedMode}
{lang}
/>
<!-- Server-side fallback: simple text inputs for users -->
@@ -616,7 +623,7 @@
<div class="error">{error}</div>
{/if}
<SaveFab disabled={loading} label="Create payment" />
<SaveFab disabled={loading} label={t('create_payment', lang)} />
</form>
</main>
@@ -1,7 +1,8 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getCategoryOptions } from '$lib/utils/categories';
import { page } from '$app/stores';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n } from '$lib/js/cospendI18n';
import FormSection from '$lib/components/FormSection.svelte';
import ImageUpload from '$lib/components/ImageUpload.svelte';
import SaveFab from '$lib/components/SaveFab.svelte';
@@ -12,6 +13,10 @@
let { data } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
/** @type {PaymentWithSplits | null} */
let payment = $state(null);
let loading = $state(true);
@@ -37,7 +42,7 @@
/** @type {number | null} */
let originalAmount = $state(null);
let categoryOptions = $derived(getCategoryOptions());
let categoryOptions = $derived(getCategoryOptionsI18n(lang));
// Recalculate splits when amount changes
function recalculateSplits() {
@@ -244,7 +249,7 @@
throw new Error('Failed to update payment');
}
await goto('/cospend/payments');
await goto(`/${root}/payments`);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
@@ -274,7 +279,7 @@
}
// Redirect to payments list after successful deletion
goto('/cospend/payments');
goto(`/${root}/payments`);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
@@ -352,25 +357,25 @@
</script>
<svelte:head>
<title>Edit Payment - Cospend</title>
<title>{t('edit_payment_title', lang)} - {t('cospend', lang)}</title>
</svelte:head>
<main class="edit-payment">
<div class="header">
<h1>Edit Payment</h1>
<p>Modify payment details and receipt image</p>
<h1>{t('edit_payment_title', lang)}</h1>
<p>{t('edit_payment_subtitle', lang)}</p>
</div>
{#if loading}
<div class="loading">Loading payment...</div>
<div class="loading">{t('loading_payments', lang)}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if payment}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
} class="payment-form">
<FormSection title="Payment Details">
<FormSection title={t('payment_details', lang)}>
<div class="form-group">
<label for="title">Title *</label>
<label for="title">{t('title_label', lang)}</label>
<input
type="text"
id="title"
@@ -380,7 +385,7 @@
</div>
<div class="form-group">
<label for="description">Description</label>
<label for="description">{t('description_label', lang)}</label>
<textarea
id="description"
bind:value={payment.description}
@@ -389,7 +394,7 @@
</div>
<div class="form-group">
<label for="category">Category</label>
<label for="category">{t('category_star', lang)}</label>
<select id="category" bind:value={payment.category} required>
{#each categoryOptions as option}
<option value={option.value}>{option.label}</option>
@@ -399,7 +404,7 @@
<div class="form-row">
<div class="form-group">
<label for="amount">Amount *</label>
<label for="amount">{t('amount_label', lang)}</label>
<div class="amount-currency">
{#if payment.originalAmount && payment.currency !== 'CHF'}
<!-- Show original amount for foreign currency -->
@@ -462,7 +467,7 @@
</div>
<div class="form-group">
<label for="date">Date</label>
<label for="date">{t('date', lang)}</label>
<input
type="date"
id="date"
@@ -474,7 +479,7 @@
</div>
<div class="form-group">
<label for="paidBy">Paid by</label>
<label for="paidBy">{t('paid_by_form', lang)}</label>
<input
type="text"
id="paidBy"
@@ -489,6 +494,7 @@
bind:imageFile={imageFile}
bind:uploading={uploading}
currentImage={payment.image}
{lang}
onimageSelected={handleImageUpload}
onimageRemoved={handleImageRemoved}
oncurrentImageRemoved={handleCurrentImageRemoved}
@@ -496,18 +502,18 @@
/>
{#if payment.splits && payment.splits.length > 0}
<FormSection title="Split Configuration">
<FormSection title={t('split_config', lang)}>
<div class="split-method-info">
<span class="label">Split Method:</span>
<span class="label">{t('split_method_form', lang)}</span>
<span class="value">
{#if payment.splitMethod === 'equal'}
Equal Split
{t('equal_split', lang)}
{:else if payment.splitMethod === 'full'}
Paid in Full
{t('paid_in_full', lang)}
{:else if payment.splitMethod === 'personal_equal'}
Personal + Equal Split
{t('personal_equal_split', lang)}
{:else if payment.splitMethod === 'proportional'}
Custom Proportions
{t('custom_proportions', lang)}
{:else}
{payment.splitMethod}
{/if}
@@ -516,8 +522,8 @@
{#if payment.splitMethod === 'personal_equal'}
<div class="personal-amounts-editor">
<h3>Personal Amounts</h3>
<p class="description">Enter personal amounts for each user. The remainder will be split equally.</p>
<h3>{t('personal_amounts', lang)}</h3>
<p class="description">{t('personal_amounts_desc', lang)}</p>
{#each payment.splits as split, index}
<div class="personal-input">
<label for="personal_{split.username}">{split.username}</label>
@@ -540,10 +546,10 @@
{@const remainder = Math.max(0, Number(payment.amount) - totalPersonal)}
{@const hasError = totalPersonal > Number(payment.amount)}
<div class="remainder-info" class:error={hasError}>
<span>Total Personal: CHF {totalPersonal.toFixed(2)}</span>
<span>Remainder to Split: CHF {remainder.toFixed(2)}</span>
<span>{t('total_personal', lang)}: CHF {totalPersonal.toFixed(2)}</span>
<span>{t('remainder_to_split', lang)}: CHF {remainder.toFixed(2)}</span>
{#if hasError}
<div class="error-message">⚠️ Personal amounts exceed total payment amount!</div>
<div class="error-message">⚠️ {t('personal_exceeds', lang)}</div>
{/if}
</div>
{/if}
@@ -551,17 +557,17 @@
{/if}
<div class="splits-display">
<h3>Split Preview</h3>
<h3>{t('split_preview', lang)}</h3>
{#each payment.splits as split}
<div class="split-item">
<span class="split-username">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
owes CHF {split.amount.toFixed(2)}
{t('owes', lang)} CHF {split.amount.toFixed(2)}
{:else if split.amount < 0}
owed CHF {Math.abs(split.amount).toFixed(2)}
{t('owed', lang)} CHF {Math.abs(split.amount).toFixed(2)}
{:else}
owes CHF {split.amount.toFixed(2)}
{t('owes', lang)} CHF {split.amount.toFixed(2)}
{/if}
</span>
</div>
@@ -581,11 +587,11 @@
onclick={deletePayment}
disabled={deleting || saving}
>
{deleting ? 'Deleting...' : 'Delete Payment'}
{deleting ? t('deleting', lang) : t('delete_payment', lang)}
</button>
</div>
<SaveFab disabled={saving || deleting} label="Save changes" />
<SaveFab disabled={saving || deleting} label={t('save_changes', lang)} />
</form>
{/if}
</main>
@@ -1,15 +1,21 @@
<script>
import { onMount } from 'svelte';
import { goto, invalidateAll } from '$app/navigation';
import { page } from '$app/stores';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
import { getCategoryEmoji } from '$lib/utils/categories';
import EditButton from '$lib/components/EditButton.svelte';
import { detectCospendLang, cospendRoot, t, locale, splitDescription, paymentCategoryName } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters';
let { data } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
// Use server-side data with progressive enhancement
/** @type {any | null} */
let payment = $derived(data.payment || null);
@@ -30,40 +36,30 @@
function formatAmountWithCurrency(/** @type {any} */ payment) {
if (payment.currency === 'CHF' || !payment.originalAmount) {
return formatCurrency(payment.amount, 'CHF', 'de-CH');
return formatCurrency(payment.amount, 'CHF', loc);
}
return `${formatCurrency(payment.originalAmount, payment.currency, 'de-CH')} ≈ ${formatCurrency(payment.amount, 'CHF', 'de-CH')}`;
return `${formatCurrency(payment.originalAmount, payment.currency, loc)} ≈ ${formatCurrency(payment.amount, 'CHF', loc)}`;
}
function formatDate(/** @type {string} */ dateString) {
return new Date(dateString).toLocaleDateString('de-CH');
return new Date(dateString).toLocaleDateString(loc);
}
function getSplitDescription(/** @type {any} */ payment) {
if (!payment.splits || payment.splits.length === 0) return 'No splits';
if (payment.splitMethod === 'equal') {
return `Split equally among ${payment.splits.length} people`;
} else if (payment.splitMethod === 'full') {
return `Paid in full by ${payment.paidBy}`;
} else if (payment.splitMethod === 'personal_equal') {
return `Personal amounts + equal split among ${payment.splits.length} people`;
} else {
return `Custom split among ${payment.splits.length} people`;
}
return splitDescription(payment, lang);
}
</script>
<svelte:head>
<title>{payment ? payment.title : 'Payment'} - Cospend</title>
<title>{payment ? payment.title : 'Payment'} - {t('cospend', lang)}</title>
</svelte:head>
<main class="payment-view">
{#if loading}
<div class="loading">Loading payment...</div>
<div class="loading">{t('loading_payments', lang)}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if payment}
@@ -78,7 +74,7 @@
{formatAmountWithCurrency(payment)}
{#if payment.currency !== 'CHF' && payment.exchangeRate}
<div class="exchange-rate-info">
<small>Exchange rate: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
<small>{t('exchange_rate', lang)}: 1 {payment.currency} = {payment.exchangeRate.toFixed(4)} CHF</small>
</div>
{/if}
</div>
@@ -93,30 +89,30 @@
<div class="payment-info">
<div class="info-grid">
<div class="info-item">
<span class="label">Date:</span>
<span class="label">{t('date', lang)}</span>
<span class="value">{formatDate(payment.date)}</span>
</div>
<div class="info-item">
<span class="label">Paid by:</span>
<span class="label">{t('paid_by_label', lang)}</span>
<span class="value">{payment.paidBy}</span>
</div>
<div class="info-item">
<span class="label">Created by:</span>
<span class="label">{t('created_by', lang)}</span>
<span class="value">{payment.createdBy}</span>
</div>
<div class="info-item">
<span class="label">Category:</span>
<span class="value">{getCategoryName(payment.category || 'groceries')}</span>
<span class="label">{t('category_label', lang)}</span>
<span class="value">{paymentCategoryName(payment.category || 'groceries', lang)}</span>
</div>
<div class="info-item">
<span class="label">Split method:</span>
<span class="label">{t('split_method_label', lang)}</span>
<span class="value">{getSplitDescription(payment)}</span>
</div>
</div>
{#if payment.description}
<div class="description">
<h3>Description</h3>
<h3>{t('description', lang)}</h3>
<p>{payment.description}</p>
</div>
{/if}
@@ -124,7 +120,7 @@
{#if payment.splits && payment.splits.length > 0}
<div class="splits-section">
<h3>Split Details</h3>
<h3>{t('split_details', lang)}</h3>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item" class:current-user={split.username === data.session?.user?.nickname}>
@@ -133,17 +129,17 @@
<div class="user-info">
<span class="username">{split.username}</span>
{#if split.username === data.session?.user?.nickname}
<span class="you-badge">You</span>
<span class="you-badge">{t('you', lang)}</span>
{/if}
</div>
</div>
<div class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0}
owed {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owed', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{:else}
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{/if}
</div>
</div>
@@ -156,7 +152,7 @@
</main>
{#if payment}
<EditButton href="/cospend/payments/edit/{data.paymentId}" />
<EditButton href="/{root}/payments/edit/{data.paymentId}" />
{/if}
<style>
@@ -1,15 +1,20 @@
<script>
import { onMount } from 'svelte';
import { getCategoryEmoji, getCategoryName } from '$lib/utils/categories';
import { getFrequencyDescription, formatNextExecution } from '$lib/utils/recurring';
import { getCategoryEmoji } from '$lib/utils/categories';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { toast } from '$lib/js/toast.svelte';
import AddButton from '$lib/components/AddButton.svelte';
import { formatCurrency } from '$lib/utils/formatters';
import Toggle from '$lib/components/Toggle.svelte';
import { page } from '$app/stores';
import { detectCospendLang, cospendRoot, t, locale, paymentCategoryName, frequencyDescription, formatNextExecutionI18n } from '$lib/js/cospendI18n';
let { data } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
/** @type {any[]} */
let recurringPayments = $state([]);
let loading = $state(true);
@@ -60,7 +65,7 @@
}
async function deleteRecurringPayment(/** @type {string} */ paymentId, /** @type {string} */ title) {
if (!confirm(`Are you sure you want to delete the recurring payment "${title}"?`)) {
if (!confirm(`${t('delete_recurring_confirm', lang)} "${title}"?`)) {
return;
}
@@ -81,7 +86,7 @@
}
function formatDate(/** @type {string} */ dateString) {
return new Date(dateString).toLocaleDateString('de-CH');
return new Date(dateString).toLocaleDateString(loc);
}
$effect(() => {
@@ -92,31 +97,31 @@
</script>
<svelte:head>
<title>Recurring Payments - Cospend</title>
<title>{t('recurring_title', lang)} - {t('cospend', lang)}</title>
</svelte:head>
<main class="recurring-payments">
<div class="header">
<h1>Recurring Payments</h1>
<p>Automate your regular shared expenses</p>
<h1>{t('recurring_title', lang)}</h1>
<p>{t('recurring_subtitle', lang)}</p>
</div>
<div class="filters">
<label>
<Toggle bind:checked={showActiveOnly} />
<span>Show active only</span>
<span>{t('show_active_only', lang)}</span>
</label>
</div>
{#if loading}
<div class="loading">Loading recurring payments...</div>
<div class="loading">{t('loading_recurring', lang)}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if recurringPayments.length === 0}
<div class="empty-state">
<h2>No recurring payments found</h2>
<p>Create your first recurring payment to automate regular expenses like rent, utilities, or subscriptions.</p>
<a href="/cospend/payments/add" class="btn btn-primary">Add Your First Payment</a>
<h2>{t('no_recurring', lang)}</h2>
<p>{t('no_recurring_desc', lang)}</p>
<a href="/{root}/payments/add" class="btn btn-primary">{t('add_first_payment', lang)}</a>
</div>
{:else}
<div class="payments-grid">
@@ -127,11 +132,11 @@
<span class="category-emoji">{getCategoryEmoji(payment.category)}</span>
<h3>{payment.title}</h3>
<span class="status-badge" class:active={payment.isActive} class:inactive={!payment.isActive}>
{payment.isActive ? 'Active' : 'Inactive'}
{payment.isActive ? t('active', lang) : t('inactive', lang)}
</span>
</div>
<div class="payment-amount">
{formatCurrency(payment.amount, 'CHF', 'de-CH')}
{formatCurrency(payment.amount, 'CHF', loc)}
</div>
</div>
@@ -141,17 +146,17 @@
<div class="payment-details">
<div class="detail-row">
<span class="label">Category:</span>
<span class="value">{getCategoryName(payment.category)}</span>
<span class="label">{t('category_label', lang)}</span>
<span class="value">{paymentCategoryName(payment.category, lang)}</span>
</div>
<div class="detail-row">
<span class="label">Frequency:</span>
<span class="value">{getFrequencyDescription(payment)}</span>
<span class="label">{t('frequency', lang)}</span>
<span class="value">{frequencyDescription(payment, lang)}</span>
</div>
<div class="detail-row">
<span class="label">Paid by:</span>
<span class="label">{t('paid_by_label', lang)}</span>
<div class="payer-info">
<ProfilePicture username={payment.paidBy} size={20} />
<span class="value">{payment.paidBy}</span>
@@ -159,29 +164,29 @@
</div>
<div class="detail-row">
<span class="label">Next execution:</span>
<span class="label">{t('next_execution', lang)}</span>
<span class="value next-execution">
{formatNextExecution(new Date(payment.nextExecutionDate))}
{formatNextExecutionI18n(new Date(payment.nextExecutionDate), lang)}
</span>
</div>
{#if payment.lastExecutionDate}
<div class="detail-row">
<span class="label">Last executed:</span>
<span class="label">{t('last_executed', lang)}</span>
<span class="value">{formatDate(payment.lastExecutionDate)}</span>
</div>
{/if}
{#if payment.endDate}
<div class="detail-row">
<span class="label">Ends:</span>
<span class="label">{t('ends', lang)}</span>
<span class="value">{formatDate(payment.endDate)}</span>
</div>
{/if}
</div>
<div class="splits-preview">
<h4>Split between:</h4>
<h4>{t('split_between', lang)}</h4>
<div class="splits-list">
{#each payment.splits as split}
<div class="split-item">
@@ -189,11 +194,11 @@
<span class="username">{split.username}</span>
<span class="split-amount" class:positive={split.amount < 0} class:negative={split.amount > 0}>
{#if split.amount > 0}
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{:else if split.amount < 0}
gets {formatCurrency(Math.abs(split.amount), 'CHF', 'de-CH')}
{t('gets', lang)} {formatCurrency(Math.abs(split.amount), 'CHF', loc)}
{:else}
owes {formatCurrency(split.amount, 'CHF', 'de-CH')}
{t('owes', lang)} {formatCurrency(split.amount, 'CHF', loc)}
{/if}
</span>
</div>
@@ -202,8 +207,8 @@
</div>
<div class="card-actions">
<a href="/cospend/recurring/edit/{payment._id}" class="btn btn-secondary btn-small">
Edit
<a href="/{root}/recurring/edit/{payment._id}" class="btn btn-secondary btn-small">
{t('edit', lang)}
</a>
<button
class="btn btn-small"
@@ -211,13 +216,13 @@
class:btn-success={!payment.isActive}
onclick={() => toggleActiveStatus(payment._id, payment.isActive)}
>
{payment.isActive ? 'Pause' : 'Activate'}
{payment.isActive ? t('pause', lang) : t('activate', lang)}
</button>
<button
class="btn btn-danger btn-small"
onclick={() => deleteRecurringPayment(payment._id, payment.title)}
>
Delete
{t('delete_', lang)}
</button>
</div>
</div>
@@ -226,7 +231,7 @@
{/if}
</main>
<AddButton href="/cospend/payments/add" />
<AddButton href="/{root}/payments/add" />
<style>
.recurring-payments {
@@ -1,9 +1,10 @@
<script>
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { getCategoryOptions } from '$lib/utils/categories';
import { page } from '$app/stores';
import { detectCospendLang, cospendRoot, locale, t, getCategoryOptionsI18n, frequencyDescription } from '$lib/js/cospendI18n';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { validateCronExpression, getFrequencyDescription, calculateNextExecutionDate } from '$lib/utils/recurring';
import { validateCronExpression, calculateNextExecutionDate } from '$lib/utils/recurring';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import SplitMethodSelector from '$lib/components/cospend/SplitMethodSelector.svelte';
import UsersList from '$lib/components/cospend/UsersList.svelte';
@@ -11,6 +12,10 @@
let { data } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
// svelte-ignore state_referenced_locally
let formData = $state({
title: '',
@@ -55,7 +60,7 @@
let exchangeRateTimeout = $state();
let jsEnhanced = $state(false);
let categoryOptions = $derived(getCategoryOptions());
let categoryOptions = $derived(getCategoryOptionsI18n(lang));
onMount(async () => {
jsEnhanced = true;
@@ -134,7 +139,7 @@
startDate: new Date(formData.startDate)
});
const nextDate = calculateNextExecutionDate(recurringPayment, new Date(formData.startDate));
nextExecutionPreview = nextDate.toLocaleString('de-CH', {
nextExecutionPreview = nextDate.toLocaleString(loc, {
weekday: 'long',
year: 'numeric',
month: 'long',
@@ -199,7 +204,7 @@
}
const result = await response.json();
await goto('/cospend/recurring');
await goto(`/${root}/recurring`);
} catch (err) {
error = err instanceof Error ? err.message : String(err);
@@ -284,47 +289,47 @@
</script>
<svelte:head>
<title>Edit Recurring Payment - Cospend</title>
<title>{t('edit_recurring_title', lang)} - {t('cospend', lang)}</title>
</svelte:head>
<main class="edit-recurring-payment">
<div class="header">
<h1>Edit Recurring Payment</h1>
<h1>{t('edit_recurring_title', lang)}</h1>
</div>
{#if loadingPayment}
<div class="loading">Loading recurring payment...</div>
<div class="loading">{t('loading_recurring', lang)}</div>
{:else if error && !formData.title}
<div class="error">Error: {error}</div>
{:else}
<form onsubmit={(e) => { e.preventDefault(); handleSubmit(); }
} class="payment-form">
<div class="form-section">
<h2>Payment Details</h2>
<h2>{t('payment_details_section', lang)}</h2>
<div class="form-group">
<label for="title">Title *</label>
<label for="title">{t('title_label', lang)}</label>
<input
type="text"
id="title"
bind:value={formData.title}
required
placeholder="e.g., Monthly rent, Weekly groceries"
placeholder={t('title_placeholder', lang)}
/>
</div>
<div class="form-group">
<label for="description">Description</label>
<label for="description">{t('description_label', lang)}</label>
<textarea
id="description"
bind:value={formData.description}
placeholder="Additional details about this recurring payment..."
placeholder={t('description_placeholder', lang)}
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="category">Category *</label>
<label for="category">{t('category_star', lang)}</label>
<select id="category" bind:value={formData.category} required>
{#each categoryOptions as option}
<option value={option.value}>{option.label}</option>
@@ -334,7 +339,7 @@
<div class="form-row">
<div class="form-group">
<label for="amount">Amount *</label>
<label for="amount">{t('amount_label', lang)}</label>
<div class="amount-currency">
<input
type="number"
@@ -377,7 +382,7 @@
</div>
<div class="form-group">
<label for="paidBy">Paid by</label>
<label for="paidBy">{t('paid_by_form', lang)}</label>
<select id="paidBy" bind:value={formData.paidBy} required>
{#each users as user}
<option value={user}>{user}</option>
@@ -387,30 +392,30 @@
</div>
<div class="form-group">
<label for="isActive">Status</label>
<label for="isActive">{t('status_label', lang)}</label>
<select id="isActive" bind:value={formData.isActive}>
<option value={true}>Active</option>
<option value={false}>Inactive</option>
<option value={true}>{t('active', lang)}</option>
<option value={false}>{t('inactive', lang)}</option>
</select>
</div>
</div>
<div class="form-section">
<h2>Recurring Schedule</h2>
<h2>{t('recurring_schedule', lang)}</h2>
<div class="form-row">
<div class="form-group">
<label for="frequency">Frequency *</label>
<label for="frequency">{t('frequency_label', lang)}</label>
<select id="frequency" bind:value={formData.frequency} required>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<option value="custom">Custom (Cron)</option>
<option value="daily">{t('freq_daily', lang)}</option>
<option value="weekly">{t('freq_weekly', lang)}</option>
<option value="monthly">{t('freq_monthly', lang)}</option>
<option value="custom">{t('freq_custom', lang)}</option>
</select>
</div>
<div class="form-group">
<label for="startDate">Start Date *</label>
<label for="startDate">{t('start_date', lang)}</label>
<input
type="date"
id="startDate"
@@ -448,20 +453,20 @@
{/if}
<div class="form-group">
<label for="endDate">End Date (optional)</label>
<label for="endDate">{t('end_date_optional', lang)}</label>
<input
type="date"
id="endDate"
bind:value={formData.endDate}
/>
<div class="help-text">Leave blank for indefinite recurring payments</div>
<div class="help-text">{t('end_date_hint', lang)}</div>
</div>
{#if nextExecutionPreview}
<div class="execution-preview">
<h3>Next Execution</h3>
<h3>{t('next_execution_preview', lang)}</h3>
<p class="next-execution">{nextExecutionPreview}</p>
<p class="frequency-description">{getFrequencyDescription(/** @type {any} */ (formData))}</p>
<p class="frequency-description">{frequencyDescription(/** @type {any} */ (formData), lang)}</p>
</div>
{/if}
</div>
@@ -472,6 +477,7 @@
currentUser={data.session?.user?.nickname}
{predefinedMode}
canRemoveUsers={!predefinedMode}
{lang}
/>
<SplitMethodSelector
@@ -490,7 +496,7 @@
<div class="error">{error}</div>
{/if}
<SaveFab disabled={loading || cronError} label="Save changes" />
<SaveFab disabled={loading || cronError} label={t('save_changes', lang)} />
</form>
{/if}
</main>
@@ -1,7 +1,8 @@
import { fail, redirect } from '@sveltejs/kit';
import type { PageServerLoad, Actions } from './$types';
import { detectCospendLang, cospendRoot } from '$lib/js/cospendI18n';
export const load: PageServerLoad = async ({ fetch, locals, request }) => {
export const load: PageServerLoad = async ({ fetch, locals, request, url }) => {
const session = await locals.auth();
if (!session) {
@@ -43,7 +44,7 @@ export const load: PageServerLoad = async ({ fetch, locals, request }) => {
}
export const actions: Actions = {
settle: async ({ request, fetch, locals }) => {
settle: async ({ request, fetch, locals, url }) => {
const data = await request.formData();
const settlementType = data.get('settlementType');
@@ -113,7 +114,8 @@ export const actions: Actions = {
}
// Redirect back to dashboard on success
throw redirect(303, '/cospend');
const root = cospendRoot(detectCospendLang(url.pathname));
throw redirect(303, `/${root}`);
} catch (error: unknown) {
if (error && typeof error === 'object' && 'status' in error && (error as { status: number }).status === 303) {
throw error; // Re-throw redirect
@@ -1,14 +1,19 @@
<script>
import { onMount } from 'svelte';
import { enhance } from '$app/forms';
import { page } from '$app/stores';
import ProfilePicture from '$lib/components/cospend/ProfilePicture.svelte';
import { PREDEFINED_USERS, isPredefinedUsersMode } from '$lib/config/users';
import { detectCospendLang, cospendRoot, t, locale } from '$lib/js/cospendI18n';
import { formatCurrency } from '$lib/utils/formatters';
let { data, form } = $props();
const lang = $derived(detectCospendLang($page.url.pathname));
const root = $derived(cospendRoot(lang));
const loc = $derived(locale(lang));
// Use server-side data with progressive enhancement
let debtData = $derived(data.debtData || {
whoOwesMe: [],
@@ -38,7 +43,7 @@
from: debtData.whoOwesMe[0].username,
to: data.currentUser,
amount: debtData.whoOwesMe[0].netAmount,
description: `Settlement: ${debtData.whoOwesMe[0].username} pays ${data.currentUser}`
description: `${t('settlement_payment', lang)}: ${debtData.whoOwesMe[0].username} ${data.currentUser}`
};
if (!settlementAmount) {
settlementAmount = debtData.whoOwesMe[0].netAmount.toString();
@@ -49,7 +54,7 @@
from: data.currentUser,
to: debtData.whoIOwe[0].username,
amount: debtData.whoIOwe[0].netAmount,
description: `Settlement: ${data.currentUser} pays ${debtData.whoIOwe[0].username}`
description: `${t('settlement_payment', lang)}: ${data.currentUser} ${debtData.whoIOwe[0].username}`
};
if (!settlementAmount) {
settlementAmount = debtData.whoIOwe[0].netAmount.toString();
@@ -67,7 +72,7 @@
from: user,
to: currentUser,
amount: amount,
description: `Settlement: ${user} pays ${currentUser}`
description: `${t('settlement_payment', lang)}: ${user} ${currentUser}`
};
} else {
selectedSettlement = {
@@ -75,7 +80,7 @@
from: currentUser,
to: user,
amount: amount,
description: `Settlement: ${currentUser} pays ${user}`
description: `${t('settlement_payment', lang)}: ${currentUser} ${user}`
};
}
settlementAmount = amount.toString();
@@ -83,13 +88,13 @@
async function processSettlement() {
if (!selectedSettlement || !settlementAmount) {
error = 'Please select a settlement and enter an amount';
error = t('error_select_settlement', lang);
return;
}
const amount = parseFloat(/** @type {string} */ (settlementAmount));
if (isNaN(amount) || amount <= 0) {
error = 'Please enter a valid positive amount';
error = t('error_valid_amount', lang);
return;
}
@@ -132,7 +137,7 @@
}
// Redirect back to dashboard on success
window.location.href = '/cospend';
window.location.href = `/${root}`;
} catch (err) {
error = err instanceof Error ? err.message : String(err);
submitting = false;
@@ -142,36 +147,36 @@
</script>
<svelte:head>
<title>Settle Debts - Cospend</title>
<title>{t('settle_title', lang)} - {t('cospend', lang)}</title>
</svelte:head>
<main class="settle-main">
<div class="header-section">
<h1>Settle Debts</h1>
<p>Record payments to settle outstanding debts between users</p>
<h1>{t('settle_title', lang)}</h1>
<p>{t('settle_subtitle', lang)}</p>
</div>
{#if loading}
<div class="loading">Loading debt information...</div>
<div class="loading">{t('loading_debts', lang)}</div>
{:else if error}
<div class="error">Error: {error}</div>
{:else if debtData.whoOwesMe.length === 0 && debtData.whoIOwe.length === 0}
<div class="no-debts">
<h2>🎉 All Settled!</h2>
<p>No outstanding debts to settle. Everyone is even!</p>
<h2>🎉 {t('all_settled', lang)}</h2>
<p>{t('no_debts_msg', lang)}</p>
<div class="actions">
<a href="/cospend/dash" class="btn btn-primary">Back to Dashboard</a>
<a href="/{root}/dash" class="btn btn-primary">{t('back_to_dashboard', lang)}</a>
</div>
</div>
{:else}
<div class="settlement-container">
<!-- Available Settlements -->
<div class="available-settlements">
<h2>Available Settlements</h2>
<h2>{t('available_settlements', lang)}</h2>
{#if debtData.whoOwesMe.length > 0}
<div class="settlement-section">
<h3>Money You're Owed</h3>
<h3>{t('money_owed_to_you', lang)}</h3>
{#each debtData.whoOwesMe as debt}
<div class="settlement-option"
role="button"
@@ -184,11 +189,11 @@
<ProfilePicture username={debt.username} size={40} />
<div class="user-details">
<span class="username">{debt.username}</span>
<span class="debt-amount">owes you {formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
<span class="debt-amount">{t('owes_you', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
</div>
</div>
<div class="settlement-action">
<span class="action-text">Receive Payment</span>
<span class="action-text">{t('receive_payment', lang)}</span>
</div>
</div>
{/each}
@@ -197,7 +202,7 @@
{#if debtData.whoIOwe.length > 0}
<div class="settlement-section">
<h3>Money You Owe</h3>
<h3>{t('money_you_owe', lang)}</h3>
{#each debtData.whoIOwe as debt}
<div class="settlement-option"
role="button"
@@ -210,11 +215,11 @@
<ProfilePicture username={debt.username} size={40} />
<div class="user-details">
<span class="username">{debt.username}</span>
<span class="debt-amount">you owe {formatCurrency(debt.netAmount, 'CHF', 'de-CH')}</span>
<span class="debt-amount">{t('you_owe', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)}</span>
</div>
</div>
<div class="settlement-action">
<span class="action-text">Make Payment</span>
<span class="action-text">{t('make_payment', lang)}</span>
</div>
</div>
{/each}
@@ -225,7 +230,7 @@
<!-- Settlement Details -->
{#if selectedSettlement}
<div class="settlement-details">
<h2>Settlement Details</h2>
<h2>{t('settlement_details', lang)}</h2>
<div class="settlement-summary">
<div class="settlement-flow">
@@ -233,7 +238,7 @@
<ProfilePicture username={selectedSettlement.from} size={48} />
<span class="username">{selectedSettlement.from}</span>
{#if selectedSettlement.from === data.currentUser}
<span class="you-badge">You</span>
<span class="you-badge">{t('you', lang)}</span>
{/if}
</div>
<div class="flow-arrow"></div>
@@ -241,13 +246,13 @@
<ProfilePicture username={selectedSettlement.to} size={48} />
<span class="username">{selectedSettlement.to}</span>
{#if selectedSettlement.to === data.currentUser}
<span class="you-badge">You</span>
<span class="you-badge">{t('you', lang)}</span>
{/if}
</div>
</div>
<div class="settlement-amount-section">
<label for="amount">Settlement Amount</label>
<label for="amount">{t('settlement_amount', lang)}</label>
<div class="amount-input">
<span class="currency">CHF</span>
<input
@@ -274,60 +279,60 @@
onclick={processSettlement}
disabled={submitting || !settlementAmount}>
{#if submitting}
Recording Settlement...
{t('recording_settlement', lang)}
{:else}
Record Settlement
{t('record_settlement', lang)}
{/if}
</button>
<button class="btn btn-secondary" onclick={() => selectedSettlement = null}>
Cancel
{t('cancel', lang)}
</button>
</div>
</div>
{:else}
<!-- No-JS Fallback Form -->
<div class="settlement-details no-js-fallback">
<h2>Record Settlement</h2>
<h2>{t('record_settlement', lang)}</h2>
<form method="POST" action="?/settle" class="settlement-form">
<div class="form-group">
<label for="settlementType">Settlement Type</label>
<label for="settlementType">{t('settlement_type', lang)}</label>
<select id="settlementType" name="settlementType" required>
<option value="">Select settlement type</option>
<option value="">{t('select_settlement', lang)}</option>
{#each debtData.whoOwesMe as debt}
<option value="receive" data-from="{debt.username}" data-to="{data.currentUser}">
Receive {formatCurrency(debt.netAmount, 'CHF', 'de-CH')} from {debt.username}
{t('receive_from', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('from', lang)} {debt.username}
</option>
{/each}
{#each debtData.whoIOwe as debt}
<option value="pay" data-from="{data.currentUser}" data-to="{debt.username}">
Pay {formatCurrency(debt.netAmount, 'CHF', 'de-CH')} to {debt.username}
{t('pay_to', lang)} {formatCurrency(debt.netAmount, 'CHF', loc)} {t('to', lang)} {debt.username}
</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="fromUser">From User</label>
<label for="fromUser">{t('from_user', lang)}</label>
<select id="fromUser" name="fromUser" required>
<option value="">Select payer</option>
<option value="">{t('select_payer', lang)}</option>
{#each [...debtData.whoOwesMe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
<option value="{user}">{user}{user === data.currentUser ? ' (You)' : ''}</option>
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="toUser">To User</label>
<label for="toUser">{t('to_user', lang)}</label>
<select id="toUser" name="toUser" required>
<option value="">Select recipient</option>
<option value="">{t('select_recipient', lang)}</option>
{#each [...debtData.whoIOwe.map((/** @type {any} */ d) => d.username), data.currentUser].filter(Boolean) as user}
<option value="{user}">{user}{user === data.currentUser ? ' (You)' : ''}</option>
<option value="{user}">{user}{user === data.currentUser ? ` (${t('you', lang)})` : ''}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="fallback-amount">Settlement Amount (CHF)</label>
<label for="fallback-amount">{t('settlement_amount_chf', lang)}</label>
<input
id="fallback-amount"
name="amount"
@@ -342,10 +347,10 @@
<div class="settlement-actions">
<button type="submit" class="btn btn-settlement">
Record Settlement
{t('record_settlement', lang)}
</button>
<a href="/cospend/dash" class="btn btn-secondary">
Cancel
<a href="/{root}/dash" class="btn btn-secondary">
{t('cancel', lang)}
</a>
</div>
</form>
-7
View File
@@ -1,7 +0,0 @@
import type { LayoutServerLoad } from "./$types"
export const load : LayoutServerLoad = async ({locals}) => {
return {
session: locals.session ?? await locals.auth()
}
};
-5
View File
@@ -1,5 +0,0 @@
import { redirect } from '@sveltejs/kit';
export function load() {
redirect(302, '/cospend/list');
}