This commit is contained in:
2026-05-08 09:06:14 +12:00
parent 1533b5aa9b
commit 9afc3170ff
22 changed files with 2710 additions and 549 deletions
+5
View File
@@ -284,6 +284,11 @@ export const api = {
}),
internalSession: (fetcher?: ApiFetch) =>
request<LoginResponse>('/api/access/me', { method: 'GET' }, 'client', fetcher),
updateMe: (payload: { name?: string; email?: string; current_password?: string; new_password?: string }) =>
request<LoginResponse>('/api/access/me', {
method: 'PATCH',
body: JSON.stringify(payload)
}, 'client'),
adminLogin: (email: string, password: string) =>
request<LoginResponse>('/api/auth/admin/login', {
method: 'POST',
@@ -326,7 +326,7 @@
border: 1px solid rgba(34, 54, 45, 0.1);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.82);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.08);
box-shadow: none;
}
.loading-card {
@@ -775,7 +775,7 @@
flex-shrink: 0;
border-radius: 0.8rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
background: var(--color-brand);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.04em;
@@ -861,8 +861,8 @@
border-radius: 0.85rem;
padding: 0.85rem 1rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.2);
background: var(--color-brand);
box-shadow: none;
font-weight: 600;
cursor: pointer;
}
@@ -990,7 +990,7 @@
.feature-toggle.enabled {
color: #fff;
border-color: transparent;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
background: var(--color-brand);
}
.feature-toggle:disabled {
+304 -88
View File
@@ -17,6 +17,7 @@
ShieldCheck,
DollarSign,
ClipboardList,
TrendingUp,
Search,
LogOut,
Plus,
@@ -49,6 +50,7 @@
icon: Calculator,
moduleKey: 'mix_calculator'
};
const reportingItem: NavItem = { href: '/reporting', label: 'Reporting', shortLabel: 'RP', icon: TrendingUp, moduleKey: 'products' };
const workingDocumentItems: NavItem[] = [
{ href: '/raw-materials', label: 'Raw Materials', shortLabel: 'RM', icon: Wheat, moduleKey: 'raw_materials' },
{ href: '/mixes', label: 'Mix Master', shortLabel: 'MM', icon: FlaskConical, moduleKey: 'mix_master' },
@@ -111,6 +113,12 @@
description: 'Review delivered product pricing and margins.',
keywords: 'products pricing margins delivered outputs'
},
{
href: '/reporting',
label: 'Open Reporting',
description: 'View raw material costs, mix summaries, product pricing, and data quality reports.',
keywords: 'reporting reports raw materials mix cost product pricing data quality price review'
},
{
href: '/settings',
label: 'Open Workspace Settings',
@@ -141,7 +149,7 @@
let seededSearchToken = $state<string | null>(null);
let paletteInput: HTMLInputElement | null = $state(null);
const appVersion = `v${packageInfo.version}`;
const releaseStage = 'Alpha';
const releaseStage = 'Beta';
const currentYear = new Date().getFullYear();
const visibleDashboardItem = $derived(
!$clientSession || !dashboardItem.moduleKey || hasModuleAccess($clientSession, dashboardItem.moduleKey) ? dashboardItem : null
@@ -156,6 +164,11 @@
? mixCalculatorItem
: null
);
const visibleReportingItem = $derived(
!$clientSession || !reportingItem.moduleKey || hasModuleAccess($clientSession, reportingItem.moduleKey)
? reportingItem
: null
);
const visibleFooterLinks = $derived([
...footerLinks,
...(!$clientSession || !hasModuleAccess($clientSession, 'client_access', 'manage')
@@ -214,6 +227,7 @@
'/products': 'Products',
'/scenarios': 'Scenarios',
'/client-access': 'Client Access',
'/reporting': 'Reporting',
'/settings': 'Settings'
};
const section = sectionMap[pathname];
@@ -414,6 +428,15 @@
window.removeEventListener('resize', syncViewport);
};
});
const userInitials = $derived(
($clientSession?.name ?? '')
.split(' ')
.slice(0, 2)
.map((w: string) => w[0])
.join('')
.toUpperCase() || '?'
);
</script>
<svelte:head>
@@ -464,6 +487,14 @@
<span>{visibleMixCalculatorItem.label}</span>
</a>
{/if}
{#if visibleReportingItem}
{@const Icon = visibleReportingItem.icon}
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleReportingItem.label}</span>
</a>
{/if}
</nav>
{#if visibleWorkingDocumentItems.length}
@@ -494,17 +525,31 @@
<div class="sidebar-meta">
{#if $clientSession}
<button class="sidebar-signout" class:active={matchesRoute('/settings', page.url.pathname)} type="button" onclick={openSettings}>
<span class="nav-icon"><Settings size={18} strokeWidth={1.75} /></span>
<span>Settings</span>
</button>
<button class="sidebar-signout" type="button" onclick={() => clientSession.clear()}>
<span class="nav-icon"><LogOut size={18} strokeWidth={1.75} /></span>
<span>Sign out</span>
</button>
{/if}
<div class="sidebar-meta-foot">
<span class="version-pill">
<span>{appVersion}</span>
<div class="sidebar-meta-top">
<span class="version-pill">
<span class="meta-label">Build</span>
<span>{appVersion}</span>
</span>
<span class="release-pill">{releaseStage}</span>
</span>
<small>&copy; {currentYear} Hunter Premium Produce</small>
</div>
<div class="sidebar-meta-bottom">
<small>&copy; {currentYear} Hunter Premium Produce</small>
<div class="powered-by">
<span>Powered by</span>
<img src="/lean101-isotipo.png" alt="Lean 101" class="lean101-logo" />
<strong>Lean 101</strong>
</div>
</div>
</div>
</div>
</div>
@@ -548,7 +593,10 @@
quickMenuOpen = false;
}}
>
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
<span class="user-avatar-wrap">
<span class="user-avatar">{$clientSession ? userInitials : '?'}</span>
<span class={`user-status-dot ${$clientSession ? 'live' : 'idle'}`}></span>
</span>
<span class="user-trigger-copy">
<span class="workspace-label">{$sessionHydrated ? ($clientSession ? 'Signed in' : 'Signed out') : 'Checking session'}</span>
<strong>{$sessionHydrated ? ($clientSession ? $clientSession.name || 'Client account' : 'Sign in required') : 'Restoring workspace access'}</strong>
@@ -559,22 +607,28 @@
{#if userMenuOpen}
<div class="menu-panel user-menu-panel">
<div class="user-menu-summary">
<strong>
{$sessionHydrated
? $clientSession
? $clientSession.name || 'Client account'
: 'Client session inactive'
: 'Checking saved client session'}
</strong>
<span>
{$sessionHydrated
? $clientSession
? $clientSession.email
: 'Return to the dashboard page to sign in.'
: 'Waiting for the browser session check to complete.'}
</span>
<span class="user-menu-avatar">{$clientSession ? userInitials : '?'}</span>
<div class="user-menu-summary-text">
<strong>
{$sessionHydrated
? $clientSession
? $clientSession.name || 'Client account'
: 'Client session inactive'
: 'Checking saved client session'}
</strong>
<span>
{$sessionHydrated
? $clientSession
? $clientSession.email
: 'Return to the dashboard page to sign in.'
: 'Waiting for the browser session check to complete.'}
</span>
</div>
</div>
<button type="button" onclick={openSettings}>Change settings</button>
<button type="button" class="menu-settings-btn" onclick={openSettings}>
<Settings size={15} strokeWidth={1.75} />
Settings
</button>
{#if $clientSession}
<button type="button" onclick={() => clientSession.clear()}>Log out</button>
{:else if !$sessionHydrated}
@@ -689,6 +743,14 @@
</a>
{/if}
{#if visibleReportingItem}
{@const Icon = visibleReportingItem.icon}
<a class:active={matchesRoute(visibleReportingItem.href, page.url.pathname)} href={visibleReportingItem.href} onclick={() => (navOpen = false)}>
<span class="nav-icon"><Icon size={18} strokeWidth={1.75} /></span>
<span>{visibleReportingItem.label}</span>
</a>
{/if}
{#if visibleWorkingDocumentItems.length}
<div class="drawer-sublist" id="drawer-working-documents-nav">
{#each visibleWorkingDocumentItems as item}
@@ -793,26 +855,51 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
:global(:root) {
--bg: #f4f7f5;
--panel: #ffffff;
--panel-soft: #f8fbf9;
--line: #e5ece7;
--line-strong: #d9e4dd;
--text: #18231d;
--muted: #6d7d74;
--green: #22a95e;
--green-deep: #148249;
--green-soft: #eaf8ef;
--blue-soft: #eef7ff;
--shadow: 0 10px 30px rgba(15, 23, 17, 0.06);
/* ── Brand ──────────────────────────────────────────────── */
--color-brand: #15803d;
--color-brand-tint: #f0fdf4;
/* ── Surfaces ───────────────────────────────────────────── */
--color-bg-app: #f6f8fa;
--color-bg-surface: #ffffff;
/* ── Borders ────────────────────────────────────────────── */
--color-border: #e1e4e8;
--color-divider: #eaecef;
/* ── Text ───────────────────────────────────────────────── */
--color-text-primary: #24292f;
--color-text-secondary: #57606a;
--color-text-muted: #8b949e;
/* ── Semantic ───────────────────────────────────────────── */
--color-success: #1a7f37;
--color-warning: #bf8700;
--color-error: #cf222e;
--color-info: #0969da;
/* ── Legacy aliases (keep old token names working) ───────── */
--bg: var(--color-bg-app);
--panel: var(--color-bg-surface);
--panel-soft: var(--color-bg-app);
--line: var(--color-border);
--line-strong: var(--color-border);
--text: var(--color-text-primary);
--muted: var(--color-text-muted);
--green: var(--color-brand);
--green-deep: #1a1f1c;
--green-soft: var(--color-brand-tint);
--blue-soft: #e8f4ff;
--shadow: none; /* flat design — use borders, not shadows */
}
:global(html, body) {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--text);
background: var(--color-bg-app);
color: var(--color-text-primary);
font-family: Inter, "Segoe UI", sans-serif;
font-size: 14px;
}
:global(*) {
@@ -921,9 +1008,12 @@
}
.nav-list a:hover .nav-icon,
.nav-list a.active .nav-icon,
.sidebar-signout:hover .nav-icon {
color: var(--green-deep);
color: #304038;
}
.nav-list a.active .nav-icon {
color: #fff;
}
.nav-icon-mask {
@@ -1058,13 +1148,13 @@
}
.nav-list a:hover {
background: rgba(234, 248, 239, 0.55);
color: var(--green-deep);
background: var(--panel-soft);
color: #304038;
}
.nav-list a.active {
background: var(--green-soft);
color: var(--green-deep);
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
@@ -1076,7 +1166,7 @@
bottom: 0.45rem;
width: 3px;
border-radius: 999px;
background: var(--green-deep);
background: var(--color-brand);
}
.nav-group {
@@ -1102,11 +1192,11 @@
.nav-group-toggle:hover,
.nav-group-toggle.active {
background: var(--green-soft);
background: var(--color-brand-tint);
}
.nav-group-toggle.active {
color: var(--green-deep);
color: var(--color-brand);
font-weight: 600;
}
@@ -1172,42 +1262,101 @@
}
.sidebar-signout:hover {
background: rgba(234, 248, 239, 0.55);
color: var(--green-deep);
background: var(--color-brand-tint);
color: var(--color-brand);
}
.sidebar-signout.active {
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
.sidebar-signout.active .nav-icon {
color: #fff;
}
.sidebar-meta-foot {
display: grid;
gap: 0.25rem;
padding: 0.7rem 0.55rem 0;
gap: 0.55rem;
padding: 0.8rem 0.55rem 0;
border-top: 1px solid var(--line);
color: var(--muted);
font-size: 0.76rem;
}
.sidebar-meta-top,
.sidebar-meta-bottom {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.65rem;
}
.sidebar-meta-foot small {
font-size: 0.72rem;
line-height: 1.35;
}
.powered-by {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
white-space: nowrap;
}
.powered-by span {
font-size: 0.72rem;
font-weight: 500;
}
.powered-by strong {
font-size: 0.76rem;
font-weight: 600;
color: #5e6c64;
}
.lean101-logo {
width: 1rem;
height: 1rem;
object-fit: contain;
opacity: 0.72;
flex-shrink: 0;
}
.version-pill {
display: inline-flex;
align-items: center;
gap: 0.45rem;
flex-wrap: wrap;
padding: 0.24rem 0.56rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel-soft);
color: #5e6c64;
font-size: 0.72rem;
font-weight: 600;
}
.meta-label {
color: var(--muted);
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.release-pill {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.24rem 0.58rem;
border: 1px solid rgba(20, 130, 73, 0.16);
padding: 0.2rem 0.52rem;
border: 1px solid color-mix(in srgb, var(--color-brand) 14%, transparent);
border-radius: 999px;
background: #eaf8ef;
color: #148249;
font-size: 0.68rem;
background: color-mix(in srgb, var(--color-brand) 8%, white);
color: var(--color-brand);
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.08em;
letter-spacing: 0.07em;
text-transform: uppercase;
}
@@ -1215,11 +1364,14 @@
min-width: 0;
display: flex;
flex-direction: column;
min-height: 100vh;
height: 100vh;
overflow: hidden;
}
.topbar {
display: grid;
grid-template-columns: minmax(0, 0.95fr) minmax(20rem, 1.1fr) auto;
grid-template-columns: 1fr minmax(20rem, 36rem) 1fr;
align-items: center;
gap: 0.9rem;
padding: 0.86rem 1.34rem;
@@ -1277,7 +1429,7 @@
}
.topbar-search {
width: min(100%, 36rem);
width: 100%;
min-height: 3rem;
background: #fff;
}
@@ -1334,23 +1486,90 @@
cursor: pointer;
}
.user-status-dot {
width: 0.72rem;
height: 0.72rem;
.user-avatar-wrap {
position: relative;
flex-shrink: 0;
}
.user-avatar {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.02em;
user-select: none;
}
.user-status-dot {
position: absolute;
bottom: 0;
right: 0;
width: 0.55rem;
height: 0.55rem;
border-radius: 999px;
border: 1.5px solid var(--panel-soft);
background: #b4c0ba;
box-shadow: 0 0 0 0.24rem rgba(180, 192, 186, 0.2);
}
.user-status-dot.live {
background: var(--green);
box-shadow: 0 0 0 0.24rem rgba(34, 169, 94, 0.14);
background: #4ade80;
}
.user-status-dot.idle {
background: #c08b3d;
box-shadow: 0 0 0 0.24rem rgba(192, 139, 61, 0.14);
}
.user-menu-avatar {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
border-radius: 50%;
background: var(--green-deep);
color: #fff;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.02em;
user-select: none;
}
.user-menu-summary {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.72rem 0.78rem;
border-radius: 0.82rem;
background: var(--panel-soft);
}
.user-menu-summary-text {
display: grid;
gap: 0.2rem;
min-width: 0;
}
.user-menu-summary-text strong {
font-size: 0.9rem;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-menu-summary-text span {
color: var(--muted);
font-size: 0.8rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.user-trigger-copy {
@@ -1374,17 +1593,11 @@
min-width: 16rem;
}
.user-menu-summary {
display: grid;
gap: 0.2rem;
padding: 0.72rem 0.78rem;
border-radius: 0.82rem;
background: var(--panel-soft);
}
.user-menu-summary span {
color: var(--muted);
font-size: 0.82rem;
.menu-settings-btn {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chevron {
@@ -1409,10 +1622,10 @@
display: grid;
gap: 0.18rem;
padding: 0.4rem;
border: 1px solid var(--line);
border: 1px solid var(--color-border);
border-radius: 0.96rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 18px 40px rgba(15, 23, 17, 0.1);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(10px);
}
@@ -1433,9 +1646,9 @@
padding: 0.88rem 1.05rem;
border: none;
border-radius: 999px;
background: linear-gradient(135deg, #2f7b48 0%, #174b2d 100%);
background: var(--color-brand);
color: #fff;
box-shadow: 0 18px 36px rgba(23, 75, 45, 0.26);
box-shadow: none;
font-weight: 700;
letter-spacing: 0.01em;
cursor: pointer;
@@ -1497,8 +1710,11 @@
}
.content {
flex: 1;
min-height: 0;
min-width: 0;
padding: 1.34rem;
overflow: auto;
}
.locked-card {
@@ -1534,7 +1750,7 @@
margin-top: 1rem;
padding: 0.78rem 0.92rem;
border-radius: 0.88rem;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
background: var(--color-brand);
color: #fff;
font-weight: 600;
}
@@ -1552,10 +1768,10 @@
.palette {
width: min(44rem, 100%);
border: 1px solid rgba(217, 228, 221, 0.9);
border: 1px solid var(--color-border);
border-radius: 1.2rem;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 24px 60px rgba(15, 23, 17, 0.16);
background: var(--color-bg-surface);
box-shadow: 0 4px 16px rgba(0,0,0,0.1);
overflow: hidden;
}
@@ -1668,7 +1884,7 @@
border: 1px solid rgba(217, 228, 221, 0.92);
border-radius: 1.35rem;
background: rgba(255, 255, 255, 0.96);
box-shadow: 0 20px 40px rgba(15, 23, 17, 0.16);
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
backdrop-filter: blur(16px);
}
@@ -1695,8 +1911,8 @@
.bottom-nav a.active,
.bottom-nav button.active {
color: var(--green-deep);
background: linear-gradient(180deg, #f4fbf7 0%, #e8f6ee 100%);
color: var(--color-brand);
background: var(--color-brand-tint);
}
.bottom-nav-icon {
@@ -1707,13 +1923,13 @@
justify-content: center;
border-radius: 0.78rem;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
background: var(--green-deep);
font-size: 0.66rem;
letter-spacing: 0.04em;
}
.bottom-nav-icon.muted {
background: linear-gradient(135deg, #96a49c 0%, #718077 100%);
background: #8b949e;
}
.bottom-drawer {
@@ -1729,7 +1945,7 @@
border-radius: 1.6rem 1.6rem 0 0;
background:
linear-gradient(180deg, rgba(248, 251, 249, 0.98) 0%, rgba(255, 255, 255, 0.98) 100%);
box-shadow: 0 -20px 45px rgba(15, 23, 17, 0.16);
box-shadow: 0 -2px 8px rgba(0,0,0,0.06);
backdrop-filter: blur(16px);
}
@@ -167,7 +167,7 @@
.primary-button {
border: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
background: var(--color-brand);
color: #fff;
}
@@ -3,6 +3,7 @@
import { api } from '$lib/api';
import { featureFlags } from '$lib/features';
import { clientSession, hasModuleAccess } from '$lib/session';
import { toast } from '$lib/toast';
import type {
MixCalculatorCreateInput,
MixCalculatorOptions,
@@ -156,14 +157,18 @@
async function calculatePreview() {
const payload = buildPayload();
if (!payload) {
toast.error(formError);
return;
}
previewLoading = true;
const tid = toast.loading('Calculating…');
try {
preview = await api.previewMixCalculatorSession(payload);
formSuccess = 'Calculation refreshed from the saved product mix.';
toast.dismiss(tid);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to calculate the mix session.');
formError = error instanceof Error ? error.message : 'Unable to calculate the mix session.';
} finally {
previewLoading = false;
@@ -195,15 +200,20 @@
}
saveLoading = true;
const tid = toast.loading(mode === 'update' ? 'Saving session…' : 'Creating session…');
try {
const saved =
mode === 'update' && initialSession
? await api.updateMixCalculatorSession(initialSession.id, payload)
: await api.createMixCalculatorSession(payload);
toast.dismiss(tid);
toast.success(mode === 'update' ? 'Session saved' : 'Session created');
const target = destination === 'print' ? `/mix-calculator/${saved.id}/print` : `/mix-calculator/${saved.id}`;
await goto(target);
} catch (error) {
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to save the mix calculator session.');
formError = error instanceof Error ? error.message : 'Unable to save the mix calculator session.';
saveLoading = false;
}
@@ -427,8 +437,28 @@
</div>
{:else}
<div class="empty-state">
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size, then run the calculator.</span>
<div class="empty-shimmer-metrics">
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
<div class="shimmer-metric"><div class="shimmer-line short"></div><div class="shimmer-line wide"></div></div>
</div>
<div class="empty-state-copy">
<div class="empty-icon" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<strong>No calculation yet</strong>
<span>Choose a client, product, date, and batch size on the left, then click Calculate mix.</span>
</div>
<div class="empty-shimmer-rows">
{#each [1,2,3,4,5] as _}
<div class="shimmer-row">
<div class="shimmer-line wide"></div>
<div class="shimmer-line medium"></div>
<div class="shimmer-line medium"></div>
<div class="shimmer-line short"></div>
</div>
{/each}
</div>
</div>
{/if}
</article>
@@ -750,7 +780,7 @@
.primary-button {
border: none;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
background: var(--color-brand);
color: #fff;
}
@@ -765,14 +795,14 @@
}
.primary-button:hover:not(:disabled) {
box-shadow: 0 14px 28px rgba(23, 75, 45, 0.22);
box-shadow: none;
filter: brightness(1.04);
}
.secondary-button:hover:not(:disabled) {
border-color: #9fb0a6;
background: #f6faf7;
box-shadow: 0 10px 22px rgba(24, 38, 29, 0.08);
box-shadow: none;
}
.button-icon {
@@ -863,12 +893,122 @@
}
.empty-state {
display: flex;
flex-direction: column;
gap: 0;
border-radius: 1rem;
overflow: hidden;
border: 1px solid var(--line);
}
.empty-shimmer-metrics {
display: grid;
gap: 0.2rem;
place-items: start;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.75rem;
padding: 1rem;
border-bottom: 1px solid var(--line);
background: var(--panel-soft);
}
.shimmer-metric {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.85rem;
border: 1px solid var(--line);
border-radius: 0.85rem;
background: var(--panel);
}
.empty-state-copy {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 2rem 1.5rem;
text-align: center;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
.empty-state-copy strong {
font-size: 0.98rem;
font-weight: 700;
color: var(--text);
}
.empty-state-copy span {
max-width: 26rem;
font-size: 0.84rem;
color: var(--muted);
line-height: 1.5;
}
.empty-icon {
display: flex;
align-items: flex-end;
gap: 0.28rem;
height: 2.2rem;
margin-bottom: 0.35rem;
}
.empty-icon span {
width: 0.38rem;
border-radius: 999px 999px 0 0;
background: var(--color-border);
animation: bar-pulse 1.6s ease-in-out infinite;
}
.empty-icon span:nth-child(1) { height: 60%; animation-delay: 0s; }
.empty-icon span:nth-child(2) { height: 100%; animation-delay: 0.2s; }
.empty-icon span:nth-child(3) { height: 40%; animation-delay: 0.4s; }
@keyframes bar-pulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 1; }
}
.empty-shimmer-rows {
display: flex;
flex-direction: column;
background: var(--panel-soft);
}
.shimmer-row {
display: grid;
grid-template-columns: 2fr 1fr 1fr 0.75fr;
gap: 1rem;
align-items: center;
padding: 0.78rem 1rem;
border-bottom: 1px solid var(--line);
}
.shimmer-row:last-child {
border-bottom: none;
}
.shimmer-line {
height: 0.7rem;
border-radius: 999px;
background: linear-gradient(
90deg,
var(--color-border) 25%,
color-mix(in srgb, var(--color-border) 40%, white) 50%,
var(--color-border) 75%
);
background-size: 200% 100%;
animation: shimmer 1.8s ease-in-out infinite;
}
.shimmer-line.short { width: 40%; }
.shimmer-line.medium { width: 65%; }
.shimmer-line.wide { width: 90%; }
@keyframes shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
@media (max-width: 980px) {
.workspace-grid {
grid-template-columns: 1fr;
@@ -2,6 +2,7 @@
import { goto, invalidateAll } from '$app/navigation';
import { api } from '$lib/api';
import { clientSession } from '$lib/session';
import { toast } from '$lib/toast';
import type { Mix, MixIngredient, RawMaterial } from '$lib/types';
type DraftIngredient = {
@@ -166,16 +167,14 @@
}
async function saveMix() {
feedback = '';
errorMessage = '';
const validationWarnings = getDraftWarnings();
if (validationWarnings.length) {
errorMessage = validationWarnings[0];
toast.error(validationWarnings[0]);
return;
}
isSaving = true;
const tid = toast.loading(savedMix ? 'Saving mix…' : 'Creating mix…');
try {
const cleanIngredients = getCleanIngredients();
@@ -194,6 +193,8 @@
}))
});
toast.dismiss(tid);
toast.success('Mix created');
await invalidateAll();
await goto(`/mixes/${created.id}`);
return;
@@ -263,9 +264,11 @@
const refreshed = await api.mix(savedMix.id);
await invalidateAll();
loadDraftFromMix(refreshed);
feedback = 'Mix saved with updated ingredient costing.';
toast.dismiss(tid);
toast.success('Mix saved');
} catch (error) {
errorMessage = error instanceof Error ? error.message : 'Unable to save mix';
toast.dismiss(tid);
toast.error(error instanceof Error ? error.message : 'Unable to save mix');
} finally {
isSaving = false;
}
@@ -644,8 +647,8 @@
.primary-button {
border: none;
color: #fff;
background: linear-gradient(135deg, var(--green) 0%, var(--green-deep) 100%);
box-shadow: 0 8px 20px rgba(34, 169, 94, 0.18);
background: var(--color-brand);
box-shadow: none;
}
.secondary-button {
+148
View File
@@ -0,0 +1,148 @@
<script lang="ts">
import { toast, type Toast } from '$lib/toast';
function icon(type: Toast['type']) {
if (type === 'success') return '✓';
if (type === 'error') return '✕';
if (type === 'loading') return null; // spinner shown separately
return '';
}
</script>
{#if $toast.length}
<div class="toast-stack" aria-live="polite" aria-atomic="false">
{#each $toast as t (t.id)}
<div class="toast toast--{t.type}" role={t.type === 'error' ? 'alert' : 'status'}>
<span class="toast-icon" aria-hidden="true">
{#if t.type === 'loading'}
<span class="spinner"></span>
{:else}
{icon(t.type)}
{/if}
</span>
<span class="toast-message">{t.message}</span>
{#if t.type !== 'loading'}
<button
class="toast-close"
type="button"
aria-label="Dismiss"
onclick={() => toast.dismiss(t.id)}
>✕</button>
{/if}
</div>
{/each}
</div>
{/if}
<style>
.toast-stack {
position: fixed;
bottom: 1.5rem;
left: 50%;
transform: translateX(-50%);
display: flex;
flex-direction: column;
gap: 0.5rem;
z-index: 9999;
pointer-events: none;
width: max-content;
max-width: min(28rem, calc(100vw - 2rem));
}
.toast {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.7rem 1rem 0.7rem 0.85rem;
border-radius: 0.75rem;
font-size: 0.88rem;
font-weight: 500;
pointer-events: all;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14);
animation: slide-up 180ms ease both;
line-height: 1.4;
}
@keyframes slide-up {
from { opacity: 0; transform: translateY(0.5rem); }
to { opacity: 1; transform: translateY(0); }
}
.toast--info {
background: #1a1f1c;
color: #f0f4f1;
}
.toast--success {
background: #1a3326;
color: #a3f0c5;
border: 1px solid #2a6645;
}
.toast--error {
background: #3b1212;
color: #fca5a5;
border: 1px solid #7f2020;
}
.toast--loading {
background: #1a1f1c;
color: #d1e8da;
}
.toast-icon {
flex-shrink: 0;
font-size: 0.82rem;
display: flex;
align-items: center;
}
.toast--success .toast-icon { color: #4ade80; }
.toast--error .toast-icon { color: #f87171; }
.toast--info .toast-icon { color: #93c5fd; }
.toast-message {
flex: 1;
}
.toast-close {
flex-shrink: 0;
margin-left: 0.25rem;
padding: 0;
background: none;
border: none;
color: inherit;
opacity: 0.5;
font-size: 0.78rem;
cursor: pointer;
line-height: 1;
transition: opacity 120ms ease;
}
.toast-close:hover {
opacity: 1;
}
/* Loading spinner */
.spinner {
display: block;
width: 0.85rem;
height: 0.85rem;
border: 2px solid rgba(255, 255, 255, 0.2);
border-top-color: #d1e8da;
border-radius: 50%;
animation: spin 600ms linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@media (max-width: 480px) {
.toast-stack {
bottom: 5rem;
width: calc(100vw - 2rem);
max-width: none;
}
}
</style>
+50
View File
@@ -0,0 +1,50 @@
import { writable } from 'svelte/store';
export type ToastType = 'info' | 'success' | 'error' | 'loading';
export type Toast = {
id: string;
message: string;
type: ToastType;
duration?: number; // ms — undefined means the toast stays until manually dismissed
};
function createToastStore() {
const { subscribe, update } = writable<Toast[]>([]);
const timers = new Map<string, ReturnType<typeof setTimeout>>();
function add(message: string, type: ToastType = 'info', duration?: number): string {
const id = `toast-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
const resolved = duration ?? (type === 'loading' ? undefined : type === 'error' ? 5000 : 3000);
update((toasts) => [...toasts, { id, message, type, duration: resolved }]);
if (resolved !== undefined) {
timers.set(id, setTimeout(() => dismiss(id), resolved));
}
return id;
}
function dismiss(id: string) {
clearTimeout(timers.get(id));
timers.delete(id);
update((toasts) => toasts.filter((t) => t.id !== id));
}
function success(message: string) {
return add(message, 'success');
}
function error(message: string) {
return add(message, 'error');
}
function loading(message: string): string {
return add(message, 'loading');
}
return { subscribe, add, dismiss, success, error, loading };
}
export const toast = createToastStore();