feat(sync): add sync status PillNav dropdown + onboarding step

PillNavigation sync dropdown:
- New cloud icon pill showing sync status (Lokal/Sync/Pausiert)
- Dropdown with contextual actions: activate, top up credits, settings
- Shows next charge date when active
- Only visible for authenticated users

Onboarding wizard:
- New SyncStep between AI tier and Credits steps
- Explains local-first model: data always stays local, sync is optional
- Interval selection (monthly 30 / quarterly 90 / yearly 360 credits)
- Activate button with balance check and error handling
- Also fixed missing AiTierStep rendering in wizard template

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 22:51:00 +02:00
parent b8cd33df7a
commit d2c9795405
4 changed files with 252 additions and 0 deletions

View file

@ -6,6 +6,7 @@
import ProfileStep from './steps/ProfileStep.svelte';
import AppsStep from './steps/AppsStep.svelte';
import AiTierStep from './steps/AiTierStep.svelte';
import SyncStep from './steps/SyncStep.svelte';
import CreditsStep from './steps/CreditsStep.svelte';
import CompleteStep from './steps/CompleteStep.svelte';
import { Check } from '@mana/shared-icons';
@ -31,6 +32,7 @@
{ id: 'profile', label: 'Profil', component: ProfileStep },
{ id: 'apps', label: 'Apps', component: AppsStep },
{ id: 'ai-tier', label: 'KI', component: AiTierStep },
{ id: 'sync', label: 'Sync', component: SyncStep },
{ id: 'credits', label: 'Credits', component: CreditsStep },
{ id: 'complete', label: 'Fertig', component: CompleteStep },
];
@ -159,6 +161,10 @@
<ProfileStep bind:nameValue={profileNameRef} />
{:else if currentStepData.id === 'apps'}
<AppsStep />
{:else if currentStepData.id === 'ai-tier'}
<AiTierStep />
{:else if currentStepData.id === 'sync'}
<SyncStep />
{:else if currentStepData.id === 'credits'}
<CreditsStep />
{:else if currentStepData.id === 'complete'}

View file

@ -0,0 +1,158 @@
<script lang="ts">
/**
* Onboarding step: introduces Cloud Sync and lets the user decide
* whether to activate it. Shows pricing and explains the local-first
* model — sync is optional, data always stays local.
*/
import { syncBilling } from '$lib/stores/sync-billing.svelte';
import { creditsService, type CreditBalance } from '$lib/api/credits';
import { Cloud, DeviceMobile, Info, CheckCircle, Warning } from '@mana/shared-icons';
import type { BillingInterval } from '$lib/api/sync';
import { onMount } from 'svelte';
const SYNC_PRICES: Record<BillingInterval, { credits: number; label: string }> = {
monthly: { credits: 30, label: 'Monatlich' },
quarterly: { credits: 90, label: 'Quartalsweise' },
yearly: { credits: 360, label: 'Jährlich' },
};
let balance = $state<CreditBalance | null>(null);
let selectedInterval = $state<BillingInterval>('monthly');
let activating = $state(false);
let error = $state<string | null>(null);
onMount(async () => {
try {
balance = await creditsService.getBalance();
} catch {
// Non-critical
}
});
async function handleActivate() {
activating = true;
error = null;
try {
await syncBilling.activate(selectedInterval);
} catch (e) {
error = e instanceof Error ? e.message : 'Aktivierung fehlgeschlagen';
} finally {
activating = false;
}
}
</script>
<div class="mx-auto max-w-2xl">
<div class="mb-6 text-center">
<div class="mx-auto mb-3 flex h-14 w-14 items-center justify-center rounded-full bg-primary/10">
<Cloud size={28} class="text-primary" />
</div>
<div class="mb-2 text-2xl font-bold">Cloud Sync</div>
<p class="text-muted-foreground">
Synchronisiere deine Daten verschlüsselt über alle Geräte — oder nutze Mana nur lokal.
</p>
</div>
<!-- Local-first info -->
<div class="mb-4 rounded-xl border border-emerald-500/30 bg-emerald-500/5 p-4">
<div class="flex items-center gap-3">
<DeviceMobile size={20} class="text-emerald-500" />
<div class="flex-1">
<div class="font-semibold">Lokal — immer verfügbar</div>
<div class="text-sm text-muted-foreground">
Alle deine Daten sind lokal auf deinem Gerät gespeichert. Mana funktioniert vollständig
offline — auch ohne Cloud Sync.
</div>
</div>
</div>
</div>
<!-- Sync option -->
{#if syncBilling.active}
<div class="rounded-xl border border-primary bg-primary/5 p-4">
<div class="flex items-center gap-3">
<CheckCircle size={20} weight="fill" class="text-primary" />
<div>
<div class="font-semibold">Cloud Sync ist aktiv</div>
<div class="text-sm text-muted-foreground">
Deine Daten werden über alle Geräte synchronisiert.
</div>
</div>
</div>
</div>
{:else}
<div class="rounded-xl border border-border bg-card p-4">
<div class="flex items-start gap-3 mb-4">
<div
class="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10 text-primary"
>
<Cloud size={20} />
</div>
<div>
<div class="text-base font-semibold">Cloud Sync aktivieren</div>
<p class="mt-1 text-sm text-muted-foreground">
Multi-Device-Sync, automatische Backups, Ende-zu-Ende-Verschlüsselung.
</p>
</div>
</div>
<!-- Interval selection -->
<div class="space-y-2 mb-4">
{#each ['monthly', 'quarterly', 'yearly'] as const as iv}
<label
class="flex items-center justify-between rounded-lg border p-3 cursor-pointer transition-colors {selectedInterval ===
iv
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/40'}"
>
<div class="flex items-center gap-2">
<input
type="radio"
name="sync-interval"
value={iv}
checked={selectedInterval === iv}
onchange={() => (selectedInterval = iv)}
class="h-3.5 w-3.5 text-primary"
/>
<span class="text-sm font-medium">{SYNC_PRICES[iv].label}</span>
</div>
<span class="text-sm font-bold text-primary">{SYNC_PRICES[iv].credits} Credits</span>
</label>
{/each}
</div>
{#if error}
<div class="mb-3 flex items-center gap-2 rounded-lg bg-red-50 dark:bg-red-900/20 p-3">
<Warning size={16} class="text-red-600 dark:text-red-400" />
<p class="text-sm text-red-800 dark:text-red-200">{error}</p>
</div>
{/if}
<button
onclick={handleActivate}
disabled={activating ||
(balance !== null && balance.balance < SYNC_PRICES[selectedInterval].credits)}
class="w-full rounded-lg bg-primary py-2.5 text-sm font-semibold text-primary-foreground hover:bg-primary/90 transition-colors disabled:opacity-50"
>
{activating
? 'Wird aktiviert...'
: `Sync aktivieren (${SYNC_PRICES[selectedInterval].credits} Credits)`}
</button>
{#if balance !== null && balance.balance < SYNC_PRICES[selectedInterval].credits}
<p class="mt-2 text-center text-xs text-amber-600 dark:text-amber-400">
Nicht genügend Credits ({balance.balance} verfügbar). Du kannst Sync jederzeit später in den
Einstellungen aktivieren.
</p>
{/if}
</div>
{/if}
<div class="mt-6 flex items-start gap-3 rounded-xl bg-primary/5 p-4">
<Info size={20} class="mt-0.5 shrink-0 text-primary" />
<div class="text-sm text-muted-foreground">
Sync ist optional — du kannst diesen Schritt überspringen und Mana nur lokal nutzen. Alle
Features funktionieren auch ohne Sync. Du kannst jederzeit in den Einstellungen aktivieren.
</div>
</div>
</div>

View file

@ -238,6 +238,72 @@
return first ? first.shortLabel.split(' (')[0] : 'KI';
});
// ── Sync status dropdown ────────────────────────────────
let syncStatusItems = $derived.by(() => {
const items: import('@mana/shared-ui').PillDropdownItem[] = [];
if (syncBilling.active) {
items.push({
id: 'sync-active',
label: 'Cloud Sync aktiv',
icon: 'cloudCheck',
active: true,
disabled: true,
});
if (syncBilling.nextChargeAt) {
const date = new Date(syncBilling.nextChargeAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
items.push({
id: 'sync-next',
label: `Nächste Abbuchung: ${date}`,
disabled: true,
});
}
} else if (syncBilling.paused) {
items.push({
id: 'sync-paused',
label: 'Sync pausiert — Credits aufladen',
icon: 'warning',
onClick: () => goto('/credits?tab=packages'),
});
} else {
items.push({
id: 'sync-inactive',
label: 'Sync aktivieren',
icon: 'cloudArrowUp',
onClick: () => goto('/settings/sync'),
});
items.push({
id: 'sync-info',
label: 'Nur lokal — ab 30 Credits/Monat',
disabled: true,
});
}
items.push({ id: 'sync-divider', label: '', divider: true });
items.push({
id: 'sync-settings',
label: 'Sync-Einstellungen',
icon: 'gear',
onClick: () => goto('/settings/sync'),
});
return items;
});
let currentSyncLabel = $derived(
syncBilling.loading
? '...'
: syncBilling.active
? 'Sync'
: syncBilling.paused
? 'Pausiert'
: 'Lokal'
);
// ── User / Guest awareness ──────────────────────────────
let userEmail = $derived(
authStore.isAuthenticated ? authStore.user?.email || $_('nav.menu') : ''
@ -750,6 +816,9 @@
showAiTierSelector={true}
{aiTierItems}
{currentAiTierLabel}
showSyncStatus={authStore.isAuthenticated}
{syncStatusItems}
{currentSyncLabel}
{appItems}
{userEmail}
settingsHref="/settings"

View file

@ -247,6 +247,12 @@
aiTierItems?: PillDropdownItem[];
/** Current AI tier label, e.g. "Browser" or "Server" */
currentAiTierLabel?: string;
/** Show sync status dropdown */
showSyncStatus?: boolean;
/** Sync status dropdown items */
syncStatusItems?: PillDropdownItem[];
/** Current sync status label */
currentSyncLabel?: string;
/** Primary color for active state (CSS custom property or hex) */
primaryColor?: string;
/** Elements to prepend before nav items (tab groups, dividers, nav items) */
@ -342,6 +348,9 @@
showAiTierSelector = false,
aiTierItems = [],
currentAiTierLabel = 'KI',
showSyncStatus = false,
syncStatusItems = [],
currentSyncLabel = 'Sync',
themeMode = 'system',
onThemeModeChange,
appItems = [],
@ -670,6 +679,16 @@
/>
{/if}
<!-- Sync Status -->
{#if showSyncStatus && syncStatusItems.length > 0}
<PillDropdown
items={syncStatusItems}
direction={dropdownDirection}
label={currentSyncLabel}
icon="cloud"
/>
{/if}
<!-- Theme Toggle (only show when not using theme variants dropdown) -->
{#if showThemeToggle && onToggleTheme && !showThemeVariants}
<button