Commit Message feat: implement comprehensive shared packages architecture for monorepo SUMMARY: Introduce 10 shared packages to unify common code across all 4 web apps, reducing ~3,000 lines of duplicated code and establishing consistent patterns for authentication, UI components, theming, and utilities. NEW SHARED PACKAGES: - @manacore/shared-auth: Unified auth logic (token management, JWT utils, fetch interceptor, storage/device/network adapters) - @manacore/shared-auth-ui: Reusable auth UI (LoginPage, RegisterPage, OAuth buttons for Google/Apple) - @manacore/shared-tailwind: Unified Tailwind config with 4 themes (lume, nature, stone, ocean) and light/dark mode support - @manacore/shared-icons: Phosphor-based icon library (40+ icons) - @manacore/shared-ui: Atomic design system (Text, Button, Badge, Toggle, Input, Modal) - @manacore/shared-i18n: Unified i18n setup with locale detection - @manacore/shared-config: Environment validation with Zod - @manacore/shared-subscriptio n-types: Subscription type definitions - @manacore/shared-subscriptio n-ui: Subscription UI components (planned) EXTENDED PACKAGES: - @manacore/shared-types: Added auth.ts, theme.ts, ui.ts, common.ts - @manacore/shared-utils: Added format.ts, validation.ts APP MIGRATIONS: - memoro/web: Migrated login (549→46 LOC), tailwind (165→12 LOC), removed 15+ duplicate components - manacore/web: Migrated to client-side auth with shared-auth, added new components (Icon, ThemeToggle, Logo) - manadeck/web: Replaced local authService/tokenManager with shared-auth, migrated auth pages - maerchenzauber/web: Added auth setup, stores, components, routes DELETED FILES (migrated to shared packages): - OAuth buttons (Google/Apple) from memoro, manacore, manadeck - Local authService, tokenManager, deviceManager, jwt utils - Duplicate Modal, Toggle, Text components - iconPaths and ManaIcon components - Subscription-related components (CostCard, PackageCard, etc.) BENEFITS: - 92% reduction in login page code - 93% reduction in tailwind config code - Consistent theming across all apps - Single source of truth for auth logic - Easier maintenance and updates BREAKING CHANGES: - Icon imports now from @manacore/shared-icons - Modal imports from @manacore/shared-ui - OAuth config via setGoogleCl ientId()/setAppleConfig()

This commit is contained in:
Till-JS 2025-11-24 21:09:20 +01:00
parent 725db638ea
commit ef70a1af0b
198 changed files with 11113 additions and 3656 deletions

View file

@ -0,0 +1,52 @@
<script lang="ts">
import type { BillingCycle } from '@manacore/shared-subscription-types';
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="mx-auto mb-2 flex max-w-lg rounded-lg p-1 bg-menu">
<button
onclick={() => onChange('monthly')}
class="flex flex-1 items-center justify-center rounded-md py-3 transition-colors"
class:bg-content={billingCycle === 'monthly'}
class:text-mana={billingCycle === 'monthly'}
class:font-bold={billingCycle === 'monthly'}
class:text-theme={billingCycle !== 'monthly'}
>
<span class="text-sm">
{monthlyLabel}
</span>
</button>
<button
onclick={() => onChange('yearly')}
class="flex flex-1 items-center justify-center gap-2 rounded-md py-3 transition-colors"
class:bg-content={billingCycle === 'yearly'}
class:text-mana={billingCycle === 'yearly'}
class:font-bold={billingCycle === 'yearly'}
class:text-theme={billingCycle !== 'yearly'}
>
<span class="text-sm">
{yearlyLabel}
</span>
{#if yearlyDiscount}
<span class="rounded-xl bg-mana px-2 py-1 text-xs font-bold text-white">
-{yearlyDiscount}
</span>
{/if}
</button>
</div>

View file

@ -0,0 +1,57 @@
<script lang="ts">
import type { CostItem } from '@manacore/shared-subscription-types';
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="rounded-xl p-4 bg-content border border-theme">
<h3 class="mb-4 text-xl font-bold text-theme">{title}</h3>
<div class="space-y-3">
{#each costs as item}
<div class="flex items-center justify-between">
<div class="flex items-center">
<svg
class="mr-2 h-[18px] w-[18px]"
fill="none"
stroke="#4287f5"
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="text-sm text-theme-secondary">
{item.action}
</p>
</div>
<p class="text-base font-semibold text-theme">
{item.cost} {manaLabel}
</p>
</div>
{/each}
</div>
</div>

View file

@ -0,0 +1,16 @@
<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

@ -0,0 +1,99 @@
<script lang="ts">
import type { ManaPackage } from '@manacore/shared-subscription-types';
import SubscriptionButton from './SubscriptionButton.svelte';
import ManaIcon from './ManaIcon.svelte';
interface Props {
package: ManaPackage;
onSelect: (packageId: string) => void;
// i18n labels
popularLabel?: string;
manaLabel?: string;
oneTimeLabel?: string;
buyLabel?: string;
}
let {
package: pkg,
onSelect,
popularLabel = 'Popular',
manaLabel = 'Mana',
oneTimeLabel = 'Einmalig',
buyLabel = 'Kaufen'
}: Props = $props();
function formatPrice(pkg: ManaPackage) {
return pkg.priceString || `${pkg.price.toFixed(2).replace('.', ',')}€`;
}
// Package-specific colors and background sizes
function getPackageStyles() {
const id = pkg.id.toLowerCase();
if (id.includes('small')) return { bg: '#E3F2FD', icon: '#2196F3', bgSize: '45%' };
if (id.includes('medium')) return { bg: '#BBDEFB', icon: '#1976D2', bgSize: '60%' };
if (id.includes('large')) return { bg: '#90CAF9', icon: '#1565C0', bgSize: '75%' };
if (id.includes('giant')) return { bg: '#64B5F6', icon: '#0D47A1', bgSize: '90%' };
return { bg: '#E1F5FE', icon: '#0288D1', bgSize: '50%' };
}
const packageStyles = $derived(getPackageStyles());
// Hover state
let isHovered = $state(false);
</script>
<div
class="relative rounded-xl p-4 transition-all duration-200 bg-content border hover:-translate-y-0.5 hover:shadow-lg"
class:border-mana={pkg.popular}
class:border-theme={!pkg.popular}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
>
{#if pkg.popular}
<div class="absolute -top-3 right-4 rounded-xl bg-mana px-3 py-1 text-xs font-bold text-white">
{popularLabel}
</div>
{/if}
<!-- Package Name -->
<h3 class="mb-4 text-center text-lg font-bold text-theme">
{pkg.name}
</h3>
<!-- Three column layout -->
<div class="mb-5 flex justify-between gap-2">
<!-- Mana Icon with background -->
<div class="flex aspect-square flex-1 items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<div
class="flex items-center justify-center rounded-lg"
style="width: {packageStyles.bgSize}; height: {packageStyles.bgSize}; background-color: {packageStyles.bg};"
>
<ManaIcon size={32} color={packageStyles.icon} />
</div>
</div>
<!-- Mana Amount -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="mb-0.5 text-2xl font-bold text-theme">
{pkg.manaAmount}
</p>
<p class="text-center text-xs text-theme-secondary">{manaLabel}</p>
</div>
<!-- Price -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="text-xl font-bold text-theme">
{formatPrice(pkg)}
</p>
<p class="mt-0.5 text-[10px] text-theme-secondary">{oneTimeLabel}</p>
</div>
</div>
<SubscriptionButton
label={buyLabel}
onclick={() => onSelect(pkg.id)}
iconName="arrow-forward-outline"
leftIconName="cart-outline"
variant={pkg.popular ? 'accent' : 'primary'}
/>
</div>

View file

@ -0,0 +1,70 @@
<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="flex w-full h-12 items-center justify-between rounded-lg border px-4 font-medium transition-all duration-200"
class:bg-mana={variant === 'accent' && !disabled}
class:border-mana={variant === 'accent' && !disabled}
class:text-white={variant === 'accent' && !disabled}
class:bg-menu={variant === 'primary' && !disabled}
class:border-theme={variant === 'primary' && !disabled}
class:text-theme={variant === 'primary' && !disabled}
class:bg-content={variant === 'secondary' && !disabled}
class:hover:bg-menu-hover={!disabled}
class:opacity-50={disabled}
class:cursor-not-allowed={disabled}
>
<div class="flex items-center justify-center">
<svg
class="mr-2 h-4 w-4"
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="ml-2 h-4 w-4"
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>

View file

@ -0,0 +1,134 @@
<script lang="ts">
import type { SubscriptionPlan } from '@manacore/shared-subscription-types';
import SubscriptionButton from './SubscriptionButton.svelte';
import ManaIcon from './ManaIcon.svelte';
interface Props {
plan: SubscriptionPlan;
onSelect: (planId: string) => void;
isCurrentPlan?: boolean;
isLegacy?: boolean;
// i18n labels
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 = 'Current Plan',
legacyPlanLabel = 'Legacy Plan',
popularLabel = 'Popular',
perMonthLabel = 'pro Monat',
perYearLabel = 'pro Jahr',
monthlyEquivalentLabel = '/Monat',
buyLabel = 'Kaufen',
yourPlanLabel = 'Dein Plan',
yourLegacyPlanLabel = 'Dein Legacy-Plan'
}: Props = $props();
function formatPrice(plan: SubscriptionPlan) {
return plan.priceString || `${plan.price.toFixed(2).replace('.', ',')}€`;
}
// Tier-specific background colors and sizes for Mana icon
function getTierStyles() {
const id = plan.id.toLowerCase();
if (id.includes('free')) return { bg: '#F5F5F5', icon: '#9E9E9E', bgSize: '30%' };
if (id.includes('small')) return { bg: '#E3F2FD', icon: '#2196F3', bgSize: '45%' };
if (id.includes('medium')) return { bg: '#BBDEFB', icon: '#1976D2', bgSize: '60%' };
if (id.includes('large')) return { bg: '#90CAF9', icon: '#1565C0', bgSize: '75%' };
if (id.includes('giant')) return { bg: '#64B5F6', icon: '#0D47A1', bgSize: '90%' };
return { bg: '#E1F5FE', icon: '#0288D1', bgSize: '50%' };
}
const tierStyles = $derived(getTierStyles());
// Hover state
let isHovered = $state(false);
</script>
<div
class="relative rounded-xl p-4 transition-all duration-200 bg-content border hover:-translate-y-0.5 hover:shadow-lg"
class:border-2={isCurrentPlan}
class:border-mana={isCurrentPlan || plan.popular}
class:border-theme={!isCurrentPlan && !plan.popular}
onmouseenter={() => (isHovered = true)}
onmouseleave={() => (isHovered = false)}
>
{#if isCurrentPlan}
<div class="absolute -top-3 left-4 rounded-xl bg-mana px-3 py-1 text-xs font-bold text-white">
{isLegacy ? legacyPlanLabel : currentPlanLabel}
</div>
{/if}
{#if plan.popular && !isCurrentPlan}
<div class="absolute -top-3 right-4 rounded-xl bg-mana px-3 py-1 text-xs font-bold text-white">
{popularLabel}
</div>
{/if}
<!-- Tier Name -->
<h3 class="mb-4 text-center text-lg font-bold text-theme">
{plan.name}
</h3>
<!-- Three column layout -->
<div class="mb-5 flex justify-between gap-2">
<!-- Mana Icon with background -->
<div class="flex aspect-square flex-1 items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<div
class="flex items-center justify-center rounded-lg"
style="width: {tierStyles.bgSize}; height: {tierStyles.bgSize}; background-color: {tierStyles.bg};"
>
<ManaIcon size={32} color={tierStyles.icon} />
</div>
</div>
<!-- Mana Amount -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="mb-0.5 text-2xl font-bold text-theme">
{plan.monthlyMana}
</p>
<p class="text-center text-xs text-theme-secondary">{perMonthLabel}</p>
</div>
<!-- Price -->
<div class="flex aspect-square flex-1 flex-col items-center justify-center rounded-xl bg-menu" style="min-height: 80px;">
<p class="text-xl font-bold text-theme">
{formatPrice(plan)}
</p>
<p class="mt-0.5 text-xs text-theme-secondary">
{plan.billingCycle === 'yearly' ? perYearLabel : perMonthLabel}
</p>
{#if plan.billingCycle === 'yearly' && plan.monthlyEquivalent}
<p class="mt-0 text-[9px] text-theme-secondary">
({plan.monthlyEquivalent.toFixed(2).replace('.', ',')}{monthlyEquivalentLabel})
</p>
{/if}
</div>
</div>
<!-- Button only show if NOT free plan -->
{#if !plan.id.toLowerCase().includes('free')}
<SubscriptionButton
label={isCurrentPlan
? isLegacy
? yourLegacyPlanLabel
: yourPlanLabel
: buyLabel}
onclick={() => onSelect(plan.id)}
iconName={isCurrentPlan ? 'checkmark-circle-outline' : 'arrow-forward-outline'}
variant={isCurrentPlan ? 'secondary' : plan.popular ? 'accent' : 'primary'}
disabled={isCurrentPlan}
/>
{/if}
</div>

View file

@ -0,0 +1,81 @@
<script lang="ts">
import type { UsageData } from '@manacore/shared-subscription-types';
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)
const currentMana = usageData.currentMana;
// Calculate used vs available Mana
const usedMana = usageData.maxMana - currentMana;
const formattedCurrentMana = currentMana.toString();
const formattedUsedMana = usedMana.toString();
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="rounded-2xl p-5 bg-content border border-theme shadow-lg">
<!-- Mana Progress Bar -->
<div>
<div class="mb-4 flex items-start justify-between">
<div class="flex-1">
<h2 class="text-xl font-bold text-theme">{title || yourManaLabel}</h2>
</div>
<div class="flex items-end">
<div class="self-start rounded-xl px-4 py-1.5 bg-menu">
<p class="text-xl font-bold text-theme">
{formattedCurrentMana}
</p>
</div>
</div>
</div>
<!-- Progress Bar -->
<div class="relative mb-2 h-4 overflow-hidden rounded-lg bg-menu">
<div
class="h-full rounded-lg"
style="width: {availablePercentage}%; background: linear-gradient(90deg, #4287f5 0%, #66B2FF 100%); box-shadow: 0 0 4px #4287f580;"
></div>
</div>
<!-- Percentage -->
<div class="flex justify-between">
<p class="text-sm font-medium text-theme-secondary">
{availablePercentage}% {availableLabel}
</p>
<p class="text-sm font-medium text-theme-secondary">
{formattedUsedMana} {consumedLabel}
</p>
</div>
<!-- Current Plan -->
{#if currentPlan}
<div class="mt-3 border-t border-theme pt-3">
<p class="text-center text-sm font-medium text-theme-secondary">
{currentPlanLabel}: {currentPlan}
</p>
</div>
{/if}
</div>
</div>

View file

@ -0,0 +1,24 @@
/**
* Shared subscription UI components for Manacore monorepo
*
* This package contains Svelte 5 components for displaying
* subscription plans, mana packages, and usage information.
*/
// Components
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';
// Re-export types for convenience
export type {
SubscriptionPlan,
ManaPackage,
BillingCycle,
UsageData,
CostItem,
} from '@manacore/shared-subscription-types';