refactor(settings): rewrite GeneralSection inline + delete @mana/subscriptions

GeneralSection: replace the GlobalSettingsSection wrapper (which
rendered its own SettingsSection pill + SettingsCard, requiring
title="" to suppress the inner header) with inline settings rows.
Each setting is a label+control row with scoped CSS — no double-card,
no wrapper hack.

Delete packages/subscriptions/ — the package is dead after merging
its SubscriptionPage into the Credits & Abo workbench app.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-16 14:33:35 +02:00
parent f203e100c1
commit e2d540a958
18 changed files with 247 additions and 1755 deletions

View file

@ -1,29 +0,0 @@
{
"name": "@mana/subscriptions",
"version": "1.0.0",
"private": true,
"description": "Unified subscription package — types and UI components",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"types": "./src/index.ts",
"default": "./src/index.ts"
}
},
"scripts": {
"type-check": "svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"svelte-i18n": "^4.0.0",
"typescript": "^5.9.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

View file

@ -1,116 +0,0 @@
<script lang="ts">
import type { BillingCycle } from './plans';
interface Props {
billingCycle: BillingCycle;
onChange: (cycle: BillingCycle) => void;
yearlyDiscount?: string;
monthlyLabel?: string;
yearlyLabel?: string;
}
let {
billingCycle,
onChange,
yearlyDiscount = '33%',
monthlyLabel = 'Monatlich',
yearlyLabel = 'Jährlich',
}: Props = $props();
</script>
<div class="billing-toggle">
<button
onclick={() => onChange('monthly')}
class="billing-toggle__button"
class:billing-toggle__button--active={billingCycle === 'monthly'}
>
<span class="billing-toggle__label">
{monthlyLabel}
</span>
</button>
<button
onclick={() => onChange('yearly')}
class="billing-toggle__button billing-toggle__button--yearly"
class:billing-toggle__button--active={billingCycle === 'yearly'}
>
<span class="billing-toggle__label">
{yearlyLabel}
</span>
{#if yearlyDiscount}
<span class="billing-toggle__discount">
-{yearlyDiscount}
</span>
{/if}
</button>
</div>
<style>
.billing-toggle {
display: flex;
max-width: 32rem;
margin: 0 auto 0.5rem;
padding: 0.25rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow: 0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .billing-toggle {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.billing-toggle__button {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 0.75rem;
border-radius: 0.5rem;
border: none;
background: transparent;
cursor: pointer;
transition: all 0.2s ease;
color: hsl(var(--color-muted-foreground));
}
.billing-toggle__button--yearly {
gap: 0.5rem;
}
.billing-toggle__button--active {
background: rgba(255, 255, 255, 0.9);
color: hsl(var(--color-primary, 221 83% 53%));
font-weight: 600;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
:global(.dark) .billing-toggle__button--active {
background: rgba(255, 255, 255, 0.15);
}
.billing-toggle__button:hover:not(.billing-toggle__button--active) {
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .billing-toggle__button:hover:not(.billing-toggle__button--active) {
background: rgba(255, 255, 255, 0.05);
}
.billing-toggle__label {
font-size: 0.875rem;
}
.billing-toggle__discount {
padding: 0.25rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
color: white;
background: hsl(var(--color-primary, 221 83% 53%));
}
</style>

View file

@ -1,117 +0,0 @@
<script lang="ts">
import type { CostItem } from './usage';
interface Props {
costs: CostItem[];
title?: string;
manaLabel?: string;
}
let { costs, title = 'Mana-Kosten', manaLabel = 'Mana' }: Props = $props();
// Icon mapping
const iconPaths: Record<string, string> = {
'mic-outline':
'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
'chatbubble-outline':
'M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z',
'add-circle-outline': 'M12 9v3m0 0v3m0-3h3m-3 0H9m12 0a9 9 0 11-18 0 9 9 0 0118 0z',
'copy-outline':
'M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z',
};
</script>
<div class="cost-card">
<h3 class="cost-card__title">{title}</h3>
<div class="cost-card__list">
{#each costs as item}
<div class="cost-card__item">
<div class="cost-card__item-left">
<svg
class="cost-card__icon"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPaths[item.icon] || iconPaths['mic-outline']} />
</svg>
<p class="cost-card__action">
{item.action}
</p>
</div>
<p class="cost-card__cost">
{item.cost}
{manaLabel}
</p>
</div>
{/each}
</div>
</div>
<style>
.cost-card {
position: relative;
padding: 1rem;
border-radius: 0.75rem;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .cost-card {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.cost-card__title {
margin: 0 0 1rem 0;
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.cost-card__list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.cost-card__item {
display: flex;
align-items: center;
justify-content: space-between;
}
.cost-card__item-left {
display: flex;
align-items: center;
}
.cost-card__icon {
width: 1.125rem;
height: 1.125rem;
margin-right: 0.5rem;
color: hsl(var(--color-primary, 221 83% 53%));
}
.cost-card__action {
margin: 0;
font-size: 0.875rem;
color: hsl(var(--color-muted-foreground));
}
.cost-card__cost {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
</style>

View file

@ -1,16 +0,0 @@
<script lang="ts">
interface Props {
color?: string;
size?: number;
class?: string;
}
let { color = '#0099FF', size = 24, class: className = '' }: Props = $props();
</script>
<svg width={size} height={size} viewBox="0 0 24 24" class={className}>
<path
d="M12.3047 1C12.3392 1.04573 19.608 10.6706 19.6084 14.6953C19.6084 18.7293 16.3386 21.9998 12.3047 22C8.27061 22 5 18.7294 5 14.6953C5.00041 10.661 12.3047 1 12.3047 1ZM12.3047 7.3916C12.2811 7.42276 8.65234 12.2288 8.65234 14.2393C8.65241 16.2562 10.2877 17.8916 12.3047 17.8916C14.3217 17.8916 15.957 16.2562 15.957 14.2393C15.957 12.2301 12.3331 7.42917 12.3047 7.3916Z"
fill={color}
/>
</svg>

View file

@ -1,127 +0,0 @@
<script lang="ts">
import type { ManaPackage } from './plans';
import ManaIcon from './ManaIcon.svelte';
interface Props {
package: ManaPackage;
onSelect: (packageId: string) => void;
popularLabel?: string;
buyLabel?: string;
}
let { package: pkg, onSelect, popularLabel = 'Beliebt', buyLabel = 'Kaufen' }: Props = $props();
function formatPrice(p: ManaPackage) {
return p.priceString || `${p.price.toFixed(2).replace('.', ',')}€`;
}
function getColor() {
const id = pkg.id.toLowerCase();
if (id.includes('small')) return '#2196F3';
if (id.includes('medium')) return '#1976D2';
if (id.includes('large')) return '#1565C0';
if (id.includes('giant')) return '#0D47A1';
return '#0288D1';
}
</script>
<button class="row" class:popular={pkg.popular} onclick={() => onSelect(pkg.id)}>
<div class="icon">
<ManaIcon size={20} color={getColor()} />
</div>
<div class="info">
<span class="name">
{pkg.name}
{#if pkg.popular}
<span class="badge">{popularLabel}</span>
{/if}
</span>
<span class="mana">{pkg.manaAmount.toLocaleString('de-DE')} Mana</span>
</div>
<span class="price">{formatPrice(pkg)}</span>
<span class="action">{buyLabel}</span>
</button>
<style>
.row {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
cursor: pointer;
transition: all 0.15s;
text-align: left;
color: hsl(var(--color-foreground));
font: inherit;
}
.row:hover {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-primary) / 0.3);
}
.row.popular {
border-color: hsl(var(--color-primary));
}
.icon {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.5);
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.name {
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.375rem;
}
.mana {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.badge {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
font-weight: 600;
color: white;
background: hsl(var(--color-primary));
}
.price {
flex-shrink: 0;
font-size: 0.875rem;
font-weight: 700;
}
.action {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
white-space: nowrap;
}
</style>

View file

@ -1,151 +0,0 @@
<script lang="ts">
interface Props {
label: string;
onclick: () => void;
iconName?: string;
leftIconName?: string;
variant?: 'primary' | 'secondary' | 'accent';
disabled?: boolean;
}
let {
label,
onclick,
iconName = 'arrow-forward-outline',
leftIconName = 'cart-outline',
variant = 'primary',
disabled = false,
}: Props = $props();
// Icon mapping (simple SVG paths for common icons)
const iconPaths: Record<string, string> = {
'arrow-forward-outline': 'M5 12h14M12 5l7 7-7 7',
'checkmark-circle-outline': 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
'cart-outline':
'M3 3h2l.4 2M7 13h10l4-8H5.4M7 13L5.4 5M7 13l-2.293 2.293c-.63.63-.184 1.707.707 1.707H17m0 0a2 2 0 100 4 2 2 0 000-4zm-8 2a2 2 0 11-4 0 2 2 0 014 0z',
};
</script>
<button
{disabled}
onclick={disabled ? undefined : onclick}
class="subscription-button"
class:subscription-button--primary={variant === 'primary'}
class:subscription-button--secondary={variant === 'secondary'}
class:subscription-button--accent={variant === 'accent'}
class:subscription-button--disabled={disabled}
>
<div class="subscription-button__content">
<svg
class="subscription-button__icon subscription-button__icon--left"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPaths[leftIconName] || iconPaths['cart-outline']} />
</svg>
<span>{label}</span>
</div>
<svg
class="subscription-button__icon subscription-button__icon--right"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d={iconPaths[iconName] || iconPaths['arrow-forward-outline']} />
</svg>
</button>
<style>
.subscription-button {
display: flex;
width: 100%;
height: 3rem;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
}
.subscription-button--primary {
background: rgba(0, 0, 0, 0.05);
border: 1px solid rgba(0, 0, 0, 0.1);
color: hsl(var(--color-foreground));
}
:global(.dark) .subscription-button--primary {
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.subscription-button--primary:hover:not(.subscription-button--disabled) {
background: rgba(0, 0, 0, 0.08);
}
:global(.dark) .subscription-button--primary:hover:not(.subscription-button--disabled) {
background: rgba(255, 255, 255, 0.15);
}
.subscription-button--secondary {
background: rgba(255, 255, 255, 0.8);
border: 1px solid rgba(0, 0, 0, 0.1);
color: hsl(var(--color-foreground));
}
:global(.dark) .subscription-button--secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.subscription-button--secondary:hover:not(.subscription-button--disabled) {
background: rgba(255, 255, 255, 0.9);
}
:global(.dark) .subscription-button--secondary:hover:not(.subscription-button--disabled) {
background: rgba(255, 255, 255, 0.12);
}
.subscription-button--accent {
background: hsl(var(--color-primary, 221 83% 53%));
border: 1px solid hsl(var(--color-primary, 221 83% 53%));
color: white;
}
.subscription-button--accent:hover:not(.subscription-button--disabled) {
filter: brightness(1.1);
}
.subscription-button--disabled {
opacity: 0.5;
cursor: not-allowed;
}
.subscription-button__content {
display: flex;
align-items: center;
justify-content: center;
}
.subscription-button__icon {
width: 1rem;
height: 1rem;
}
.subscription-button__icon--left {
margin-right: 0.5rem;
}
.subscription-button__icon--right {
margin-left: 0.5rem;
}
</style>

View file

@ -1,209 +0,0 @@
<script lang="ts">
import type { SubscriptionPlan } from './plans';
import ManaIcon from './ManaIcon.svelte';
interface Props {
plan: SubscriptionPlan;
onSelect: (planId: string) => void;
isCurrentPlan?: boolean;
isLegacy?: boolean;
currentPlanLabel?: string;
legacyPlanLabel?: string;
popularLabel?: string;
perMonthLabel?: string;
perYearLabel?: string;
monthlyEquivalentLabel?: string;
buyLabel?: string;
yourPlanLabel?: string;
yourLegacyPlanLabel?: string;
}
let {
plan,
onSelect,
isCurrentPlan = false,
isLegacy = false,
currentPlanLabel = 'Aktuell',
legacyPlanLabel = 'Legacy',
popularLabel = 'Beliebt',
perMonthLabel = '/Mo',
perYearLabel = '/Jahr',
buyLabel = 'Kaufen',
yourPlanLabel = 'Dein Plan',
}: Props = $props();
function formatPrice(p: SubscriptionPlan) {
return p.priceString || `${p.price.toFixed(2).replace('.', ',')}€`;
}
function getTierColor() {
const id = plan.id.toLowerCase();
if (id.includes('free')) return '#9E9E9E';
if (id.includes('small')) return '#2196F3';
if (id.includes('medium')) return '#1976D2';
if (id.includes('large')) return '#1565C0';
if (id.includes('giant')) return '#0D47A1';
return '#0288D1';
}
const isFree = $derived(plan.id.toLowerCase().includes('free'));
</script>
<button
class="row"
class:current={isCurrentPlan}
class:popular={plan.popular && !isCurrentPlan}
disabled={isCurrentPlan}
onclick={() => onSelect(plan.id)}
>
<div class="icon" style="color: {getTierColor()}">
<ManaIcon size={20} color={getTierColor()} />
</div>
<div class="info">
<span class="name">
{plan.name}
{#if isCurrentPlan}
<span class="badge current-badge">{isLegacy ? legacyPlanLabel : currentPlanLabel}</span>
{/if}
{#if plan.popular && !isCurrentPlan}
<span class="badge popular-badge">{popularLabel}</span>
{/if}
</span>
<span class="mana">{plan.monthlyMana} Mana{perMonthLabel}</span>
</div>
<div class="right">
{#if isFree}
<span class="price free">Kostenlos</span>
{:else}
<span class="price">{formatPrice(plan)}</span>
<span class="period">{plan.billingCycle === 'yearly' ? perYearLabel : perMonthLabel}</span>
{/if}
</div>
{#if !isFree && !isCurrentPlan}
<span class="action">{buyLabel}</span>
{:else if isCurrentPlan}
<span class="action muted">{yourPlanLabel}</span>
{/if}
</button>
<style>
.row {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
border-radius: 0.75rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-card));
cursor: pointer;
transition: all 0.15s;
text-align: left;
color: hsl(var(--color-foreground));
font: inherit;
}
.row:hover:not(:disabled) {
background: hsl(var(--color-surface-hover));
border-color: hsl(var(--color-primary) / 0.3);
}
.row:disabled {
cursor: default;
opacity: 0.8;
}
.row.current {
border-color: hsl(var(--color-primary));
background: hsl(var(--color-primary) / 0.05);
}
.row.popular {
border-color: hsl(var(--color-primary));
}
.icon {
flex-shrink: 0;
width: 2rem;
height: 2rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.5rem;
background: hsl(var(--color-muted) / 0.5);
}
.info {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.name {
font-size: 0.875rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.375rem;
}
.mana {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
}
.badge {
font-size: 0.625rem;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
font-weight: 600;
color: white;
}
.current-badge {
background: hsl(var(--color-primary));
}
.popular-badge {
background: hsl(var(--color-primary));
}
.right {
flex-shrink: 0;
text-align: right;
}
.price {
display: block;
font-size: 0.875rem;
font-weight: 700;
}
.price.free {
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
.period {
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
}
.action {
flex-shrink: 0;
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
white-space: nowrap;
}
.action.muted {
color: hsl(var(--color-muted-foreground));
font-weight: 500;
}
</style>

View file

@ -1,194 +0,0 @@
<script lang="ts">
import type { UsageData } from './usage';
interface Props {
title?: string;
usageData: UsageData;
currentPlan?: string;
// i18n labels
yourManaLabel?: string;
availableLabel?: string;
consumedLabel?: string;
currentPlanLabel?: string;
}
let {
title,
usageData,
currentPlan,
yourManaLabel = 'Dein Mana',
availableLabel = 'verfügbar',
consumedLabel = 'verbraucht',
currentPlanLabel = 'Aktueller Plan',
}: Props = $props();
// Use real credits (this would normally come from a store/API)
// svelte-ignore state_referenced_locally
const currentMana = usageData.currentMana;
// Calculate used vs available Mana
// svelte-ignore state_referenced_locally
const usedMana = usageData.maxMana - currentMana;
const formattedCurrentMana = currentMana.toString();
const formattedUsedMana = usedMana.toString();
// svelte-ignore state_referenced_locally
const calculatedPercentage = Math.round((currentMana / usageData.maxMana) * 100);
// Minimum 1% for numbers up to 5, so that a small blue bar is always visible
const availablePercentage =
currentMana <= 5 && currentMana > 0 ? Math.max(1, calculatedPercentage) : calculatedPercentage;
</script>
<div class="usage-card">
<!-- Mana Progress Bar -->
<div>
<div class="usage-card__header">
<div class="usage-card__title-wrapper">
<h2 class="usage-card__title">{title || yourManaLabel}</h2>
</div>
<div class="usage-card__value-wrapper">
<div class="usage-card__value-badge">
<p class="usage-card__value">
{formattedCurrentMana}
</p>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="usage-card__progress-track">
<div class="usage-card__progress-fill" style="width: {availablePercentage}%;"></div>
</div>
<!-- Percentage -->
<div class="usage-card__stats">
<p class="usage-card__stat">
{availablePercentage}% {availableLabel}
</p>
<p class="usage-card__stat">
{formattedUsedMana}
{consumedLabel}
</p>
</div>
<!-- Current Plan -->
{#if currentPlan}
<div class="usage-card__plan">
<p class="usage-card__plan-text">
{currentPlanLabel}: {currentPlan}
</p>
</div>
{/if}
</div>
</div>
<style>
.usage-card {
position: relative;
padding: 1.25rem;
border-radius: 1rem;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid rgba(0, 0, 0, 0.1);
box-shadow:
0 4px 6px -1px rgba(0, 0, 0, 0.1),
0 2px 4px -1px rgba(0, 0, 0, 0.06);
}
:global(.dark) .usage-card {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
}
.usage-card__header {
margin-bottom: 1rem;
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.usage-card__title-wrapper {
flex: 1;
}
.usage-card__title {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.usage-card__value-wrapper {
display: flex;
align-items: flex-end;
}
.usage-card__value-badge {
align-self: flex-start;
border-radius: 0.75rem;
padding: 0.375rem 1rem;
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .usage-card__value-badge {
background: rgba(255, 255, 255, 0.1);
}
.usage-card__value {
margin: 0;
font-size: 1.25rem;
font-weight: 700;
color: hsl(var(--color-foreground));
}
.usage-card__progress-track {
position: relative;
margin-bottom: 0.5rem;
height: 1rem;
overflow: hidden;
border-radius: 0.5rem;
background: rgba(0, 0, 0, 0.05);
}
:global(.dark) .usage-card__progress-track {
background: rgba(255, 255, 255, 0.1);
}
.usage-card__progress-fill {
height: 100%;
border-radius: 0.5rem;
background: linear-gradient(90deg, #4287f5 0%, #66b2ff 100%);
box-shadow: 0 0 4px rgba(66, 135, 245, 0.5);
}
.usage-card__stats {
display: flex;
justify-content: space-between;
}
.usage-card__stat {
margin: 0;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
.usage-card__plan {
margin-top: 0.75rem;
border-top: 1px solid rgba(0, 0, 0, 0.1);
padding-top: 0.75rem;
}
:global(.dark) .usage-card__plan {
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.usage-card__plan-text {
margin: 0;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -1,28 +0,0 @@
{
"costs": [
{
"action": "Pro Minute Aufnahme",
"actionKey": "subscription.cost_per_minute_recording",
"cost": 2,
"icon": "mic-outline"
},
{
"action": "Frage an Memo stellen",
"actionKey": "subscription.cost_ask_memo_question",
"cost": 5,
"icon": "chatbubble-outline"
},
{
"action": "Neue Memory erstellen",
"actionKey": "subscription.cost_create_memory",
"cost": 5,
"icon": "add-circle-outline"
},
{
"action": "Memos kombinieren (pro Memo)",
"actionKey": "subscription.cost_combine_memos",
"cost": 5,
"icon": "copy-outline"
}
]
}

View file

@ -1,10 +0,0 @@
{
"usage": {
"total": 0,
"lastWeek": 0,
"lastMonth": 0,
"currentMana": 150,
"maxMana": 150,
"history": []
}
}

View file

@ -1,195 +0,0 @@
{
"subscriptions": [
{
"id": "free",
"name": "Mana Quelle Free",
"nameEn": "Mana Source Free",
"nameIt": "Mana Fonte Free",
"price": 0,
"priceUnit": "",
"monthlyMana": 50,
"canGiftMana": false,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Quelle_S_v1",
"name": "Mana Quelle S",
"nameEn": "Mana Source S",
"nameIt": "Mana Fonte S",
"price": 4.99,
"priceUnit": "/ Monat",
"monthlyMana": 500,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Quelle_S_Yearly_v1",
"name": "Mana Quelle S",
"nameEn": "Mana Source S",
"nameIt": "Mana Fonte S",
"price": 47.9,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 3,99€ / Monat, 20% Rabatt)",
"monthlyMana": 500,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 3.99,
"available": true
},
{
"id": "Mana_Quelle_M_v1",
"name": "Mana Quelle M",
"nameEn": "Mana Source M",
"nameIt": "Mana Fonte M",
"price": 9.99,
"priceUnit": "/ Monat",
"monthlyMana": 1000,
"canGiftMana": true,
"popular": true,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Quelle_M_Yearly_v1",
"name": "Mana Quelle M",
"nameEn": "Mana Source M",
"nameIt": "Mana Fonte M",
"price": 95.9,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 7,99€ / Monat, 20% Rabatt)",
"monthlyMana": 1000,
"canGiftMana": true,
"popular": true,
"billingCycle": "yearly",
"monthlyEquivalent": 7.99,
"available": true
},
{
"id": "Mana_Quelle_L_v1",
"name": "Mana Quelle L",
"nameEn": "Mana Source L",
"nameIt": "Mana Fonte L",
"price": 19.99,
"priceUnit": "/ Monat",
"monthlyMana": 2000,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Quelle_L_Yearly_v1",
"name": "Mana Quelle L",
"nameEn": "Mana Source L",
"nameIt": "Mana Fonte L",
"price": 191.9,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 15,99€ / Monat, 20% Rabatt)",
"monthlyMana": 2000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 15.99,
"available": true
},
{
"id": "Mana_Quelle_XL_v1",
"name": "Mana Quelle XL",
"nameEn": "Mana Source XL",
"nameIt": "Mana Fonte XL",
"price": 39.99,
"priceUnit": "/ Monat",
"monthlyMana": 4000,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Quelle_XL_Yearly_v1",
"name": "Mana Quelle XL",
"nameEn": "Mana Source XL",
"nameIt": "Mana Fonte XL",
"price": 383.9,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 31,99€ / Monat, 20% Rabatt)",
"monthlyMana": 4000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 31.99,
"available": true
},
{
"id": "Mana_Quelle_XXL_v1",
"name": "Mana Quelle XXL",
"nameEn": "Mana Source XXL",
"nameIt": "Mana Fonte XXL",
"price": 99.99,
"priceUnit": "/ Monat",
"monthlyMana": 10000,
"canGiftMana": true,
"popular": false,
"billingCycle": "monthly",
"available": true
},
{
"id": "Mana_Quelle_XXL_Yearly_v1",
"name": "Mana Quelle XXL",
"nameEn": "Mana Source XXL",
"nameIt": "Mana Fonte XXL",
"price": 959.9,
"priceUnit": "/ Jahr",
"priceBreakdown": "(entspricht 79,99€ / Monat, 20% Rabatt)",
"monthlyMana": 10000,
"canGiftMana": true,
"popular": false,
"billingCycle": "yearly",
"monthlyEquivalent": 79.99,
"available": true
}
],
"packages": [
{
"id": "Mana_Potion_Small_v1",
"name": "Kleiner Mana Trank",
"nameEn": "Small Mana Potion",
"nameIt": "Piccola Pozione di Mana",
"manaAmount": 350,
"price": 4.9,
"popular": false
},
{
"id": "Mana_Potion_Medium_v1",
"name": "Mittlerer Mana Trank",
"nameEn": "Medium Mana Potion",
"nameIt": "Media Pozione di Mana",
"manaAmount": 700,
"price": 9.8,
"popular": false
},
{
"id": "Mana_Potion_Large_v1",
"name": "Großer Mana Trank",
"nameEn": "Large Mana Potion",
"nameIt": "Grande Pozione di Mana",
"manaAmount": 1400,
"price": 19.6,
"popular": false
},
{
"id": "Mana_Potion_Giant_v2",
"name": "Riesiger Mana Trank",
"nameEn": "Giant Mana Potion",
"nameIt": "Pozione di Mana Gigante",
"manaAmount": 2800,
"price": 39.2,
"popular": false
}
]
}

View file

@ -1,51 +0,0 @@
/**
* @mana/subscriptions Unified subscription package
*
* Consolidates shared-subscription-types + shared-subscription-ui.
*/
// === Types ===
export {
type BillingCycle,
type PlanCategory,
type SubscriptionPlan,
type ManaPackage,
type ProductMapping,
type PackageMapping,
type FreeTierConfig,
DEFAULT_FREE_TIER,
} from './plans';
export {
type UsageData,
type UsageHistoryEntry,
type CostItem,
type ManaBalance,
type CreditTransaction,
type OperationPricing,
} from './usage';
export {
type RevenueCatSubscriptionPlan,
type RevenueCatManaPackage,
type SubscriptionServiceData,
type PurchaseResult,
type CustomerSubscriptionStatus,
type RestorePurchasesResult,
type RevenueCatOffering,
} from './revenueCat';
// === UI Components ===
export { default as SubscriptionPage } from './pages/SubscriptionPage.svelte';
export { default as SubscriptionCard } from './SubscriptionCard.svelte';
export { default as PackageCard } from './PackageCard.svelte';
export { default as BillingToggle } from './BillingToggle.svelte';
export { default as UsageCard } from './UsageCard.svelte';
export { default as CostCard } from './CostCard.svelte';
export { default as SubscriptionButton } from './SubscriptionButton.svelte';
export { default as ManaIcon } from './ManaIcon.svelte';
// === Default data ===
export { default as defaultSubscriptionData } from './data/subscriptionData.json';
export { default as defaultAppCosts } from './data/appCosts.json';
export { default as defaultUsageData } from './data/defaultUsageData.json';

View file

@ -1,167 +0,0 @@
<script lang="ts">
import type { SubscriptionPlan, ManaPackage, BillingCycle } from '../plans';
import type { UsageData, CostItem } from '../usage';
import BillingToggle from '../BillingToggle.svelte';
import SubscriptionCard from '../SubscriptionCard.svelte';
import PackageCard from '../PackageCard.svelte';
import UsageCard from '../UsageCard.svelte';
import CostCard from '../CostCard.svelte';
import defaultSubscriptionData from '../data/subscriptionData.json';
import defaultAppCosts from '../data/appCosts.json';
import defaultUsageData from '../data/defaultUsageData.json';
interface Props {
appName: string;
onSubscribe: (planId: string) => void;
onBuyPackage: (packageId: string) => void;
currentPlanId?: string;
usageData?: UsageData;
subscriptions?: SubscriptionPlan[];
packages?: ManaPackage[];
costs?: CostItem[];
pageTitle?: string;
subscriptionsTitle?: string;
packagesTitle?: string;
yearlyDiscount?: string;
}
let {
appName,
onSubscribe,
onBuyPackage,
currentPlanId = 'free',
usageData = defaultUsageData.usage as UsageData,
subscriptions = defaultSubscriptionData.subscriptions as SubscriptionPlan[],
packages = defaultSubscriptionData.packages as ManaPackage[],
costs = defaultAppCosts.costs as CostItem[],
pageTitle = 'Mana kaufen',
subscriptionsTitle = 'Abonnements',
packagesTitle = 'Einmalkäufe',
yearlyDiscount = '33%',
}: Props = $props();
let billingCycle = $state<BillingCycle>('monthly');
const currentPlanName = $derived(() => {
const plan = subscriptions.find((p) => p.id === currentPlanId);
return plan?.name || 'Free';
});
function getSubscriptionPlans() {
return subscriptions.filter((plan) => plan.id !== 'free' && plan.billingCycle === billingCycle);
}
function isCurrentPlan(planId: string) {
if (currentPlanId === 'free' && planId === 'free') return true;
return planId === currentPlanId;
}
</script>
<div class="sub-page">
<!-- Billing toggle -->
<div class="toggle-row">
<BillingToggle {billingCycle} onChange={(cycle) => (billingCycle = cycle)} {yearlyDiscount} />
</div>
<!-- Subscriptions -->
<section class="section">
<h2 class="section-title">{subscriptionsTitle}</h2>
<div class="card-list">
{#if subscriptions.find((p) => p.id === 'free')}
<SubscriptionCard
plan={subscriptions.find((p) => p.id === 'free')!}
onSelect={onSubscribe}
isCurrentPlan={isCurrentPlan('free')}
/>
{/if}
{#each getSubscriptionPlans() as plan}
<SubscriptionCard {plan} onSelect={onSubscribe} isCurrentPlan={isCurrentPlan(plan.id)} />
{/each}
</div>
</section>
<!-- One-time packages -->
<section class="section">
<h2 class="section-title">{packagesTitle}</h2>
<div class="card-list">
{#each packages as pkg}
<PackageCard package={pkg} onSelect={onBuyPackage} />
{/each}
</div>
</section>
<!-- Usage & Costs (collapsed, less prominent) -->
<section class="section">
<details class="details">
<summary class="summary">Verbrauch & Kosten</summary>
<div class="details-content">
<UsageCard {usageData} currentPlan={currentPlanName()} />
<CostCard {costs} />
</div>
</details>
</section>
</div>
<style>
.sub-page {
display: flex;
flex-direction: column;
gap: 1.25rem;
max-width: 40rem;
margin: 0 auto;
width: 100%;
}
.toggle-row {
display: flex;
justify-content: center;
}
.section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-title {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-transform: uppercase;
letter-spacing: 0.05em;
margin: 0;
}
.card-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.details {
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
overflow: hidden;
}
.summary {
padding: 0.75rem 1rem;
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
user-select: none;
}
.summary:hover {
color: hsl(var(--color-foreground));
}
.details-content {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0 1rem 1rem;
}
</style>

View file

@ -1,136 +0,0 @@
/**
* Subscription plan and package types
*/
/**
* Billing cycle options
*/
export type BillingCycle = 'monthly' | 'yearly';
/**
* Subscription plan category
*/
export type PlanCategory = 'individual' | 'team' | 'enterprise';
/**
* Base subscription plan interface
*/
export interface SubscriptionPlan {
/** Unique identifier */
id: string;
/** Display name (localized) */
name: string;
/** English name */
nameEn?: string;
/** German name */
nameDe?: string;
/** Italian name */
nameIt?: string;
/** Price in local currency */
price: number;
/** Formatted price string (e.g., "5,99€") */
priceString?: string;
/** Currency code (e.g., "EUR") */
currencyCode?: string;
/** Price breakdown text */
priceBreakdown?: string;
/** Monthly equivalent for yearly plans */
monthlyEquivalent?: number;
/** Mana amount per month */
monthlyMana: number;
/** Initial mana grant on signup */
initialMana?: number;
/** Daily mana regeneration */
dailyMana?: number;
/** Maximum mana capacity */
maxMana?: number;
/** Whether user can gift mana */
canGiftMana: boolean;
/** Mark as popular/recommended */
popular?: boolean;
/** Billing frequency */
billingCycle: BillingCycle;
/** Team subscription flag */
isTeamSubscription?: boolean;
/** Enterprise subscription flag */
isEnterpriseSubscription?: boolean;
/** Plan features list */
features?: string[];
}
/**
* One-time mana package interface
*/
export interface ManaPackage {
/** Unique identifier */
id: string;
/** Display name (localized) */
name: string;
/** English name */
nameEn?: string;
/** German name */
nameDe?: string;
/** Italian name */
nameIt?: string;
/** Mana amount */
manaAmount: number;
/** Price in local currency */
price: number;
/** Formatted price string */
priceString?: string;
/** Currency code */
currencyCode?: string;
/** Team package flag */
isTeamPackage?: boolean;
/** Enterprise package flag */
isEnterprisePackage?: boolean;
/** Mark as popular */
popular?: boolean;
}
/**
* Product mapping for RevenueCat
*/
export interface ProductMapping {
/** Internal subscription ID */
subscriptionId: string;
/** App Store/Play Store product ID */
productId: string;
/** Billing cycle */
billingCycle: BillingCycle;
/** Category */
category: PlanCategory;
}
/**
* Package mapping for RevenueCat
*/
export interface PackageMapping {
/** Internal package ID */
packageId: string;
/** App Store/Play Store product ID */
productId: string;
/** Category */
category: PlanCategory;
}
/**
* Free tier configuration
*/
export interface FreeTierConfig {
/** Initial mana for free users */
initialMana: number;
/** Daily mana regeneration */
dailyMana: number;
/** Maximum mana capacity */
maxMana: number;
}
/**
* Default free tier configuration
*/
export const DEFAULT_FREE_TIER: FreeTierConfig = {
initialMana: 150,
dailyMana: 5,
maxMana: 150,
};

View file

@ -1,99 +0,0 @@
/**
* RevenueCat integration types
*/
import type { SubscriptionPlan, ManaPackage } from './plans';
/**
* RevenueCat-enhanced subscription plan
*/
export interface RevenueCatSubscriptionPlan extends SubscriptionPlan {
/** RevenueCat package object */
revenueCatPackage?: unknown;
/** RevenueCat product object */
revenueCatProduct?: unknown;
/** App Store/Play Store product ID */
productId: string;
}
/**
* RevenueCat-enhanced mana package
*/
export interface RevenueCatManaPackage extends ManaPackage {
/** RevenueCat package object */
revenueCatPackage?: unknown;
/** RevenueCat product object */
revenueCatProduct?: unknown;
/** App Store/Play Store product ID */
productId: string;
}
/**
* Subscription service data response
*/
export interface SubscriptionServiceData {
/** All available subscription plans */
subscriptions: RevenueCatSubscriptionPlan[];
/** All available one-time packages */
packages: RevenueCatManaPackage[];
/** Whether data is from RevenueCat or fallback */
isFromRevenueCat: boolean;
/** Last update timestamp */
lastUpdated: Date;
}
/**
* Purchase result
*/
export interface PurchaseResult {
/** Whether purchase was successful */
success: boolean;
/** Customer info from RevenueCat */
customerInfo?: unknown;
/** Error message if failed */
error?: string;
}
/**
* Customer subscription status
*/
export interface CustomerSubscriptionStatus {
/** Whether user has active subscription */
hasActiveSubscription: boolean;
/** Current plan ID */
currentPlanId?: string;
/** Subscription expiration date */
expirationDate?: Date;
/** Whether in grace period */
isInGracePeriod?: boolean;
/** Whether subscription will renew */
willRenew?: boolean;
}
/**
* Restore purchases result
*/
export interface RestorePurchasesResult {
/** Whether restore was successful */
success: boolean;
/** Restored subscription plan ID */
restoredPlanId?: string;
/** Error message if failed */
error?: string;
}
/**
* Offering from RevenueCat
*/
export interface RevenueCatOffering {
/** Offering identifier */
identifier: string;
/** Available packages in this offering */
availablePackages: RevenueCatSubscriptionPlan[];
/** Lifetime package (if available) */
lifetime?: RevenueCatManaPackage;
/** Annual package */
annual?: RevenueCatSubscriptionPlan;
/** Monthly package */
monthly?: RevenueCatSubscriptionPlan;
}

View file

@ -1,93 +0,0 @@
/**
* Usage and cost tracking types
*/
/**
* Usage data for displaying user's mana consumption
*/
export interface UsageData {
/** Total mana consumed all time */
total: number;
/** Mana consumed last week */
lastWeek: number;
/** Mana consumed last month */
lastMonth: number;
/** Current mana balance */
currentMana: number;
/** Maximum mana capacity */
maxMana: number;
/** Usage history */
history?: UsageHistoryEntry[];
}
/**
* Single usage history entry
*/
export interface UsageHistoryEntry {
/** Date of usage (ISO string) */
date: string;
/** Amount consumed */
amount: number;
/** Action type (optional) */
action?: string;
}
/**
* Cost item for displaying operation costs
*/
export interface CostItem {
/** Action description */
action: string;
/** Translation key for action */
actionKey?: string;
/** Mana cost */
cost: number;
/** Icon name */
icon: string;
}
/**
* User's credit/mana balance
*/
export interface ManaBalance {
/** Current mana amount */
current: number;
/** Maximum capacity */
max: number;
/** Last updated timestamp */
lastUpdated: string;
}
/**
* Credit transaction record
*/
export interface CreditTransaction {
/** Transaction ID */
id: string;
/** User ID */
userId: string;
/** Amount (positive = credit, negative = debit) */
amount: number;
/** Transaction type */
type: 'purchase' | 'subscription' | 'usage' | 'gift' | 'refund' | 'bonus';
/** Description */
description: string;
/** Timestamp */
createdAt: string;
/** Related operation ID (if applicable) */
operationId?: string;
}
/**
* Pricing information for operations
*/
export interface OperationPricing {
/** Operation key */
operation: string;
/** Base cost in mana */
baseCost: number;
/** Per-unit cost (e.g., per minute, per token) */
perUnitCost?: number;
/** Unit type */
unitType?: 'minute' | 'token' | 'request';
}

View file

@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"outDir": "dist",
"declaration": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}