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,27 @@
{
"name": "@manacore/shared-auth-ui",
"version": "1.0.0",
"description": "Shared authentication UI components for Mana apps",
"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"
}
},
"files": [
"src"
],
"peerDependencies": {
"svelte": "^5.0.0",
"@manacore/shared-auth": "workspace:*"
},
"devDependencies": {
"svelte": "^5.16.0",
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,69 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initializeAppleAuth, signInWithApple, waitForAppleAuth } from '../utils/appleAuth';
interface Props {
onError?: (error: Error) => void;
}
let { onError }: Props = $props();
let isLoading = $state(false);
let error = $state<string | null>(null);
let sdkLoaded = $state(false);
async function handleAppleSignIn() {
isLoading = true;
error = null;
try {
await signInWithApple();
} catch (err) {
console.error('Error initiating Apple Sign-In:', err);
error = err instanceof Error ? err.message : 'Failed to initiate Apple Sign-In';
onError?.(err instanceof Error ? err : new Error('Unknown error during Apple Sign-In'));
isLoading = false;
}
}
onMount(async () => {
try {
await waitForAppleAuth();
const initialized = initializeAppleAuth();
if (initialized) {
sdkLoaded = true;
} else {
console.warn('Apple Sign-In not configured - hiding button');
}
} catch (err) {
console.error('Error loading Apple Sign-In:', err);
}
});
</script>
{#if sdkLoaded}
<div class="space-y-3">
{#if error}
<div class="rounded-xl bg-red-500/20 border border-red-500/30 p-3 text-sm text-red-500">
{error}
</div>
{/if}
<button
onclick={handleAppleSignIn}
disabled={isLoading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl bg-black border border-gray-800 px-4 font-medium text-white transition-all hover:bg-gray-900 disabled:opacity-50"
>
{#if isLoading}
<div class="h-5 w-5 animate-spin rounded-full border-2 border-white border-t-transparent"></div>
{:else}
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="currentColor">
<path
d="M17.05 20.28c-.98.95-2.05.8-3.08.35-1.09-.46-2.09-.48-3.24 0-1.44.62-2.2.44-3.06-.35C2.79 15.25 3.51 7.59 9.05 7.31c1.35.07 2.29.74 3.08.8 1.18-.24 2.31-.93 3.57-.84 1.51.12 2.65.72 3.4 1.8-3.12 1.87-2.38 5.98.48 7.13-.57 1.5-1.31 2.99-2.54 4.09l.01-.01zM12.03 7.25c-.15-2.23 1.66-4.07 3.74-4.25.29 2.58-2.34 4.5-3.74 4.25z"
/>
</svg>
{/if}
<span>{isLoading ? 'Signing in...' : 'Continue with Apple'}</span>
</button>
</div>
{/if}

View file

@ -0,0 +1,76 @@
<script lang="ts">
import { onMount } from 'svelte';
import { initializeGoogleAuth, renderGoogleButton, waitForGoogleAuth } from '../utils/googleAuth';
interface Props {
onSuccess: (idToken: string) => Promise<void>;
onError?: (error: Error) => void;
}
let { onSuccess, onError }: Props = $props();
let buttonContainer: HTMLDivElement;
let isLoading = $state(false);
let error = $state<string | null>(null);
async function handleGoogleSignIn(idToken: string) {
isLoading = true;
error = null;
try {
await onSuccess(idToken);
} catch (err) {
console.error('Error during Google Sign-In:', err);
error = err instanceof Error ? err.message : 'Google Sign-In failed';
onError?.(err instanceof Error ? err : new Error('Unknown error during Google Sign-In'));
} finally {
isLoading = false;
}
}
onMount(async () => {
try {
await waitForGoogleAuth();
initializeGoogleAuth(handleGoogleSignIn);
if (buttonContainer) {
renderGoogleButton(buttonContainer, {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'pill'
});
}
} catch (err) {
console.error('Error initializing Google Sign-In:', err);
error = 'Failed to load Google Sign-In';
}
});
</script>
{#if error}
<div class="rounded-xl bg-red-500/20 border border-red-500/30 p-3 text-sm text-red-500 mb-2">
{error}
</div>
{/if}
<div bind:this={buttonContainer} class="relative w-full google-btn-wrapper" style="min-height: 56px;">
{#if isLoading}
<div class="absolute inset-0 flex items-center justify-center rounded-xl bg-white/80 dark:bg-black/80 backdrop-blur-sm z-10">
<div class="h-6 w-6 animate-spin rounded-full border-2 border-indigo-500 border-t-transparent"></div>
</div>
{/if}
</div>
<style>
:global(.google-btn-wrapper > div) {
width: 100% !important;
height: 56px !important;
}
:global(.google-btn-wrapper iframe) {
height: 56px !important;
border-radius: 0.75rem !important;
}
</style>

View file

@ -0,0 +1,30 @@
<script lang="ts">
import { iconPaths, type IconName } from '../icons/iconPaths';
interface Props {
name: IconName;
size?: number;
class?: string;
color?: string;
}
let { name, size = 24, class: className = '', color }: Props = $props();
const path = $derived(iconPaths[name]);
</script>
{#if path}
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"
>
{@html path}
</svg>
{:else}
<span class="text-red-500" title="Icon '{name}' not found"></span>
{/if}

View file

@ -0,0 +1,37 @@
/**
* Phosphor Icons (Bold weight) SVG paths
* Only includes icons used in auth UI
*/
export const iconPaths = {
'user-plus': '<path d="M256,136a8,8,0,0,1-8,8H232v16a8,8,0,0,1-16,0V144H200a8,8,0,0,1,0-16h16V112a8,8,0,0,1,16,0v16h16A8,8,0,0,1,256,136Zm-57.87,58.85a8,8,0,0,1-12.26,10.3C165.75,181.19,138.09,168,108,168s-57.75,13.19-77.87,37.15a8,8,0,0,1-12.26-10.3C32.09,177.09,52.67,163.91,76,157.53a68,68,0,1,1,64,0C163.33,163.91,183.91,177.09,198.13,194.85ZM108,152a52,52,0,1,0-52-52A52.06,52.06,0,0,0,108,152Z"/>',
'sign-in': '<path d="M144,136v-16a8,8,0,0,0-8-8H48a8,8,0,0,0-8,8v16a8,8,0,0,0,8,8h88A8,8,0,0,0,144,136Zm-27.31,52.69a8,8,0,0,0-11.32,11.31L136,230.63V224a8,8,0,0,0-16,0v24a8,8,0,0,0,8,8h24a8,8,0,0,0,0-16h-6.63l30.32-30.31a8,8,0,0,0-11.31-11.32l-24,24ZM200,32H136a8,8,0,0,0,0,16h64V208H136a8,8,0,0,0,0,16h64a16,16,0,0,0,16-16V48A16,16,0,0,0,200,32Z"/>',
'eye': '<path d="M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.47,133.47,0,0,1,25,128,133.33,133.33,0,0,1,48.07,97.25C70.33,75.19,97.22,64,128,64s57.67,11.19,79.93,33.25A133.46,133.46,0,0,1,231.05,128C223.84,141.46,192.43,192,128,192Zm0-112a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Z"/>',
'eye-off': '<path d="M53.92,34.62A8,8,0,1,0,42.08,45.38L61.32,66.55C25,88.84,9.38,123.2,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208a127.11,127.11,0,0,0,52.07-10.83l22,24.21a8,8,0,1,0,11.84-10.76Zm47.33,75.84,41.67,45.85a32,32,0,0,1-41.67-45.85ZM128,192c-30.78,0-57.67-11.19-79.93-33.25A133.16,133.16,0,0,1,25,128c4.69-8.79,19.66-33.39,47.35-49.38l18,19.75a48,48,0,0,0,63.66,70l14.73,16.2A112,112,0,0,1,128,192Zm6-95.43a8,8,0,0,1,3-15.72,48.16,48.16,0,0,1,38.77,42.64,8,8,0,0,1-7.22,8.71,6.39,6.39,0,0,1-.75,0,8,8,0,0,1-8-7.26A32.09,32.09,0,0,0,134,96.57Zm113.28,34.69c-.42.94-10.55,23.37-33.36,43.8a8,8,0,1,1-10.67-11.92A132.77,132.77,0,0,0,231.05,128a133.15,133.15,0,0,0-23.12-30.77C185.67,75.19,158.78,64,128,64a118.37,118.37,0,0,0-19.36,1.58A8,8,0,1,1,106,49.79,134,134,0,0,1,128,48c34.88,0,66.57,13.26,91.66,38.35,18.83,18.83,27.3,37.62,27.65,38.41A8,8,0,0,1,247.31,131.26Z"/>',
'key': '<path d="M216.57,39.43A80,80,0,0,0,83.91,120.78L28.69,176A15.86,15.86,0,0,0,24,187.31V216a16,16,0,0,0,16,16H72a8,8,0,0,0,8-8V208H96a8,8,0,0,0,8-8V184h16a8,8,0,0,0,5.66-2.34l9.56-9.57A79.73,79.73,0,0,0,160,176h.1A80,80,0,0,0,216.57,39.43ZM224,98.1c-1.09,34.09-29.75,61.86-63.89,61.9H160a63.7,63.7,0,0,1-23.65-4.51,8,8,0,0,0-8.84,1.68L116.69,168H96a8,8,0,0,0-8,8v16H72a8,8,0,0,0-8,8v16H40V187.31l58.83-58.82a8,8,0,0,0,1.68-8.84A63.72,63.72,0,0,1,96,95.92c0-34.14,27.81-62.8,61.9-63.89A64,64,0,0,1,224,98.1ZM192,76a12,12,0,1,1-12-12A12,12,0,0,1,192,76Z"/>',
'arrow-left': '<path d="M224,128a8,8,0,0,1-8,8H59.31l58.35,58.34a8,8,0,0,1-11.32,11.32l-72-72a8,8,0,0,1,0-11.32l72-72a8,8,0,0,1,11.32,11.32L59.31,120H216A8,8,0,0,1,224,128Z"/>',
'info': '<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,192a88,88,0,1,1,88-88A88.1,88.1,0,0,1,128,216Zm16-40a8,8,0,0,1-8,8,16,16,0,0,1-16-16V128a8,8,0,0,1,0-16,16,16,0,0,1,16,16v40A8,8,0,0,1,144,176ZM112,84a12,12,0,1,1,12,12A12,12,0,0,1,112,84Z"/>',
'mail-open': '<path d="M228.44,89.34l-96-64a8,8,0,0,0-8.88,0l-96,64A8,8,0,0,0,24,96V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V96A8,8,0,0,0,228.44,89.34ZM128,41.61l81.91,54.61-67,47.78a8,8,0,0,1-9.79,0l-67-47.78ZM40,200V111.53l65.9,47a24,24,0,0,0,29.44,0l65.9-47L216,200Z"/>',
'lock': '<path d="M208,80H176V56a48,48,0,0,0-96,0V80H48A16,16,0,0,0,32,96V208a16,16,0,0,0,16,16H208a16,16,0,0,0,16-16V96A16,16,0,0,0,208,80ZM96,56a32,32,0,0,1,64,0V80H96ZM208,208H48V96H208V208Zm-68-56a12,12,0,1,1-12-12A12,12,0,0,1,140,152Z"/>',
'shield-check': '<path d="M208,40H48A16,16,0,0,0,32,56v56c0,52.72,25.52,84.67,46.93,102.19,23.06,18.86,46,26.07,47.48,26.53a8,8,0,0,0,5.18,0c1.52-.46,24.42-7.67,47.48-26.53C200.48,196.67,226,164.72,226,112V56A16,16,0,0,0,208,40Zm0,72c0,37.07-13.66,67.16-40.58,89.42A132.87,132.87,0,0,1,128,224.54a132.77,132.77,0,0,1-39.42-23.12C61.66,179.16,48,149.07,48,112V56H208ZM82.34,141.66a8,8,0,0,1,11.32-11.32L112,148.69l50.34-50.35a8,8,0,0,1,11.32,11.32l-56,56a8,8,0,0,1-11.32,0Z"/>',
'arrows-left-right': '<path d="M45.66,77.66A8,8,0,0,1,40,64h80a8,8,0,0,1,0,16H59.31l18.35,18.34a8,8,0,0,1-11.32,11.32ZM216,176H136a8,8,0,0,0,0,16h60.69l-18.35,18.34a8,8,0,0,0,11.32,11.32l32-32a8,8,0,0,0,0-11.32l-32-32a8,8,0,0,0-11.32,11.32L196.69,176Z"/>',
'envelope': '<path d="M224,48H32a8,8,0,0,0-8,8V192a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A8,8,0,0,0,224,48ZM203.43,64,128,133.15,52.57,64ZM216,192H40V74.19l82.59,75.71a8,8,0,0,0,10.82,0L216,74.19V192Z"/>',
'folder': '<path d="M216,72H131.31L104,44.69A15.86,15.86,0,0,0,92.69,40H40A16,16,0,0,0,24,56V200.62A15.4,15.4,0,0,0,39.38,216H216.89A15.13,15.13,0,0,0,232,200.89V88A16,16,0,0,0,216,72ZM40,56H92.69l16,16H40ZM216,200H40V88H216Z"/>',
'music': '<path d="M212.92,17.69a8,8,0,0,0-6.86-1.45l-128,32A8,8,0,0,0,72,56V166.09A36,36,0,1,0,88,196V62.25l112-28v99.84A36,36,0,1,0,216,168V24A8,8,0,0,0,212.92,17.69ZM52,216a20,20,0,1,1,20-20A20,20,0,0,1,52,216Zm128-32a20,20,0,1,1,20-20A20,20,0,0,1,180,184Z"/>',
'refresh': '<path d="M240,56v48a8,8,0,0,1-8,8H184a8,8,0,0,1,0-16H211.4L184.81,71.64A80,80,0,1,0,128,208a8,8,0,0,1,0,16A96,96,0,1,1,195.26,60.49L224,85.34V56a8,8,0,0,1,16,0Z"/>'
} as const;
export type IconName = keyof typeof iconPaths;

View file

@ -0,0 +1,40 @@
// Pages
export { default as LoginPage } from './pages/LoginPage.svelte';
export { default as RegisterPage } from './pages/RegisterPage.svelte';
// Components
export { default as Icon } from './components/Icon.svelte';
export { default as GoogleSignInButton } from './components/GoogleSignInButton.svelte';
export { default as AppleSignInButton } from './components/AppleSignInButton.svelte';
// Utilities
export {
setGoogleClientId,
initializeGoogleAuth,
renderGoogleButton,
isGoogleAuthLoaded,
waitForGoogleAuth
} from './utils/googleAuth';
export {
setAppleConfig,
initializeAppleAuth,
signInWithApple,
parseAppleAuthorizationResponse,
getStoredReturnUrl,
clearAppleSignInSession,
isAppleAuthLoaded,
waitForAppleAuth,
type AppleAuthorizationResponse
} from './utils/appleAuth';
// Types
export type {
AuthUIConfig,
AuthServiceInterface,
AuthResult,
IconName
} from './types';
// Icon paths
export { iconPaths } from './icons/iconPaths';

View file

@ -0,0 +1,438 @@
<script lang="ts">
import type { Component } from 'svelte';
import type { AuthResult } from '../types';
import Icon from '../components/Icon.svelte';
import GoogleSignInButton from '../components/GoogleSignInButton.svelte';
import AppleSignInButton from '../components/AppleSignInButton.svelte';
type AuthMode = 'initial' | 'login' | 'forgot-password' | 'password-reset-success';
interface Props {
/** App name */
appName: string;
/** Logo component */
logo: Component<{ size?: number; color?: string }>;
/** Primary color (hex) */
primaryColor: string;
/** Sign in function */
onSignIn: (email: string, password: string) => Promise<AuthResult>;
/** Sign in with Google function */
onSignInWithGoogle?: (idToken: string) => Promise<AuthResult>;
/** Sign in with Apple function */
onSignInWithApple?: (identityToken: string) => Promise<AuthResult>;
/** Forgot password function */
onForgotPassword: (email: string) => Promise<AuthResult>;
/** Navigation function */
goto: (path: string) => void;
/** Enable Google Sign-In */
enableGoogle?: boolean;
/** Enable Apple Sign-In */
enableApple?: boolean;
/** Success redirect path */
successRedirect?: string;
/** Register page path */
registerPath?: string;
/** Light background color */
lightBackground?: string;
/** Dark background color */
darkBackground?: string;
}
let {
appName,
logo: Logo,
primaryColor,
onSignIn,
onSignInWithGoogle,
onSignInWithApple,
onForgotPassword,
goto,
enableGoogle = false,
enableApple = false,
successRedirect = '/dashboard',
registerPath = '/register',
lightBackground = '#f5f5f5',
darkBackground = '#121212'
}: Props = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let email = $state('');
let password = $state('');
let mode = $state<AuthMode>('initial');
let resetEmail = $state('');
let showPassword = $state(false);
// Check for dark mode
let isDark = $state(false);
$effect(() => {
if (typeof window !== 'undefined') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const listener = (e: MediaQueryListEvent) => {
isDark = e.matches;
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener);
return () => {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener);
};
}
});
function getPageBackground() {
return isDark ? darkBackground : lightBackground;
}
async function handleLogin() {
loading = true;
error = null;
if (!email) {
error = 'Email is required';
loading = false;
return;
}
if (!password) {
error = 'Password is required';
loading = false;
return;
}
const result = await onSignIn(email, password);
loading = false;
if (result.success) {
goto(successRedirect);
} else {
error = result.error || 'Sign in failed';
}
}
async function handleForgotPassword() {
loading = true;
error = null;
if (!email) {
error = 'Email is required';
loading = false;
return;
}
const result = await onForgotPassword(email);
loading = false;
if (result.success) {
resetEmail = email;
resetForm();
switchMode('password-reset-success');
} else {
error = result.error || 'Failed to send reset email';
}
}
async function handleGoogleSuccess(idToken: string) {
if (!onSignInWithGoogle) return;
const result = await onSignInWithGoogle(idToken);
if (result.success) {
goto(successRedirect);
} else {
error = result.error || 'Google sign in failed';
}
}
function resetForm() {
email = '';
password = '';
error = null;
}
function switchMode(newMode: AuthMode) {
mode = newMode;
error = null;
}
</script>
<svelte:head>
<title>Login - {appName}</title>
</svelte:head>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
<Logo size={55} color={primaryColor} />
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<!-- Middle Section - Auth Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<div class="mb-6">
<h2
class="text-center text-xl font-semibold flex items-center justify-center gap-2"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
{#if mode === 'initial'}
Mana Login
{:else if mode === 'login'}
Sign In
{:else if mode === 'forgot-password'}
Reset Password
{:else if mode === 'password-reset-success'}
Email Sent
{/if}
</h2>
{#if mode === 'initial'}
<p
class="mt-3 text-sm text-center"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
Sign in with your Mana account
</p>
{/if}
</div>
<!-- Error Messages -->
{#if error}
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
<!-- Initial Mode -->
{#if mode === 'initial'}
<div class="mb-2 flex flex-col gap-3">
<button
onclick={() => goto(registerPath)}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="user-plus" size={20} />
Create Account
</button>
<button
onclick={() => switchMode('login')}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
Sign In
</button>
</div>
<!-- Login Mode -->
{:else if mode === 'login'}
<form
onsubmit={(e) => {
e.preventDefault();
handleLogin();
}}
class="pb-4"
>
<div class="mb-2">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
<div class="mb-2 relative">
<input
type={showPassword ? 'text' : 'password'}
bind:value={password}
placeholder="Password"
required
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<Icon
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
/>
</button>
</div>
<button
type="button"
onclick={() => switchMode('forgot-password')}
class="mb-4 flex h-10 w-full items-center justify-center rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
Forgot Password?
</button>
<button
type="submit"
disabled={loading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
<!-- Social Login -->
{#if enableGoogle || enableApple}
<div class="my-4 flex items-center gap-3">
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
<p class="text-xs" style="color: {isDark ? 'rgba(255, 255, 255, 0.5)' : 'rgba(0, 0, 0, 0.5)'};">or</p>
<div class="flex-1 border-t" style="border-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"></div>
</div>
<div class="mb-4 flex flex-col gap-2">
{#if enableGoogle && onSignInWithGoogle}
<GoogleSignInButton onSuccess={handleGoogleSuccess} />
{/if}
{#if enableApple}
<AppleSignInButton />
{/if}
</div>
{/if}
<!-- Back Button -->
<div class="mt-4">
<button
onclick={() => {
resetForm();
switchMode('initial');
}}
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="arrow-left" size={20} />
Back
</button>
</div>
<!-- Forgot Password Mode -->
{:else if mode === 'forgot-password'}
<form
onsubmit={(e) => {
e.preventDefault();
handleForgotPassword();
}}
class="pb-4"
>
<p
class="mb-4 text-sm"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
Enter your email address and we'll send you a link to reset your password.
</p>
<div class="mb-4">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
<div class="flex flex-col gap-4">
<button
type="submit"
disabled={loading}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="key" size={20} />
{loading ? 'Sending...' : 'Reset Password'}
</button>
<button
type="button"
onclick={() => {
resetForm();
switchMode('login');
}}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="arrow-left" size={20} />
Back
</button>
</div>
</form>
<!-- Password Reset Success Mode -->
{:else if mode === 'password-reset-success'}
<div class="pb-4">
<div class="flex flex-col items-center mb-6">
<div
class="w-20 h-20 rounded-full flex items-center justify-center mb-6"
style="background-color: {primaryColor}30;"
>
<Icon name="mail-open" size={40} color={primaryColor} />
</div>
<p
class="text-sm text-center px-2"
style="color: {isDark ? 'rgba(255, 255, 255, 0.7)' : 'rgba(0, 0, 0, 0.7)'};"
>
We've sent a password reset link to <strong>{resetEmail}</strong>. Please check your
inbox.
</p>
</div>
<div class="flex flex-col gap-3">
<button
onclick={() => {
resetEmail = '';
switchMode('login');
}}
class="flex h-14 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="sign-in" size={20} />
Back to Login
</button>
<button
onclick={() => switchMode('forgot-password')}
class="flex h-10 items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 border"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'};"
>
Resend Email
</button>
</div>
</div>
{/if}
</div>
</div>
<!-- Bottom padding -->
<div class="pb-8"></div>
</div>

View file

@ -0,0 +1,310 @@
<script lang="ts">
import type { Component } from 'svelte';
import type { AuthResult } from '../types';
import Icon from '../components/Icon.svelte';
interface Props {
/** App name */
appName: string;
/** Logo component */
logo: Component<{ size?: number; color?: string }>;
/** Primary color (hex) */
primaryColor: string;
/** Sign up function */
onSignUp: (email: string, password: string) => Promise<AuthResult>;
/** Navigation function */
goto: (path: string) => void;
/** Success redirect path */
successRedirect?: string;
/** Login page path */
loginPath?: string;
/** Light background color */
lightBackground?: string;
/** Dark background color */
darkBackground?: string;
}
let {
appName,
logo: Logo,
primaryColor,
onSignUp,
goto,
successRedirect = '/dashboard',
loginPath = '/login',
lightBackground = '#f5f5f5',
darkBackground = '#121212'
}: Props = $props();
let loading = $state(false);
let error = $state<string | null>(null);
let success = $state(false);
let needsVerification = $state(false);
let email = $state('');
let password = $state('');
let confirmPassword = $state('');
let showPassword = $state(false);
let showConfirmPassword = $state(false);
// Check for dark mode
let isDark = $state(false);
$effect(() => {
if (typeof window !== 'undefined') {
isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
const listener = (e: MediaQueryListEvent) => {
isDark = e.matches;
};
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', listener);
return () => {
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', listener);
};
}
});
// Password validation
let passwordRequirements = $derived.by(() => {
if (!password) {
return {
length: false,
lowercase: false,
uppercase: false,
digit: false,
special: false
};
}
return {
length: password.length >= 8,
lowercase: /[a-z]/.test(password),
uppercase: /[A-Z]/.test(password),
digit: /[0-9]/.test(password),
special: /[^a-zA-Z0-9]/.test(password)
};
});
function getPageBackground() {
return isDark ? darkBackground : lightBackground;
}
async function handleRegister() {
loading = true;
error = null;
success = false;
// Validation
if (!email) {
error = 'Email is required';
loading = false;
return;
}
if (!password) {
error = 'Password is required';
loading = false;
return;
}
if (!confirmPassword) {
error = 'Please confirm your password';
loading = false;
return;
}
if (password !== confirmPassword) {
error = 'Passwords do not match';
loading = false;
return;
}
if (password.length < 8) {
error = 'Password must be at least 8 characters';
loading = false;
return;
}
// Check password strength
const hasLowercase = /[a-z]/.test(password);
const hasUppercase = /[A-Z]/.test(password);
const hasDigit = /[0-9]/.test(password);
const hasSymbol = /[^a-zA-Z0-9]/.test(password);
if (!hasLowercase || !hasUppercase || !hasDigit || !hasSymbol) {
error = 'Password must include lowercase, uppercase, number, and special character';
loading = false;
return;
}
const result = await onSignUp(email, password);
loading = false;
if (result.success) {
if (result.needsVerification) {
needsVerification = true;
success = true;
password = '';
confirmPassword = '';
} else {
goto(successRedirect);
}
} else {
error = result.error || 'Registration failed';
}
}
</script>
<svelte:head>
<title>Create Account - {appName}</title>
</svelte:head>
<div
class="flex min-h-screen flex-col justify-between"
style="background-color: {getPageBackground()};"
>
<!-- Top Section - Logo -->
<div class="flex flex-col items-center justify-center pt-16 pb-8">
<div
class="flex items-center justify-center rounded-full transition-all mb-4"
style="width: 120px; height: 120px; border: 3px solid {primaryColor}; background-color: {isDark ? '#000' : '#fff'}; box-shadow: {isDark
? '0 6px 12px rgba(0, 0, 0, 0.4)'
: '0 6px 12px rgba(0, 0, 0, 0.15)'};"
>
<Logo size={55} color={primaryColor} />
</div>
<h1 class="text-2xl font-semibold" style="color: {isDark ? '#ffffff' : '#000000'};">
{appName}
</h1>
</div>
<!-- Middle Section - Register Form -->
<div class="flex-1 flex items-start justify-center px-5 pt-8 pb-8">
<div
class="w-full max-w-md rounded-xl p-6"
style="background-color: {isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(255, 255, 255, 0.7)'}; backdrop-filter: blur(10px); border: 1px solid {isDark ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'};"
>
<!-- Title -->
<h2
class="mb-6 text-center text-xl font-semibold"
style="color: {isDark ? 'rgba(255, 255, 255, 0.9)' : 'rgba(0, 0, 0, 0.9)'};"
>
Create Account
</h2>
<!-- Error Messages -->
{#if error}
<div class="mb-4 rounded-xl bg-red-500/20 border border-red-500/30 p-3">
<p class="text-sm text-red-500">{error}</p>
</div>
{/if}
<!-- Success Message -->
{#if success && needsVerification}
<div class="mb-4 rounded-xl bg-green-500/20 border border-green-500/30 p-3">
<p class="text-sm text-green-500">
Account created! Please check your email to verify your account.
</p>
</div>
{/if}
<!-- Register Form -->
<form
onsubmit={(e) => {
e.preventDefault();
handleRegister();
}}
class="pb-4"
>
<div class="mb-2">
<input
type="email"
bind:value={email}
placeholder="Email"
required
class="h-14 w-full rounded-xl border px-4 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
</div>
<div class="mb-2 relative">
<input
type={showPassword ? 'text' : 'password'}
bind:value={password}
placeholder="Password"
required
minlength={8}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
<button
type="button"
onclick={() => (showPassword = !showPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<Icon
name={showPassword ? 'eye-off' : 'eye'}
size={20}
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
/>
</button>
</div>
<div class="mb-2 relative">
<input
type={showConfirmPassword ? 'text' : 'password'}
bind:value={confirmPassword}
placeholder="Confirm Password"
required
minlength={8}
class="h-14 w-full rounded-xl border px-4 pr-12 text-lg transition-colors focus:outline-none focus:ring-2"
style="background-color: {isDark ? 'rgba(0, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.8)'}; border-color: {isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(0, 0, 0, 0.1)'}; color: {isDark ? '#ffffff' : '#000000'}; --tw-ring-color: {primaryColor};"
/>
<button
type="button"
onclick={() => (showConfirmPassword = !showConfirmPassword)}
class="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-lg hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
>
<Icon
name={showConfirmPassword ? 'eye-off' : 'eye'}
size={20}
color={isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'}
/>
</button>
</div>
<!-- Password Requirements -->
<p
class="mb-4 mt-2 text-xs"
style="color: {isDark ? 'rgba(255, 255, 255, 0.6)' : 'rgba(0, 0, 0, 0.6)'};"
>
Password must be at least 8 characters with lowercase, uppercase, number, and special
character.
</p>
<button
type="submit"
disabled={loading}
class="flex h-14 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80 disabled:opacity-50 border-2"
style="background-color: {primaryColor}60; border-color: {primaryColor}; color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="user-plus" size={20} />
{loading ? 'Creating Account...' : 'Create Account'}
</button>
</form>
<!-- Back Button -->
<div class="mt-4">
<button
onclick={() => goto(loginPath)}
class="flex h-10 w-full items-center justify-center gap-2 rounded-xl font-medium transition-all hover:opacity-80"
style="color: {isDark ? '#ffffff' : '#000000'};"
>
<Icon name="arrow-left" size={20} />
Back to Login
</button>
</div>
</div>
</div>
<!-- Bottom padding -->
<div class="pb-8"></div>
</div>

View file

@ -0,0 +1,80 @@
import type { Component } from 'svelte';
/**
* Configuration for auth UI pages
*/
export interface AuthUIConfig {
/** App name to display */
appName: string;
/** Logo component to render */
logo: Component<{ size?: number; color?: string }>;
/** Primary color (hex) */
primaryColor: string;
/** Primary color for dark mode (optional, defaults to primaryColor) */
darkPrimaryColor?: string;
/** Page background color for light mode */
lightBackground?: string;
/** Page background color for dark mode */
darkBackground?: string;
/** Redirect path after successful login (default: '/dashboard') */
successRedirect?: string;
/** Enable Google Sign-In */
enableGoogle?: boolean;
/** Enable Apple Sign-In */
enableApple?: boolean;
/** Google OAuth Client ID (required if enableGoogle is true) */
googleClientId?: string;
/** Apple OAuth Service ID (required if enableApple is true) */
appleClientId?: string;
/** Apple OAuth Redirect URI */
appleRedirectUri?: string;
}
/**
* Auth service interface expected by the UI components
*/
export interface AuthServiceInterface {
signIn(email: string, password: string): Promise<AuthResult>;
signUp(email: string, password: string): Promise<AuthResult>;
signInWithGoogle?(idToken: string): Promise<AuthResult>;
signInWithApple?(identityToken: string): Promise<AuthResult>;
forgotPassword(email: string): Promise<AuthResult>;
}
/**
* Result from auth operations
*/
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
}
/**
* Icon names available in the icon set
*/
export type IconName =
| 'user-plus'
| 'sign-in'
| 'eye'
| 'eye-off'
| 'key'
| 'arrow-left'
| 'info'
| 'mail-open'
| 'lock'
| 'shield-check'
| 'arrows-left-right'
| 'envelope'
| 'folder';

View file

@ -0,0 +1,216 @@
/**
* Apple Sign-In integration for web
* Uses redirect flow (not popup)
*/
// TypeScript definitions for Apple ID SDK
declare global {
interface Window {
AppleID?: {
auth: {
init: (config: AppleIDInitConfig) => void;
signIn: () => Promise<AppleIDSignInResponse>;
};
};
}
}
interface AppleIDInitConfig {
clientId: string;
scope: string;
redirectURI: string;
state?: string;
nonce?: string;
usePopup?: boolean;
responseType?: string;
responseMode?: string;
}
interface AppleIDSignInResponse {
authorization: {
code: string;
id_token?: string;
state?: string;
};
user?: {
email?: string;
name?: {
firstName?: string;
lastName?: string;
};
};
}
export interface AppleAuthorizationResponse {
code: string;
id_token?: string;
state?: string;
user?: string;
}
let appleClientId: string | null = null;
let appleRedirectUri: string | null = null;
/**
* Set Apple Sign-In configuration
*/
export function setAppleConfig(clientId: string, redirectUri: string) {
appleClientId = clientId;
appleRedirectUri = redirectUri;
}
/**
* Check if running in browser
*/
function isBrowser(): boolean {
return typeof window !== 'undefined';
}
/**
* Initialize Apple ID SDK
*/
export function initializeAppleAuth(): boolean {
if (!isBrowser() || !window.AppleID) {
console.warn('Apple ID SDK not loaded');
return false;
}
if (!appleClientId || !appleRedirectUri) {
console.error('Apple Sign-In not configured. Call setAppleConfig() first.');
return false;
}
try {
window.AppleID.auth.init({
clientId: appleClientId,
scope: 'name email',
redirectURI: appleRedirectUri,
state: generateState(),
usePopup: false,
responseType: 'code id_token',
responseMode: 'form_post'
});
console.log('Apple ID SDK initialized successfully');
return true;
} catch (error) {
console.error('Error initializing Apple ID SDK:', error);
return false;
}
}
/**
* Initiate Apple Sign-In (redirect flow)
*/
export async function signInWithApple(): Promise<void> {
if (!isBrowser()) {
throw new Error('Apple Sign-In only available in browser');
}
if (!window.AppleID) {
throw new Error('Apple ID SDK not loaded');
}
try {
const returnTo = window.location.pathname + window.location.search;
sessionStorage.setItem('apple_signin_return_to', returnTo);
await window.AppleID.auth.signIn();
} catch (error) {
console.error('Error initiating Apple Sign-In:', error);
throw error;
}
}
/**
* Parse Apple authorization response from URL
*/
export function parseAppleAuthorizationResponse(
urlParams: URLSearchParams
): AppleAuthorizationResponse | null {
const code = urlParams.get('code');
const id_token = urlParams.get('id_token');
const state = urlParams.get('state');
const user = urlParams.get('user');
const error = urlParams.get('error');
if (error) {
console.error('Apple Sign-In error:', error);
return null;
}
const storedState = sessionStorage.getItem('apple_signin_state');
if (state !== storedState) {
console.error('State mismatch - possible CSRF attack');
return null;
}
if (!id_token && !code) {
console.error('No id_token or authorization code in Apple response');
return null;
}
return {
code: code || '',
id_token: id_token || undefined,
state: state || undefined,
user: user || undefined
};
}
/**
* Generate random state for CSRF protection
*/
function generateState(): string {
const state = Math.random().toString(36).substring(2, 15);
if (isBrowser()) {
sessionStorage.setItem('apple_signin_state', state);
}
return state;
}
/**
* Get stored return URL
*/
export function getStoredReturnUrl(): string {
if (!isBrowser()) return '/dashboard';
return sessionStorage.getItem('apple_signin_return_to') || '/dashboard';
}
/**
* Clear Apple Sign-In session data
*/
export function clearAppleSignInSession() {
if (!isBrowser()) return;
sessionStorage.removeItem('apple_signin_state');
sessionStorage.removeItem('apple_signin_return_to');
}
/**
* Check if Apple ID SDK is loaded
*/
export function isAppleAuthLoaded(): boolean {
return isBrowser() && !!window.AppleID?.auth;
}
/**
* Wait for Apple ID SDK to load
*/
export function waitForAppleAuth(timeout = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (isAppleAuthLoaded()) {
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (isAppleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Apple ID SDK failed to load'));
}
}, 100);
});
}

View file

@ -0,0 +1,174 @@
/**
* Google Identity Services integration
* Provides helper functions for Google Sign-In on web
*/
// TypeScript definitions for Google Identity Services
declare global {
interface Window {
google?: {
accounts: {
id: {
initialize: (config: GoogleIdConfiguration) => void;
prompt: (momentListener?: (notification: PromptMomentNotification) => void) => void;
renderButton: (parent: HTMLElement, options: GsiButtonConfiguration) => void;
disableAutoSelect: () => void;
storeCredential: (credential: { id: string; password: string }) => void;
cancel: () => void;
onGoogleLibraryLoad: () => void;
revoke: (hint: string, callback: (done: RevocationResponse) => void) => void;
};
};
};
}
}
interface GoogleIdConfiguration {
client_id: string;
callback: (response: CredentialResponse) => void;
auto_select?: boolean;
cancel_on_tap_outside?: boolean;
context?: 'signin' | 'signup' | 'use';
ux_mode?: 'popup' | 'redirect';
login_uri?: string;
native_callback?: (response: { id: string; password: string }) => void;
itp_support?: boolean;
}
interface CredentialResponse {
credential: string;
select_by: string;
clientId?: string;
}
interface GsiButtonConfiguration {
type?: 'standard' | 'icon';
theme?: 'outline' | 'filled_blue' | 'filled_black';
size?: 'large' | 'medium' | 'small';
text?: 'signin_with' | 'signup_with' | 'continue_with' | 'signin';
shape?: 'rectangular' | 'pill' | 'circle' | 'square';
logo_alignment?: 'left' | 'center';
width?: string;
locale?: string;
}
interface PromptMomentNotification {
isDisplayMoment: () => boolean;
isDisplayed: () => boolean;
isNotDisplayed: () => boolean;
getNotDisplayedReason: () => string;
isSkippedMoment: () => boolean;
getSkippedReason: () => string;
isDismissedMoment: () => boolean;
getDismissedReason: () => string;
getMomentType: () => 'display' | 'skipped' | 'dismissed';
}
interface RevocationResponse {
successful: boolean;
error?: string;
}
let googleClientId: string | null = null;
/**
* Set Google Client ID for initialization
*/
export function setGoogleClientId(clientId: string) {
googleClientId = clientId;
}
/**
* Initialize Google Identity Services
*/
export function initializeGoogleAuth(callback: (idToken: string) => void) {
if (typeof window === 'undefined') {
console.warn('Google Auth: Cannot initialize on server-side');
return;
}
if (!window.google) {
console.warn('Google Identity Services not loaded yet');
return;
}
if (!googleClientId) {
console.error('Google Client ID not configured. Call setGoogleClientId() first.');
return;
}
try {
window.google.accounts.id.initialize({
client_id: googleClientId,
callback: (response: CredentialResponse) => {
callback(response.credential);
},
auto_select: false,
cancel_on_tap_outside: true,
ux_mode: 'popup'
});
} catch (error) {
console.error('Error initializing Google Auth:', error);
}
}
/**
* Render Google Sign-In button
*/
export function renderGoogleButton(
element: HTMLElement,
options?: Partial<GsiButtonConfiguration>
) {
if (typeof window === 'undefined' || !window.google) {
console.warn('Google Identity Services not available');
return;
}
const defaultOptions: GsiButtonConfiguration = {
type: 'standard',
theme: 'outline',
size: 'large',
text: 'signin_with',
shape: 'rectangular',
logo_alignment: 'left'
};
try {
window.google.accounts.id.renderButton(element, {
...defaultOptions,
...options
});
} catch (error) {
console.error('Error rendering Google button:', error);
}
}
/**
* Check if Google Identity Services is loaded
*/
export function isGoogleAuthLoaded(): boolean {
return typeof window !== 'undefined' && !!window.google?.accounts?.id;
}
/**
* Wait for Google Identity Services to load
*/
export function waitForGoogleAuth(timeout = 10000): Promise<void> {
return new Promise((resolve, reject) => {
if (isGoogleAuthLoaded()) {
resolve();
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
if (isGoogleAuthLoaded()) {
clearInterval(interval);
resolve();
} else if (Date.now() - startTime > timeout) {
clearInterval(interval);
reject(new Error('Google Identity Services failed to load'));
}
}, 100);
});
}

View file

@ -0,0 +1,21 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"declaration": true,
"declarationMap": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,210 @@
# @manacore/shared-auth
Shared authentication utilities for Manacore apps. This package provides a configurable authentication service that can be used across React Native (Expo) and web apps.
## Features
- **Configurable Auth Service**: Create auth services with custom base URLs and endpoints
- **Token Manager**: Handle token refresh, queueing, and state management
- **JWT Utilities**: Decode tokens, check expiration, extract user data
- **Fetch Interceptor**: Automatically attach tokens and handle 401 responses
- **Platform Adapters**: Pluggable storage, device, and network adapters
## Installation
```bash
pnpm add @manacore/shared-auth
```
## Quick Start
### Web (SvelteKit, React, etc.)
```typescript
import { initializeWebAuth } from '@manacore/shared-auth';
const { authService, tokenManager } = initializeWebAuth({
baseUrl: 'https://api.example.com',
});
// Sign in
const result = await authService.signIn('user@example.com', 'password');
if (result.success) {
console.log('Signed in!');
}
// Get current user
const user = await authService.getUserFromToken();
console.log(user?.email);
// Sign out
await authService.signOut();
```
### React Native (Expo)
```typescript
import {
createAuthService,
createTokenManager,
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
setupFetchInterceptor,
} from '@manacore/shared-auth';
import * as SecureStore from 'expo-secure-store';
// Create storage adapter for Expo
const expoStorageAdapter = {
async getItem<T = string>(key: string): Promise<T | null> {
const value = await SecureStore.getItemAsync(key);
if (!value) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
await SecureStore.setItemAsync(key, value);
},
async removeItem(key: string): Promise<void> {
await SecureStore.deleteItemAsync(key);
},
};
// Set up adapters
setStorageAdapter(expoStorageAdapter);
setDeviceAdapter(yourDeviceAdapter);
setNetworkAdapter(yourNetworkAdapter);
// Create services
const authService = createAuthService({
baseUrl: process.env.EXPO_PUBLIC_API_URL,
});
const tokenManager = createTokenManager(authService);
// Set up fetch interceptor
setupFetchInterceptor(authService, tokenManager);
```
## API Reference
### createAuthService(config)
Creates an authentication service instance.
```typescript
const authService = createAuthService({
baseUrl: 'https://api.example.com',
storageKeys: {
APP_TOKEN: '@auth/appToken',
REFRESH_TOKEN: '@auth/refreshToken',
USER_EMAIL: '@auth/userEmail',
},
endpoints: {
signIn: '/auth/signin',
signUp: '/auth/signup',
// ... other endpoints
},
});
```
### createTokenManager(authService, config?)
Creates a token manager for handling token refresh and state.
```typescript
const tokenManager = createTokenManager(authService, {
maxQueueSize: 50,
queueTimeoutMs: 30000,
maxRefreshAttempts: 3,
refreshCooldownMs: 5000,
});
// Subscribe to state changes
const unsubscribe = tokenManager.subscribe((state, token) => {
console.log('Token state:', state);
});
// Get valid token (refreshes if needed)
const token = await tokenManager.getValidToken();
```
### JWT Utilities
```typescript
import {
decodeToken,
isTokenValidLocally,
getUserFromToken,
isB2BUser,
getB2BInfo,
} from '@manacore/shared-auth';
const payload = decodeToken(token);
const isValid = isTokenValidLocally(token);
const user = getUserFromToken(token);
const isB2B = isB2BUser(token);
```
### Adapters
The package uses adapters for platform-specific functionality:
- **StorageAdapter**: For storing tokens securely
- **DeviceAdapter**: For getting device information
- **NetworkAdapter**: For checking network connectivity
```typescript
import {
setStorageAdapter,
setDeviceAdapter,
setNetworkAdapter,
} from '@manacore/shared-auth';
setStorageAdapter(myStorageAdapter);
setDeviceAdapter(myDeviceAdapter);
setNetworkAdapter(myNetworkAdapter);
```
## Migration from Existing Auth
To migrate from existing auth implementations:
1. Install the package
2. Set up the adapters for your platform
3. Replace direct authService calls with the shared service
4. Update token manager usage
### Before
```typescript
// memoro/apps/mobile/features/auth/services/authService.ts
import { authService } from './authService';
await authService.signIn(email, password);
```
### After
```typescript
// Use the shared auth service
import { authService } from '@/services/auth'; // Your configured instance
await authService.signIn(email, password);
```
## Token States
The token manager tracks these states:
- `IDLE`: Initial state
- `VALID`: Token is valid
- `REFRESHING`: Token refresh in progress
- `EXPIRED`: Token has expired
- `EXPIRED_OFFLINE`: Token expired while offline (preserves auth)
## Contributing
1. Make changes to the source files in `src/`
2. Run `pnpm run type-check` to validate TypeScript
3. Run `pnpm run build` to compile

View file

@ -0,0 +1,37 @@
{
"name": "@manacore/shared-auth",
"version": "0.1.0",
"description": "Shared authentication utilities for Manacore apps",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsc",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"dependencies": {
"base64-js": "^1.5.1"
},
"devDependencies": {
"typescript": "^5.9.3"
},
"peerDependencies": {
"react-native": ">=0.70.0"
},
"peerDependenciesMeta": {
"react-native": {
"optional": true
}
},
"keywords": [
"manacore",
"auth",
"jwt",
"token"
],
"license": "MIT"
}

View file

@ -0,0 +1,81 @@
import type { DeviceManagerAdapter, DeviceInfo } from '../types';
let deviceAdapter: DeviceManagerAdapter | null = null;
/**
* Set the device manager adapter for the auth service
*/
export function setDeviceAdapter(adapter: DeviceManagerAdapter): void {
deviceAdapter = adapter;
}
/**
* Get the current device adapter
*/
export function getDeviceAdapter(): DeviceManagerAdapter {
if (!deviceAdapter) {
throw new Error(
'Device adapter not initialized. Call setDeviceAdapter() before using auth services.'
);
}
return deviceAdapter;
}
/**
* Check if device adapter is initialized
*/
export function isDeviceInitialized(): boolean {
return deviceAdapter !== null;
}
/**
* Create a web-based device manager adapter
*/
export function createWebDeviceAdapter(): DeviceManagerAdapter {
// Generate a persistent device ID for web
const getOrCreateDeviceId = (): string => {
const storageKey = '@manacore/deviceId';
let deviceId = localStorage.getItem(storageKey);
if (!deviceId) {
deviceId = crypto.randomUUID();
localStorage.setItem(storageKey, deviceId);
}
return deviceId;
};
return {
async getDeviceInfo(): Promise<DeviceInfo> {
const userAgent = navigator.userAgent;
let deviceName = 'Web Browser';
let deviceType = 'web';
// Try to extract browser name
if (userAgent.includes('Chrome')) {
deviceName = 'Chrome Browser';
} else if (userAgent.includes('Safari')) {
deviceName = 'Safari Browser';
} else if (userAgent.includes('Firefox')) {
deviceName = 'Firefox Browser';
} else if (userAgent.includes('Edge')) {
deviceName = 'Edge Browser';
}
// Detect device type
if (/Mobi|Android/i.test(userAgent)) {
deviceType = 'mobile_web';
} else if (/Tablet|iPad/i.test(userAgent)) {
deviceType = 'tablet_web';
}
return {
deviceId: getOrCreateDeviceId(),
deviceName,
deviceType,
platform: 'web',
};
},
async getStoredDeviceId(): Promise<string | null> {
return localStorage.getItem('@manacore/deviceId');
},
};
}

View file

@ -0,0 +1,55 @@
import type { NetworkAdapter } from '../types';
let networkAdapter: NetworkAdapter | null = null;
/**
* Set the network adapter for the auth service
*/
export function setNetworkAdapter(adapter: NetworkAdapter): void {
networkAdapter = adapter;
}
/**
* Get the current network adapter
*/
export function getNetworkAdapter(): NetworkAdapter | null {
return networkAdapter;
}
/**
* Check if device is connected to the network
*/
export async function isDeviceConnected(): Promise<boolean> {
if (!networkAdapter) {
// Default to true if no adapter is set
return true;
}
return networkAdapter.isDeviceConnected();
}
/**
* Check if device has a stable connection
*/
export async function hasStableConnection(): Promise<boolean> {
if (!networkAdapter || !networkAdapter.hasStableConnection) {
// Default to basic connectivity check
return isDeviceConnected();
}
return networkAdapter.hasStableConnection();
}
/**
* Create a web-based network adapter
*/
export function createWebNetworkAdapter(): NetworkAdapter {
return {
async isDeviceConnected(): Promise<boolean> {
return navigator.onLine;
},
async hasStableConnection(): Promise<boolean> {
// For web, we just check online status
// More sophisticated checks could be added
return navigator.onLine;
},
};
}

View file

@ -0,0 +1,89 @@
import type { StorageAdapter } from '../types';
/**
* Storage adapter that must be implemented by the consuming app.
*
* For React Native (Expo):
* - Use expo-secure-store for sensitive data
* - Use @react-native-async-storage/async-storage for non-sensitive data
*
* For Web:
* - Use localStorage or sessionStorage
* - Consider using encrypted storage for sensitive data
*/
let storageAdapter: StorageAdapter | null = null;
/**
* Set the storage adapter for the auth service
*/
export function setStorageAdapter(adapter: StorageAdapter): void {
storageAdapter = adapter;
}
/**
* Get the current storage adapter
*/
export function getStorageAdapter(): StorageAdapter {
if (!storageAdapter) {
throw new Error(
'Storage adapter not initialized. Call setStorageAdapter() before using auth services.'
);
}
return storageAdapter;
}
/**
* Check if storage adapter is initialized
*/
export function isStorageInitialized(): boolean {
return storageAdapter !== null;
}
/**
* Create a localStorage-based storage adapter (for web)
*/
export function createLocalStorageAdapter(): StorageAdapter {
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = localStorage.getItem(key);
if (value === null) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
localStorage.setItem(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
localStorage.removeItem(key);
},
};
}
/**
* Create an in-memory storage adapter (for testing)
*/
export function createMemoryStorageAdapter(): StorageAdapter {
const storage = new Map<string, string>();
return {
async getItem<T = string>(key: string): Promise<T | null> {
const value = storage.get(key);
if (value === undefined) return null;
try {
return JSON.parse(value) as T;
} catch {
return value as T;
}
},
async setItem(key: string, value: string): Promise<void> {
storage.set(key, typeof value === 'string' ? value : JSON.stringify(value));
},
async removeItem(key: string): Promise<void> {
storage.delete(key);
},
};
}

View file

@ -0,0 +1,546 @@
import type {
AuthServiceConfig,
AuthEndpoints,
AuthResult,
TokenRefreshResult,
UserData,
StorageKeys,
CreditBalance,
B2BInfo,
} from '../types';
import { getStorageAdapter } from '../adapters/storage';
import { getDeviceAdapter } from '../adapters/device';
import {
decodeToken,
isTokenValidLocally,
getUserFromToken,
getB2BInfo as getB2BInfoFromToken,
shouldDisableRevenueCat as checkRevenueCat,
isB2BUser as checkB2BUser,
getAppSettings as getAppSettingsFromToken,
} from './jwtUtils';
/**
* Default storage keys
*/
const DEFAULT_STORAGE_KEYS: StorageKeys = {
APP_TOKEN: '@auth/appToken',
REFRESH_TOKEN: '@auth/refreshToken',
USER_EMAIL: '@auth/userEmail',
};
/**
* Default API endpoints
*/
const DEFAULT_ENDPOINTS: AuthEndpoints = {
signIn: '/auth/signin',
signUp: '/auth/signup',
signOut: '/auth/logout',
refresh: '/auth/refresh',
validate: '/auth/validate',
forgotPassword: '/auth/forgot-password',
googleSignIn: '/auth/google-signin',
appleSignIn: '/auth/apple-signin',
credits: '/auth/credits',
};
/**
* Create an authentication service with the given configuration
*/
export function createAuthService(config: AuthServiceConfig) {
const baseUrl = config.baseUrl.replace(/\/$/, ''); // Remove trailing slash
const storageKeys: StorageKeys = { ...DEFAULT_STORAGE_KEYS, ...config.storageKeys };
const endpoints: AuthEndpoints = { ...DEFAULT_ENDPOINTS, ...config.endpoints };
// Callback for token refresh events
let onTokenRefreshCallback: ((userData: UserData) => void) | null = null;
const service = {
/**
* Sign in with email and password
*/
async signIn(email: string, password: string): Promise<AuthResult> {
try {
const storage = getStorageAdapter();
const deviceAdapter = getDeviceAdapter();
const deviceInfo = await deviceAdapter.getDeviceInfo();
const response = await fetch(`${baseUrl}${endpoints.signIn}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return service.handleAuthError(response.status, errorData);
}
const { appToken, refreshToken } = await response.json();
await Promise.all([
storage.setItem(storageKeys.APP_TOKEN, appToken),
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
storage.setItem(storageKeys.USER_EMAIL, email),
]);
return { success: true };
} catch (error) {
console.error('Error signing in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign in',
};
}
},
/**
* Sign up with email and password
*/
async signUp(email: string, password: string): Promise<AuthResult> {
try {
const storage = getStorageAdapter();
const deviceAdapter = getDeviceAdapter();
const deviceInfo = await deviceAdapter.getDeviceInfo();
const response = await fetch(`${baseUrl}${endpoints.signUp}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
if (response.status === 409) {
return { success: false, error: 'Email already in use' };
} else if (response.status === 400) {
return { success: false, error: errorData.message || 'Invalid email or password' };
}
return { success: false, error: errorData.message || 'Sign up failed' };
}
const responseData = await response.json();
// Check if email verification is required
if (responseData.confirmationRequired) {
return { success: true, needsVerification: true };
}
const { appToken, refreshToken } = responseData;
if (appToken && refreshToken) {
await Promise.all([
storage.setItem(storageKeys.APP_TOKEN, appToken),
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
storage.setItem(storageKeys.USER_EMAIL, email),
]);
}
return { success: true };
} catch (error) {
console.error('Error signing up:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during sign up',
};
}
},
/**
* Sign out the current user
*/
async signOut(): Promise<void> {
try {
const storage = getStorageAdapter();
const refreshToken = await storage.getItem<string>(storageKeys.REFRESH_TOKEN);
if (refreshToken) {
await fetch(`${baseUrl}${endpoints.signOut}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken }),
}).catch((err) => console.error('Error logging out on server:', err));
}
await service.clearAuthStorage();
} catch (error) {
console.error('Error signing out:', error);
}
},
/**
* Send password reset email
*/
async forgotPassword(email: string): Promise<AuthResult> {
try {
const response = await fetch(`${baseUrl}${endpoints.forgotPassword}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email }),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.message?.includes('rate limit')) {
return { success: false, error: 'Too many attempts. Please wait before trying again.' };
}
return { success: false, error: errorData.message || 'Password reset failed' };
}
return { success: true };
} catch (error) {
console.error('Error sending password reset email:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during password reset',
};
}
},
/**
* Refresh the authentication tokens
*/
async refreshTokens(currentRefreshToken: string): Promise<TokenRefreshResult> {
const storage = getStorageAdapter();
const deviceAdapter = getDeviceAdapter();
// Check for device ID mismatch
const storedDeviceId = await deviceAdapter.getStoredDeviceId();
const deviceInfo = await deviceAdapter.getDeviceInfo();
if (storedDeviceId && deviceInfo.deviceId !== storedDeviceId) {
throw new Error('Device ID has changed. Please sign in again.');
}
const response = await fetch(`${baseUrl}${endpoints.refresh}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ refreshToken: currentRefreshToken, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
if (response.status === 401 && errorData.message === 'Invalid refresh token') {
throw new Error('Session expired. Please sign in again.');
}
throw new Error(errorData.message || 'Failed to refresh tokens');
}
const { appToken, refreshToken } = await response.json();
if (!appToken || !refreshToken) {
throw new Error('Invalid response from token refresh - missing tokens');
}
// Store new tokens
await storage.setItem(storageKeys.APP_TOKEN, appToken);
await storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken);
// Extract user data from new token
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
const userData = getUserFromToken(appToken, storedEmail || undefined);
// Notify callback if registered
if (userData && onTokenRefreshCallback) {
onTokenRefreshCallback(userData);
}
return { appToken, refreshToken, userData };
},
/**
* Sign in with Google
*/
async signInWithGoogle(idToken: string): Promise<AuthResult> {
return service.signInWithSocial(idToken, endpoints.googleSignIn);
},
/**
* Sign in with Apple
*/
async signInWithApple(identityToken: string): Promise<AuthResult> {
return service.signInWithSocial(identityToken, endpoints.appleSignIn);
},
/**
* Internal: Sign in with social provider
*/
async signInWithSocial(token: string, endpoint: string): Promise<AuthResult> {
try {
const storage = getStorageAdapter();
const deviceAdapter = getDeviceAdapter();
const deviceInfo = await deviceAdapter.getDeviceInfo();
const response = await fetch(`${baseUrl}${endpoint}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, deviceInfo }),
});
if (!response.ok) {
const errorData = await response.json();
return { success: false, error: errorData.message || 'Social sign in failed' };
}
const responseData = await response.json();
const { appToken, refreshToken } = responseData;
// Extract email from response or token
let email = responseData.email;
if (!email && appToken) {
const userData = getUserFromToken(appToken);
email = userData?.email;
}
// Store tokens
const storagePromises = [
storage.setItem(storageKeys.APP_TOKEN, appToken),
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
];
if (email) {
storagePromises.push(storage.setItem(storageKeys.USER_EMAIL, email));
}
await Promise.all(storagePromises);
return { success: true };
} catch (error) {
console.error('Error with social sign in:', error);
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error during social sign in',
};
}
},
/**
* Get the current app token
*/
async getAppToken(): Promise<string | null> {
try {
const storage = getStorageAdapter();
return await storage.getItem<string>(storageKeys.APP_TOKEN);
} catch (error) {
console.error('Error getting app token:', error);
return null;
}
},
/**
* Get the current refresh token
*/
async getRefreshToken(): Promise<string | null> {
try {
const storage = getStorageAdapter();
return await storage.getItem<string>(storageKeys.REFRESH_TOKEN);
} catch (error) {
console.debug('Error getting refresh token:', error);
return null;
}
},
/**
* Update stored tokens
*/
async updateTokens(appToken: string, refreshToken: string): Promise<void> {
const storage = getStorageAdapter();
await Promise.all([
storage.setItem(storageKeys.APP_TOKEN, appToken),
storage.setItem(storageKeys.REFRESH_TOKEN, refreshToken),
]);
// Notify callback
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
const userData = getUserFromToken(appToken, storedEmail || undefined);
if (userData && onTokenRefreshCallback) {
onTokenRefreshCallback(userData);
}
},
/**
* Get user from current token
*/
async getUserFromToken(): Promise<UserData | null> {
const storage = getStorageAdapter();
const appToken = await storage.getItem<string>(storageKeys.APP_TOKEN);
if (!appToken) return null;
const storedEmail = await storage.getItem<string>(storageKeys.USER_EMAIL);
return getUserFromToken(appToken, storedEmail || undefined);
},
/**
* Clear all authentication data
*/
async clearAuthStorage(): Promise<void> {
const storage = getStorageAdapter();
await Promise.all(
Object.values(storageKeys).map((key) => storage.removeItem(key))
);
},
/**
* Check if user is authenticated
*/
async isAuthenticated(): Promise<boolean> {
const appToken = await service.getAppToken();
if (!appToken) return false;
return isTokenValidLocally(appToken);
},
/**
* Check if token is valid locally
*/
isTokenValidLocally(token: string): boolean {
return isTokenValidLocally(token);
},
/**
* Decode token
*/
decodeToken(token: string) {
return decodeToken(token);
},
/**
* Get user credits
*/
async getUserCredits(): Promise<CreditBalance | null> {
try {
const appToken = await service.getAppToken();
if (!appToken) return null;
const response = await fetch(`${baseUrl}${endpoints.credits}`, {
method: 'GET',
headers: {
Authorization: `Bearer ${appToken}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error('Failed to fetch user credits');
}
const data = await response.json();
return {
credits: data.credits || 0,
maxCreditLimit: data.max_credit_limit || 1000,
userId: data.id || 'unknown',
};
} catch (error) {
console.error('Error fetching user credits:', error);
return null;
}
},
/**
* Check if user is B2B
*/
async isB2BUser(): Promise<boolean> {
const appToken = await service.getAppToken();
if (!appToken) return false;
return checkB2BUser(appToken);
},
/**
* Get B2B information
*/
async getB2BInfo(): Promise<B2BInfo | null> {
const appToken = await service.getAppToken();
if (!appToken) return null;
return getB2BInfoFromToken(appToken);
},
/**
* Check if RevenueCat should be disabled
*/
async shouldDisableRevenueCat(): Promise<boolean> {
const appToken = await service.getAppToken();
if (!appToken) return false;
return checkRevenueCat(appToken);
},
/**
* Get app settings from token
*/
async getAppSettings(): Promise<Record<string, unknown> | null> {
const appToken = await service.getAppToken();
if (!appToken) return null;
return getAppSettingsFromToken(appToken);
},
/**
* Set callback for token refresh events
*/
set onTokenRefresh(callback: ((userData: UserData) => void) | null) {
onTokenRefreshCallback = callback;
},
/**
* Get callback for token refresh events
*/
get onTokenRefresh(): ((userData: UserData) => void) | null {
return onTokenRefreshCallback;
},
/**
* Handle authentication errors
*/
handleAuthError(status: number, errorData: Record<string, unknown>): AuthResult {
if (status === 401) {
const isFirebaseUserNeedsReset =
String(errorData.message).includes('Firebase user detected') ||
String(errorData.message).includes('password reset required') ||
errorData.code === 'FIREBASE_USER_PASSWORD_RESET_REQUIRED';
if (isFirebaseUserNeedsReset) {
return { success: false, error: 'FIREBASE_USER_PASSWORD_RESET_REQUIRED' };
}
const isEmailNotConfirmed =
String(errorData.message).includes('Email not confirmed') ||
String(errorData.message).includes('Email not verified') ||
errorData.code === 'EMAIL_NOT_VERIFIED';
if (isEmailNotConfirmed) {
return { success: false, error: 'EMAIL_NOT_VERIFIED' };
}
return { success: false, error: 'INVALID_CREDENTIALS' };
} else if (status === 403) {
return { success: false, error: 'EMAIL_NOT_VERIFIED' };
}
return { success: false, error: String(errorData.message) || 'Authentication failed' };
},
/**
* Get the base URL
*/
getBaseUrl(): string {
return baseUrl;
},
/**
* Get storage keys
*/
getStorageKeys(): StorageKeys {
return storageKeys;
},
};
return service;
}
/**
* Type for the auth service instance
*/
export type AuthService = ReturnType<typeof createAuthService>;

View file

@ -0,0 +1,160 @@
import type { DecodedToken, UserData } from '../types';
/**
* Decode a JWT token payload
*/
export function decodeToken(token: string): DecodedToken | null {
try {
const parts = token.split('.');
if (parts.length !== 3) {
return null;
}
const base64Url = parts[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
// Add padding if needed
const padding = base64.length % 4;
const paddedBase64 = padding ? base64 + '='.repeat(4 - padding) : base64;
// Decode base64 - atob is available in browsers, Node.js 16+, and React Native
const payload: DecodedToken = JSON.parse(atob(paddedBase64));
return payload;
} catch (error) {
console.error('Error decoding JWT token:', error);
return null;
}
}
/**
* Check if a token is valid locally (not expired)
*/
export function isTokenValidLocally(token: string, bufferSeconds: number = 10): boolean {
try {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return false;
}
const bufferTime = bufferSeconds * 1000;
const expiryTime = payload.exp * 1000;
const currentTime = Date.now();
return currentTime < expiryTime - bufferTime;
} catch (error) {
console.debug('Error validating token locally:', error);
return false;
}
}
/**
* Check if a token is expired
*/
export function isTokenExpired(token: string): boolean {
return !isTokenValidLocally(token, 0);
}
/**
* Extract user data from a JWT token
*/
export function getUserFromToken(token: string, storedEmail?: string): UserData | null {
try {
const payload = decodeToken(token);
if (!payload) {
return null;
}
// Get email from various sources
let email = payload.email || '';
if (!email && payload.user_metadata?.email) {
email = payload.user_metadata.email;
}
if (!email && storedEmail) {
email = storedEmail;
}
return {
id: payload.sub,
email: email || 'user@example.com',
role: payload.role || 'user',
};
} catch (error) {
console.error('Error extracting user from token:', error);
return null;
}
}
/**
* Get token expiration time in milliseconds
*/
export function getTokenExpirationTime(token: string): number | null {
const payload = decodeToken(token);
if (!payload || !payload.exp) {
return null;
}
return payload.exp * 1000;
}
/**
* Get time until token expiration in milliseconds
*/
export function getTimeUntilExpiration(token: string): number {
const expirationTime = getTokenExpirationTime(token);
if (!expirationTime) {
return 0;
}
return Math.max(0, expirationTime - Date.now());
}
/**
* Check if user is B2B based on JWT claims
*/
export function isB2BUser(token: string): boolean {
const payload = decodeToken(token);
if (!payload) {
return false;
}
// Handle different types for is_b2b
return payload.is_b2b === true || payload.is_b2b === 'true' || payload.is_b2b === 1;
}
/**
* Get B2B information from JWT claims
*/
export function getB2BInfo(token: string): {
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
} | null {
const payload = decodeToken(token);
if (!payload?.app_settings?.b2b) {
return null;
}
const b2bSettings = payload.app_settings.b2b;
return {
disableRevenueCat: !!b2bSettings.disableRevenueCat,
organizationId: b2bSettings.organizationId,
plan: b2bSettings.plan,
role: b2bSettings.role,
};
}
/**
* Check if RevenueCat should be disabled for this token
*/
export function shouldDisableRevenueCat(token: string): boolean {
const b2bInfo = getB2BInfo(token);
return b2bInfo?.disableRevenueCat ?? false;
}
/**
* Get app settings from JWT claims
*/
export function getAppSettings(token: string): Record<string, unknown> | null {
const payload = decodeToken(token);
return payload?.app_settings || null;
}

View file

@ -0,0 +1,464 @@
import type {
TokenState,
TokenStateObserver,
QueuedRequest,
InternalTokenRefreshResult,
} from '../types';
import { TokenState as TokenStateEnum } from '../types';
import { isDeviceConnected, hasStableConnection } from '../adapters/network';
import type { AuthService } from './authService';
/**
* Configuration for the token manager
*/
export interface TokenManagerConfig {
maxQueueSize?: number;
queueTimeoutMs?: number;
maxRefreshAttempts?: number;
refreshCooldownMs?: number;
}
/**
* Create a token manager instance
*/
export function createTokenManager(authService: AuthService, config?: TokenManagerConfig) {
// Configuration
const MAX_QUEUE_SIZE = config?.maxQueueSize ?? 50;
const QUEUE_TIMEOUT_MS = config?.queueTimeoutMs ?? 30000;
const MAX_REFRESH_ATTEMPTS = config?.maxRefreshAttempts ?? 3;
const REFRESH_COOLDOWN_MS = config?.refreshCooldownMs ?? 5000;
// State
let state: TokenState = TokenStateEnum.IDLE;
let refreshPromise: Promise<InternalTokenRefreshResult> | null = null;
let requestQueue: QueuedRequest[] = [];
const observers = new Set<TokenStateObserver>();
let refreshAttempts = 0;
let lastRefreshTime = 0;
// Internal functions
function notifyObservers(newState: TokenState, token?: string): void {
observers.forEach((observer) => {
try {
observer(newState, token);
} catch (error) {
console.debug('Error in token state observer:', error);
}
});
}
function setState(newState: TokenState, token?: string): void {
if (state !== newState) {
console.debug(`TokenManager: State transition ${state} -> ${newState}`);
state = newState;
notifyObservers(newState, token);
}
}
function removeFromQueue(requestId: string): void {
const index = requestQueue.findIndex((item) => item.id === requestId);
if (index !== -1) {
requestQueue.splice(index, 1);
}
}
function isRecoverableError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const networkErrors = [
'network', 'Network', 'fetch', 'connection', 'timeout',
'Failed to fetch', 'NetworkError', 'TypeError', 'ERR_NETWORK',
'ERR_INTERNET_DISCONNECTED', 'ECONNREFUSED', 'ENOTFOUND',
'ETIMEDOUT', 'Unable to resolve host', 'Request failed',
];
const authErrors = [
'401', '403', 'Unauthorized', 'Forbidden', 'Invalid token',
'Token expired', 'jwt expired', 'jwt malformed',
];
const errorString = `${error.message} ${error.name}`.toLowerCase();
const isNetworkError = networkErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
const isAuthError = authErrors.some((keyword) =>
errorString.includes(keyword.toLowerCase())
);
return isNetworkError && !isAuthError;
}
async function handleRefreshFailure(): Promise<void> {
console.debug('TokenManager: Handling permanent refresh failure');
try {
await authService.clearAuthStorage();
setState(TokenStateEnum.EXPIRED);
} catch (error) {
console.debug('Error in handleRefreshFailure:', error);
}
}
async function performTokenRefresh(): Promise<InternalTokenRefreshResult> {
try {
console.debug('TokenManager: Starting token refresh');
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Device offline, skipping refresh');
const currentToken = await authService.getAppToken();
if (currentToken) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
}
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
const isStable = await hasStableConnection();
if (!isStable) {
console.debug('TokenManager: Connection not stable yet, will retry');
return { success: false, error: 'unstable_connection' };
}
const refreshToken = await authService.getRefreshToken();
if (!refreshToken) {
throw new Error('No refresh token available');
}
const refreshResult = await authService.refreshTokens(refreshToken);
const { appToken } = refreshResult;
console.debug('TokenManager: Token refresh successful');
return { success: true, token: appToken };
} catch (error) {
console.debug('TokenManager: Token refresh failed:', error);
const isRecoverable = isRecoverableError(error);
if (!isRecoverable) {
await handleRefreshFailure();
}
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown refresh error',
};
}
}
async function performTokenRefreshWithRetry(): Promise<InternalTokenRefreshResult> {
const retryDelays = [0, 1000, 2000, 5000];
let lastError: unknown = null;
for (let attempt = 0; attempt < retryDelays.length; attempt++) {
try {
if (retryDelays[attempt] > 0) {
console.debug(
`TokenManager: Retrying token refresh in ${retryDelays[attempt]}ms (attempt ${attempt + 1}/${retryDelays.length})`
);
await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
}
const result = await performTokenRefresh();
if (result.success) {
return result;
}
// Non-retryable errors
if (
result.error === 'invalid_token' ||
result.error === 'token_expired' ||
result.error?.includes('Device ID has changed')
) {
return result;
}
if (result.error === 'offline') {
return { success: false, error: 'offline', shouldPreserveAuth: true };
}
if (result.error === 'unstable_connection') {
await new Promise((resolve) => setTimeout(resolve, 2000));
}
lastError = new Error(result.error || 'Token refresh failed');
if (attempt === retryDelays.length - 1) break;
} catch (error) {
lastError = error;
const isRecoverable = isRecoverableError(error);
if (!isRecoverable || attempt === retryDelays.length - 1) {
break;
}
}
}
return {
success: false,
error: lastError instanceof Error ? lastError.message : 'All retry attempts failed',
};
}
async function processQueuedRequests(token: string): Promise<void> {
console.debug(`TokenManager: Processing ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
try {
const response = await retryRequestWithToken(request.input, request.init, token);
request.resolve(response);
} catch (error) {
request.reject(error);
}
}
}
async function rejectQueuedRequests(error: string): Promise<void> {
console.debug(`TokenManager: Rejecting ${requestQueue.length} queued requests`);
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error(error));
}
}
async function retryRequestWithToken(
input: RequestInfo | URL,
init: RequestInit | undefined,
token: string
): Promise<Response> {
const headers = new Headers(init?.headers || {});
headers.set('Authorization', `Bearer ${token}`);
return fetch(input, {
...init,
headers,
});
}
// Public API
const manager = {
/**
* Subscribe to token state changes
*/
subscribe(observer: TokenStateObserver): () => void {
observers.add(observer);
return () => observers.delete(observer);
},
/**
* Get current token state
*/
getState(): TokenState {
return state;
},
/**
* Get a valid token, refreshing if necessary
*/
async getValidToken(): Promise<string | null> {
const currentToken = await authService.getAppToken();
if (currentToken && authService.isTokenValidLocally(currentToken)) {
setState(TokenStateEnum.VALID, currentToken);
return currentToken;
}
if (!currentToken) {
console.debug('TokenManager: No token available, skipping refresh');
setState(TokenStateEnum.EXPIRED);
return null;
}
const isOnline = await isDeviceConnected();
if (!isOnline) {
console.debug('TokenManager: Token expired while offline');
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return refreshResult.token;
}
if (refreshResult.shouldPreserveAuth) {
setState(TokenStateEnum.EXPIRED_OFFLINE, currentToken);
return currentToken;
}
return null;
},
/**
* Handle 401 response
*/
async handle401Response(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
if (state === TokenStateEnum.REFRESHING && refreshPromise) {
return manager.queueRequest(input, init);
}
const refreshResult = await manager.refreshToken();
if (refreshResult.success && refreshResult.token) {
return retryRequestWithToken(input, init, refreshResult.token);
}
throw new Error(refreshResult.error || 'Token refresh failed');
},
/**
* Queue a request during token refresh
*/
async queueRequest(
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
return new Promise((resolve, reject) => {
if (requestQueue.length >= MAX_QUEUE_SIZE) {
reject(new Error('Request queue full'));
return;
}
const queueItem: QueuedRequest = {
id: Math.random().toString(36).substring(2, 11),
input,
init,
resolve,
reject,
timestamp: Date.now(),
};
requestQueue.push(queueItem);
setTimeout(() => {
removeFromQueue(queueItem.id);
reject(new Error('Queued request timeout'));
}, QUEUE_TIMEOUT_MS);
});
},
/**
* Refresh the authentication token
*/
async refreshToken(): Promise<InternalTokenRefreshResult> {
const now = Date.now();
if (now - lastRefreshTime < REFRESH_COOLDOWN_MS) {
return { success: false, error: 'Refresh cooldown active' };
}
if (refreshAttempts >= MAX_REFRESH_ATTEMPTS) {
await handleRefreshFailure();
return { success: false, error: 'Max refresh attempts reached' };
}
if (refreshPromise) {
return refreshPromise;
}
setState(TokenStateEnum.REFRESHING);
lastRefreshTime = now;
refreshPromise = performTokenRefreshWithRetry();
try {
const result = await refreshPromise;
if (result.success) {
refreshAttempts = 0;
setState(TokenStateEnum.VALID, result.token);
await processQueuedRequests(result.token!);
} else {
refreshAttempts++;
setState(TokenStateEnum.EXPIRED);
await rejectQueuedRequests(result.error || 'Token refresh failed');
}
return result;
} finally {
refreshPromise = null;
}
},
/**
* Reset the token manager state
*/
reset(): void {
state = TokenStateEnum.IDLE;
refreshPromise = null;
refreshAttempts = 0;
lastRefreshTime = 0;
const requests = [...requestQueue];
requestQueue = [];
for (const request of requests) {
request.reject(new Error('Token manager reset'));
}
},
/**
* Clear tokens and reset state
*/
async clearTokens(): Promise<void> {
try {
await authService.clearAuthStorage();
manager.reset();
} catch (error) {
console.debug('Error clearing tokens:', error);
manager.reset();
}
},
/**
* Get queue status for debugging
*/
getQueueStatus(): { size: number; state: TokenState; refreshAttempts: number } {
return {
size: requestQueue.length,
state,
refreshAttempts,
};
},
/**
* Check initial token state
*/
async checkInitialState(): Promise<void> {
try {
const token = await authService.getAppToken();
if (!token) {
setState(TokenStateEnum.EXPIRED);
return;
}
if (authService.isTokenValidLocally(token)) {
setState(TokenStateEnum.VALID, token);
} else {
setState(TokenStateEnum.EXPIRED);
}
} catch (error) {
console.debug('Error checking initial token state:', error);
setState(TokenStateEnum.EXPIRED);
}
},
};
// Initialize
manager.checkInitialState();
return manager;
}
/**
* Type for the token manager instance
*/
export type TokenManager = ReturnType<typeof createTokenManager>;

View file

@ -0,0 +1,99 @@
// Types
export * from './types';
// Core utilities
import { createAuthService as _createAuthService } from './core/authService';
export { createAuthService } from './core/authService';
export type { AuthService } from './core/authService';
import { createTokenManager as _createTokenManager } from './core/tokenManager';
export { createTokenManager } from './core/tokenManager';
export type { TokenManager, TokenManagerConfig } from './core/tokenManager';
export {
decodeToken,
isTokenValidLocally,
isTokenExpired,
getUserFromToken,
getTokenExpirationTime,
getTimeUntilExpiration,
isB2BUser,
getB2BInfo,
shouldDisableRevenueCat,
getAppSettings,
} from './core/jwtUtils';
// Storage adapter
import {
setStorageAdapter as _setStorageAdapter,
createLocalStorageAdapter as _createLocalStorageAdapter,
} from './adapters/storage';
export {
setStorageAdapter,
getStorageAdapter,
isStorageInitialized,
createLocalStorageAdapter,
createMemoryStorageAdapter,
} from './adapters/storage';
// Device adapter
import {
setDeviceAdapter as _setDeviceAdapter,
createWebDeviceAdapter as _createWebDeviceAdapter,
} from './adapters/device';
export {
setDeviceAdapter,
getDeviceAdapter,
isDeviceInitialized,
createWebDeviceAdapter,
} from './adapters/device';
// Network adapter
import {
setNetworkAdapter as _setNetworkAdapter,
createWebNetworkAdapter as _createWebNetworkAdapter,
} from './adapters/network';
export {
setNetworkAdapter,
getNetworkAdapter,
isDeviceConnected,
hasStableConnection,
createWebNetworkAdapter,
} from './adapters/network';
// Fetch interceptor
import { setupFetchInterceptor as _setupFetchInterceptor } from './interceptors/fetchInterceptor';
export {
setupFetchInterceptor,
setupTokenObservers,
getInterceptorStatus,
} from './interceptors/fetchInterceptor';
export type { FetchInterceptorConfig } from './interceptors/fetchInterceptor';
/**
* Initialize auth service with all adapters for web
*
* @example
* ```typescript
* import { initializeWebAuth } from '@manacore/shared-auth';
*
* const { authService, tokenManager } = initializeWebAuth({
* baseUrl: 'https://api.example.com',
* });
* ```
*/
export function initializeWebAuth(config: { baseUrl: string; storageKeys?: Partial<import('./types').StorageKeys> }) {
// Set up adapters
_setStorageAdapter(_createLocalStorageAdapter());
_setDeviceAdapter(_createWebDeviceAdapter());
_setNetworkAdapter(_createWebNetworkAdapter());
// Create services
const authService = _createAuthService(config);
const tokenManager = _createTokenManager(authService);
// Set up interceptor
_setupFetchInterceptor(authService, tokenManager);
return { authService, tokenManager };
}

View file

@ -0,0 +1,220 @@
import type { TokenManager } from '../core/tokenManager';
import type { AuthService } from '../core/authService';
import { TokenState } from '../types';
/**
* Configuration for the fetch interceptor
*/
export interface FetchInterceptorConfig {
/**
* Patterns to skip (won't be intercepted)
*/
skipPatterns?: string[];
/**
* Backend URL to match (only intercept requests to this URL)
*/
backendUrl?: string;
}
/**
* Default patterns to skip
*/
const DEFAULT_SKIP_PATTERNS = [
// Auth endpoints
'/auth/signin',
'/auth/signup',
'/auth/refresh',
'/auth/forgot-password',
'/auth/reset-password',
'/auth/verify',
'/auth/logout',
// Public endpoints
'/health',
'/ping',
'/status',
'/version',
'/public/',
// Storage endpoints
'.supabase.co/storage/',
'/storage/v1/',
// External APIs
'googleapis.com',
'firebase.com',
'firebaseapp.com',
'replicate.com',
'openai.com',
'anthropic.com',
];
/**
* Setup a global fetch interceptor for automatic token handling
*/
export function setupFetchInterceptor(
authService: AuthService,
tokenManager: TokenManager,
config?: FetchInterceptorConfig
): void {
if (typeof globalThis === 'undefined' || !globalThis.fetch) {
console.warn('FetchInterceptor: globalThis.fetch not available');
return;
}
const originalFetch = globalThis.fetch;
const skipPatterns = [...DEFAULT_SKIP_PATTERNS, ...(config?.skipPatterns || [])];
const backendUrl = config?.backendUrl || authService.getBaseUrl();
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = extractUrl(input);
// Skip intercepting if URL doesn't match criteria
if (shouldSkipInterception(url, skipPatterns, backendUrl)) {
return originalFetch(input, init);
}
console.debug('Fetch interceptor: Intercepting URL:', url);
try {
// Make request with current token
const response = await makeRequestWithToken(originalFetch, authService, input, init);
// Handle 401 responses
if (response.status === 401) {
const responseData = await response.clone().json().catch(() => ({}));
console.debug('Fetch interceptor: Received 401 response:', responseData);
if (isTokenExpiredResponse(responseData)) {
console.debug('Fetch interceptor: Token expired, delegating to TokenManager');
return tokenManager.handle401Response(input, init);
}
}
return response;
} catch (error) {
console.debug('Error in global fetch interceptor:', error);
return originalFetch(input, init);
}
}) as typeof fetch;
}
/**
* Setup token state observers for integrations (e.g., Supabase)
*/
export function setupTokenObservers(
tokenManager: TokenManager,
onValid?: (token: string) => void | Promise<void>,
onExpired?: () => void | Promise<void>
): () => void {
return tokenManager.subscribe(async (state, token) => {
try {
if (state === TokenState.VALID && token && onValid) {
await onValid(token);
} else if (state === TokenState.EXPIRED && onExpired) {
await onExpired();
}
} catch (error) {
console.debug('Error in token observer:', error);
}
});
}
/**
* Extract URL from various input types
*/
function extractUrl(input: RequestInfo | URL): string {
if (typeof input === 'string') {
return input;
} else if (input instanceof URL) {
return input.toString();
} else if (input instanceof Request) {
return input.url;
}
return '';
}
/**
* Check if request should skip interception
*/
function shouldSkipInterception(
url: string,
skipPatterns: string[],
backendUrl: string
): boolean {
if (!url) return true;
const lowerUrl = url.toLowerCase();
// Check skip patterns
if (skipPatterns.some((pattern) => lowerUrl.includes(pattern.toLowerCase()))) {
return true;
}
// Check if URL matches backend
const backendDomain = backendUrl
.replace(/https?:\/\//, '')
.replace(/:\d+$/, '')
.toLowerCase();
if (!lowerUrl.includes(backendDomain)) {
return true;
}
return false;
}
/**
* Make a request with the current token
*/
async function makeRequestWithToken(
originalFetch: typeof fetch,
authService: AuthService,
input: RequestInfo | URL,
init?: RequestInit
): Promise<Response> {
const token = await authService.getAppToken();
const requestInit: RequestInit = {
method: init?.method || 'GET',
...init,
};
if (token) {
const headers = new Headers(requestInit.headers || {});
headers.set('Authorization', `Bearer ${token}`);
requestInit.headers = headers;
}
return originalFetch(input, requestInit);
}
/**
* Check if response indicates token expiration
*/
function isTokenExpiredResponse(responseData: Record<string, unknown>): boolean {
const error = responseData.error as Record<string, unknown> | undefined;
const errorMessage = String(error?.message || responseData.message || responseData.error || '');
const errorCode = String(responseData.code || error?.code || '');
return (
errorMessage === 'JWT expired' ||
errorCode === 'PGRST301' ||
errorMessage === 'Unauthorized'
);
}
/**
* Get interceptor status for debugging
*/
export function getInterceptorStatus(
authService: AuthService,
tokenManager: TokenManager
): {
isSetup: boolean;
backendUrl: string;
tokenManager: { size: number; state: string; refreshAttempts: number };
} {
return {
isSetup: typeof globalThis !== 'undefined' && globalThis.fetch !== undefined,
backendUrl: authService.getBaseUrl(),
tokenManager: tokenManager.getQueueStatus(),
};
}

View file

@ -0,0 +1,178 @@
/**
* Storage keys for authentication data
*/
export interface StorageKeys {
APP_TOKEN: string;
REFRESH_TOKEN: string;
USER_EMAIL: string;
}
/**
* Device information for multi-device support
*/
export interface DeviceInfo {
deviceId: string;
deviceName: string;
deviceType: string;
platform?: string;
}
/**
* Decoded JWT token payload
*/
export interface DecodedToken {
sub: string;
email?: string;
role?: string;
exp: number;
iat: number;
aud?: string;
app_id?: string;
is_b2b?: boolean | string | number;
subscription_plan_id?: string;
user_metadata?: {
email?: string;
};
app_settings?: {
b2b?: {
disableRevenueCat?: boolean;
organizationId?: string;
plan?: string;
role?: string;
};
};
}
/**
* User data extracted from token
*/
export interface UserData {
id: string;
email: string;
role: string;
}
/**
* Authentication result from sign in/up
*/
export interface AuthResult {
success: boolean;
error?: string;
needsVerification?: boolean;
}
/**
* Token refresh result
*/
export interface TokenRefreshResult {
appToken: string;
refreshToken: string;
userData?: UserData | null;
}
/**
* Token state for the token manager
*/
export enum TokenState {
IDLE = 'idle',
REFRESHING = 'refreshing',
EXPIRED = 'expired',
EXPIRED_OFFLINE = 'expired_offline',
VALID = 'valid',
}
/**
* Token state observer callback
*/
export type TokenStateObserver = (state: TokenState, token?: string) => void;
/**
* Queued request item during token refresh
*/
export interface QueuedRequest {
id: string;
input: RequestInfo | URL;
init?: RequestInit;
resolve: (value: Response) => void;
reject: (reason?: unknown) => void;
timestamp: number;
}
/**
* Internal token refresh result
*/
export interface InternalTokenRefreshResult {
success: boolean;
token?: string;
error?: string;
shouldPreserveAuth?: boolean;
shouldRetry?: boolean;
}
/**
* Configuration for the auth service
*/
export interface AuthServiceConfig {
baseUrl: string;
storageKeys?: Partial<StorageKeys>;
endpoints?: Partial<AuthEndpoints>;
}
/**
* Auth API endpoints
*/
export interface AuthEndpoints {
signIn: string;
signUp: string;
signOut: string;
refresh: string;
validate: string;
forgotPassword: string;
googleSignIn: string;
appleSignIn: string;
credits: string;
}
/**
* Storage adapter interface
*/
export interface StorageAdapter {
getItem<T = string>(key: string): Promise<T | null>;
setItem(key: string, value: string): Promise<void>;
removeItem(key: string): Promise<void>;
}
/**
* Device manager adapter interface
*/
export interface DeviceManagerAdapter {
getDeviceInfo(): Promise<DeviceInfo>;
getStoredDeviceId(): Promise<string | null>;
}
/**
* Network utilities adapter interface
*/
export interface NetworkAdapter {
isDeviceConnected(): Promise<boolean>;
hasStableConnection?(): Promise<boolean>;
}
/**
* Credit balance response
*/
export interface CreditBalance {
credits: number;
maxCreditLimit: number;
userId: string;
}
/**
* B2B information from JWT claims
*/
export interface B2BInfo {
disableRevenueCat: boolean;
organizationId?: string;
plan?: string;
role?: string;
}

View file

@ -0,0 +1,18 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -0,0 +1,23 @@
{
"name": "@manacore/shared-config",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./env": "./src/env.ts",
"./api": "./src/api.ts",
"./features": "./src/features.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"dependencies": {
"zod": "^3.24.0"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,207 @@
/**
* API endpoint construction utilities
*/
/**
* API configuration
*/
export interface ApiConfig {
/** Base URL for the API */
baseUrl: string;
/** API version prefix (e.g., 'v1') */
version?: string;
/** Default timeout in milliseconds */
timeout?: number;
/** Default headers */
headers?: Record<string, string>;
}
/**
* Create API endpoint URL builder
*/
export function createApiBuilder(config: ApiConfig) {
const { baseUrl, version } = config;
// Remove trailing slash from base URL
const base = baseUrl.replace(/\/$/, '');
// Build base path with optional version
const basePath = version ? `${base}/${version}` : base;
return {
/**
* Build endpoint URL from path segments
*/
endpoint(...segments: (string | number)[]): string {
const path = segments
.map(String)
.map(s => s.replace(/^\/+|\/+$/g, '')) // Remove leading/trailing slashes
.filter(Boolean)
.join('/');
return `${basePath}/${path}`;
},
/**
* Build endpoint URL with query parameters
*/
endpointWithQuery(
path: string | string[],
params?: Record<string, string | number | boolean | undefined>
): string {
const segments = Array.isArray(path) ? path : [path];
const url = this.endpoint(...segments);
if (!params) {
return url;
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
},
/**
* Get the base URL
*/
getBaseUrl(): string {
return basePath;
},
/**
* Get the config
*/
getConfig(): ApiConfig {
return config;
},
};
}
/**
* Build URL with query parameters
*/
export function buildUrl(
baseUrl: string,
path: string,
params?: Record<string, string | number | boolean | undefined>
): string {
// Ensure single slash between base and path
const base = baseUrl.replace(/\/$/, '');
const cleanPath = path.replace(/^\//, '');
const url = `${base}/${cleanPath}`;
if (!params) {
return url;
}
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value !== undefined) {
searchParams.append(key, String(value));
}
}
const queryString = searchParams.toString();
return queryString ? `${url}?${queryString}` : url;
}
/**
* Parse URL and extract components
*/
export function parseUrl(url: string): {
protocol: string;
host: string;
port: string;
pathname: string;
search: string;
params: Record<string, string>;
} {
const urlObj = new URL(url);
const params: Record<string, string> = {};
urlObj.searchParams.forEach((value, key) => {
params[key] = value;
});
return {
protocol: urlObj.protocol.replace(':', ''),
host: urlObj.hostname,
port: urlObj.port,
pathname: urlObj.pathname,
search: urlObj.search,
params,
};
}
/**
* Join URL path segments
*/
export function joinPath(...segments: string[]): string {
return segments
.map(s => s.replace(/^\/+|\/+$/g, ''))
.filter(Boolean)
.join('/');
}
/**
* Common HTTP methods
*/
export const HTTP_METHODS = {
GET: 'GET',
POST: 'POST',
PUT: 'PUT',
PATCH: 'PATCH',
DELETE: 'DELETE',
HEAD: 'HEAD',
OPTIONS: 'OPTIONS',
} as const;
export type HttpMethod = typeof HTTP_METHODS[keyof typeof HTTP_METHODS];
/**
* Common HTTP status codes
*/
export const HTTP_STATUS = {
OK: 200,
CREATED: 201,
NO_CONTENT: 204,
BAD_REQUEST: 400,
UNAUTHORIZED: 401,
FORBIDDEN: 403,
NOT_FOUND: 404,
CONFLICT: 409,
UNPROCESSABLE_ENTITY: 422,
TOO_MANY_REQUESTS: 429,
INTERNAL_SERVER_ERROR: 500,
BAD_GATEWAY: 502,
SERVICE_UNAVAILABLE: 503,
} as const;
export type HttpStatus = typeof HTTP_STATUS[keyof typeof HTTP_STATUS];
/**
* Check if status code is successful (2xx)
*/
export function isSuccessStatus(status: number): boolean {
return status >= 200 && status < 300;
}
/**
* Check if status code is client error (4xx)
*/
export function isClientError(status: number): boolean {
return status >= 400 && status < 500;
}
/**
* Check if status code is server error (5xx)
*/
export function isServerError(status: number): boolean {
return status >= 500 && status < 600;
}

View file

@ -0,0 +1,173 @@
/**
* Environment variable validation utilities
*/
import { z } from 'zod';
/**
* Common environment variable schemas
*/
export const envSchemas = {
/** URL schema */
url: z.string().url(),
/** Non-empty string schema */
nonEmpty: z.string().min(1),
/** Optional string schema */
optional: z.string().optional(),
/** Port number schema */
port: z.coerce.number().int().min(1).max(65535),
/** Boolean schema (accepts various formats) */
boolean: z.preprocess(
(val) => {
if (typeof val === 'boolean') return val;
if (typeof val === 'string') {
return ['true', '1', 'yes', 'on'].includes(val.toLowerCase());
}
return false;
},
z.boolean()
),
/** Number schema */
number: z.coerce.number(),
/** Positive integer schema */
positiveInt: z.coerce.number().int().positive(),
/** Email schema */
email: z.string().email(),
/** Node environment schema */
nodeEnv: z.enum(['development', 'production', 'test']).default('development'),
};
/**
* Common Supabase environment schema
*/
export const supabaseEnvSchema = z.object({
SUPABASE_URL: envSchemas.url,
SUPABASE_ANON_KEY: envSchemas.nonEmpty,
SUPABASE_SERVICE_ROLE_KEY: envSchemas.nonEmpty.optional(),
});
/**
* Common app environment schema
*/
export const appEnvSchema = z.object({
NODE_ENV: envSchemas.nodeEnv,
PORT: envSchemas.port.default(3000),
});
/**
* Create an environment config from schema
*/
export function createEnvConfig<T extends z.ZodTypeAny>(
schema: T,
env: NodeJS.ProcessEnv = process.env
): z.infer<T> {
const result = schema.safeParse(env);
if (!result.success) {
const errors = result.error.errors
.map((err) => ` ${err.path.join('.')}: ${err.message}`)
.join('\n');
throw new Error(`Environment validation failed:\n${errors}`);
}
return result.data;
}
/**
* Validate environment variables with custom schema
*/
export function validateEnv<T extends z.ZodRawShape>(
schema: z.ZodObject<T>,
env: NodeJS.ProcessEnv = process.env
): z.infer<z.ZodObject<T>> {
return createEnvConfig(schema, env);
}
/**
* Get required environment variable with type safety
*/
export function getRequiredEnv(key: string, env: NodeJS.ProcessEnv = process.env): string {
const value = env[key];
if (value === undefined || value === '') {
throw new Error(`Required environment variable "${key}" is not set`);
}
return value;
}
/**
* Get optional environment variable with default
*/
export function getEnv(
key: string,
defaultValue: string,
env: NodeJS.ProcessEnv = process.env
): string {
return env[key] ?? defaultValue;
}
/**
* Get boolean environment variable
*/
export function getBoolEnv(
key: string,
defaultValue: boolean = false,
env: NodeJS.ProcessEnv = process.env
): boolean {
const value = env[key];
if (value === undefined) {
return defaultValue;
}
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
}
/**
* Get number environment variable
*/
export function getNumEnv(
key: string,
defaultValue: number,
env: NodeJS.ProcessEnv = process.env
): number {
const value = env[key];
if (value === undefined) {
return defaultValue;
}
const parsed = Number(value);
return isNaN(parsed) ? defaultValue : parsed;
}
/**
* Check if running in development
*/
export function isDevelopment(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'development';
}
/**
* Check if running in production
*/
export function isProduction(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'production';
}
/**
* Check if running in test
*/
export function isTest(env: NodeJS.ProcessEnv = process.env): boolean {
return env.NODE_ENV === 'test';
}

View file

@ -0,0 +1,173 @@
/**
* Feature flag utilities
*/
/**
* Feature flag configuration
*/
export interface FeatureFlag {
/** Feature key */
key: string;
/** Default enabled state */
defaultEnabled: boolean;
/** Description */
description?: string;
/** Environment variable to override */
envVar?: string;
}
/**
* Create a feature flag manager
*/
export function createFeatureFlags<T extends Record<string, FeatureFlag>>(
flags: T,
env: NodeJS.ProcessEnv = process.env
) {
type FlagKey = keyof T;
/**
* Check if a feature is enabled
*/
function isEnabled(key: FlagKey): boolean {
const flag = flags[key];
if (!flag) {
return false;
}
// Check environment variable override
if (flag.envVar) {
const envValue = env[flag.envVar];
if (envValue !== undefined) {
return ['true', '1', 'yes', 'on'].includes(envValue.toLowerCase());
}
}
// Check generic feature flag env var
const genericEnvVar = `FEATURE_${String(key).toUpperCase()}`;
const genericValue = env[genericEnvVar];
if (genericValue !== undefined) {
return ['true', '1', 'yes', 'on'].includes(genericValue.toLowerCase());
}
return flag.defaultEnabled;
}
/**
* Get all enabled features
*/
function getEnabledFeatures(): FlagKey[] {
return (Object.keys(flags) as FlagKey[]).filter(isEnabled);
}
/**
* Get all disabled features
*/
function getDisabledFeatures(): FlagKey[] {
return (Object.keys(flags) as FlagKey[]).filter(key => !isEnabled(key));
}
/**
* Get feature configuration
*/
function getFlag(key: FlagKey): FeatureFlag | undefined {
return flags[key];
}
/**
* Get all flags with their current state
*/
function getAllFlags(): Record<string, boolean> {
const result: Record<string, boolean> = {};
for (const key of Object.keys(flags) as FlagKey[]) {
result[String(key)] = isEnabled(key);
}
return result;
}
return {
isEnabled,
getEnabledFeatures,
getDisabledFeatures,
getFlag,
getAllFlags,
};
}
/**
* Simple feature check using environment variable
*/
export function isFeatureEnabled(
featureName: string,
defaultValue: boolean = false,
env: NodeJS.ProcessEnv = process.env
): boolean {
const envVar = `FEATURE_${featureName.toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
const value = env[envVar];
if (value === undefined) {
return defaultValue;
}
return ['true', '1', 'yes', 'on'].includes(value.toLowerCase());
}
/**
* App metadata configuration
*/
export interface AppMetadata {
/** App name */
name: string;
/** App version */
version: string;
/** App description */
description?: string;
/** Build number */
buildNumber?: string;
/** Git commit hash */
commitHash?: string;
/** Build timestamp */
buildTime?: string;
/** Environment */
environment?: string;
}
/**
* Create app metadata from environment
*/
export function createAppMetadata(
config: {
name: string;
version: string;
description?: string;
},
env: NodeJS.ProcessEnv = process.env
): AppMetadata {
return {
name: config.name,
version: config.version,
description: config.description,
buildNumber: env.BUILD_NUMBER || env.VITE_BUILD_NUMBER,
commitHash: env.COMMIT_HASH || env.VITE_COMMIT_HASH || env.GIT_COMMIT,
buildTime: env.BUILD_TIME || env.VITE_BUILD_TIME,
environment: env.NODE_ENV || 'development',
};
}
/**
* Format version string with build info
*/
export function formatVersion(metadata: AppMetadata): string {
let version = metadata.version;
if (metadata.buildNumber) {
version += ` (${metadata.buildNumber})`;
}
if (metadata.commitHash) {
const shortHash = metadata.commitHash.substring(0, 7);
version += ` [${shortHash}]`;
}
return version;
}

View file

@ -0,0 +1,48 @@
/**
* Shared configuration utilities for Manacore monorepo
*
* This package provides common configuration utilities including
* environment validation, API endpoint construction, and feature flags.
*/
// Environment utilities
export {
envSchemas,
supabaseEnvSchema,
appEnvSchema,
createEnvConfig,
validateEnv,
getRequiredEnv,
getEnv,
getBoolEnv,
getNumEnv,
isDevelopment,
isProduction,
isTest,
} from './env';
// API utilities
export {
type ApiConfig,
createApiBuilder,
buildUrl,
parseUrl,
joinPath,
HTTP_METHODS,
HTTP_STATUS,
type HttpMethod,
type HttpStatus,
isSuccessStatus,
isClientError,
isServerError,
} from './api';
// Feature flag utilities
export {
type FeatureFlag,
createFeatureFlags,
isFeatureEnabled,
type AppMetadata,
createAppMetadata,
formatVersion,
} from './features';

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,20 @@
{
"name": "@manacore/shared-i18n",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./languages": "./src/languages.ts",
"./utils": "./src/utils.ts",
"./translations/common": "./src/translations/common/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,46 @@
/**
* Shared i18n for Manacore monorepo
*
* This package provides common i18n utilities, language definitions,
* and translations that can be shared across all projects.
*/
// Language definitions
export {
type LanguageCode,
type LanguageInfo,
LANGUAGES,
getLanguageCodes,
getLanguageInfo,
isLanguageSupported,
isRTL,
getLanguageDisplayName,
LOCALE_GROUPS,
getLanguagesByGroup,
} from './languages';
// Utilities
export {
detectBrowserLocale,
getStoredLocale,
storeLocale,
getInitialLocale,
normalizeLocale,
getBaseLanguage,
matchesLanguage,
findBestMatch,
formatLocalizedNumber,
formatLocalizedDate,
formatRelativeTime,
getPluralCategory,
interpolate,
} from './utils';
// Common translations
export {
en as commonTranslationsEn,
de as commonTranslationsDe,
type CommonTranslations,
getCommonTranslations,
mergeWithCommon,
} from './translations/common';

View file

@ -0,0 +1,159 @@
/**
* Language definitions and metadata
*/
/**
* Supported language codes
*/
export type LanguageCode =
| 'en' | 'de' | 'fr' | 'es' | 'it' | 'pt' | 'nl' | 'pl' | 'ru' | 'ja'
| 'ko' | 'zh' | 'ar' | 'hi' | 'bn' | 'ur' | 'id' | 'fa' | 'vi' | 'th'
| 'tr' | 'uk' | 'cs' | 'da' | 'fi' | 'sv' | 'nb' | 'el' | 'hu' | 'ro'
| 'bg' | 'hr' | 'sk' | 'sl' | 'sr' | 'lt' | 'lv' | 'et' | 'mt' | 'ga'
| 'tl' | 'ms' | 'he' | 'af' | 'pt-BR' | 'es-MX';
/**
* Language metadata
*/
export interface LanguageInfo {
/** Native name of the language */
nativeName: string;
/** English name of the language */
englishName: string;
/** Flag emoji */
emoji: string;
/** RTL language */
rtl?: boolean;
}
/**
* Complete language definitions
*/
export const LANGUAGES: Record<LanguageCode, LanguageInfo> = {
// Major languages
en: { nativeName: 'English', englishName: 'English', emoji: '🇬🇧' },
de: { nativeName: 'Deutsch', englishName: 'German', emoji: '🇩🇪' },
fr: { nativeName: 'Français', englishName: 'French', emoji: '🇫🇷' },
es: { nativeName: 'Español', englishName: 'Spanish', emoji: '🇪🇸' },
it: { nativeName: 'Italiano', englishName: 'Italian', emoji: '🇮🇹' },
pt: { nativeName: 'Português', englishName: 'Portuguese', emoji: '🇵🇹' },
nl: { nativeName: 'Nederlands', englishName: 'Dutch', emoji: '🇳🇱' },
pl: { nativeName: 'Polski', englishName: 'Polish', emoji: '🇵🇱' },
ru: { nativeName: 'Русский', englishName: 'Russian', emoji: '🇷🇺' },
// Asian languages
ja: { nativeName: '日本語', englishName: 'Japanese', emoji: '🇯🇵' },
ko: { nativeName: '한국어', englishName: 'Korean', emoji: '🇰🇷' },
zh: { nativeName: '中文', englishName: 'Chinese', emoji: '🇨🇳' },
vi: { nativeName: 'Tiếng Việt', englishName: 'Vietnamese', emoji: '🇻🇳' },
th: { nativeName: 'ไทย', englishName: 'Thai', emoji: '🇹🇭' },
id: { nativeName: 'Bahasa Indonesia', englishName: 'Indonesian', emoji: '🇮🇩' },
ms: { nativeName: 'Bahasa Melayu', englishName: 'Malay', emoji: '🇲🇾' },
tl: { nativeName: 'Filipino', englishName: 'Filipino', emoji: '🇵🇭' },
// South Asian languages
hi: { nativeName: 'हिन्दी', englishName: 'Hindi', emoji: '🇮🇳' },
bn: { nativeName: 'বাংলা', englishName: 'Bengali', emoji: '🇧🇩' },
ur: { nativeName: 'اردو', englishName: 'Urdu', emoji: '🇵🇰', rtl: true },
// Middle Eastern languages
ar: { nativeName: 'العربية', englishName: 'Arabic', emoji: '🇦🇪', rtl: true },
fa: { nativeName: 'فارسی', englishName: 'Persian', emoji: '🇮🇷', rtl: true },
he: { nativeName: 'עברית', englishName: 'Hebrew', emoji: '🇮🇱', rtl: true },
tr: { nativeName: 'Türkçe', englishName: 'Turkish', emoji: '🇹🇷' },
// Nordic languages
sv: { nativeName: 'Svenska', englishName: 'Swedish', emoji: '🇸🇪' },
da: { nativeName: 'Dansk', englishName: 'Danish', emoji: '🇩🇰' },
fi: { nativeName: 'Suomi', englishName: 'Finnish', emoji: '🇫🇮' },
nb: { nativeName: 'Norsk', englishName: 'Norwegian', emoji: '🇳🇴' },
// Eastern European languages
uk: { nativeName: 'Українська', englishName: 'Ukrainian', emoji: '🇺🇦' },
cs: { nativeName: 'Čeština', englishName: 'Czech', emoji: '🇨🇿' },
hu: { nativeName: 'Magyar', englishName: 'Hungarian', emoji: '🇭🇺' },
ro: { nativeName: 'Română', englishName: 'Romanian', emoji: '🇷🇴' },
bg: { nativeName: 'Български', englishName: 'Bulgarian', emoji: '🇧🇬' },
hr: { nativeName: 'Hrvatski', englishName: 'Croatian', emoji: '🇭🇷' },
sk: { nativeName: 'Slovenčina', englishName: 'Slovak', emoji: '🇸🇰' },
sl: { nativeName: 'Slovenščina', englishName: 'Slovenian', emoji: '🇸🇮' },
sr: { nativeName: 'Српски', englishName: 'Serbian', emoji: '🇷🇸' },
// Baltic languages
lt: { nativeName: 'Lietuvių', englishName: 'Lithuanian', emoji: '🇱🇹' },
lv: { nativeName: 'Latviešu', englishName: 'Latvian', emoji: '🇱🇻' },
et: { nativeName: 'Eesti', englishName: 'Estonian', emoji: '🇪🇪' },
// Other European languages
el: { nativeName: 'Ελληνικά', englishName: 'Greek', emoji: '🇬🇷' },
mt: { nativeName: 'Malti', englishName: 'Maltese', emoji: '🇲🇹' },
ga: { nativeName: 'Gaeilge', englishName: 'Irish', emoji: '🇮🇪' },
// African languages
af: { nativeName: 'Afrikaans', englishName: 'Afrikaans', emoji: '🇿🇦' },
// Regional variants
'pt-BR': { nativeName: 'Português (Brasil)', englishName: 'Portuguese (Brazil)', emoji: '🇧🇷' },
'es-MX': { nativeName: 'Español (México)', englishName: 'Spanish (Mexico)', emoji: '🇲🇽' },
};
/**
* Get list of all language codes
*/
export function getLanguageCodes(): LanguageCode[] {
return Object.keys(LANGUAGES) as LanguageCode[];
}
/**
* Get language info by code
*/
export function getLanguageInfo(code: string): LanguageInfo | undefined {
return LANGUAGES[code as LanguageCode];
}
/**
* Check if a language code is supported
*/
export function isLanguageSupported(code: string): code is LanguageCode {
return code in LANGUAGES;
}
/**
* Check if a language is RTL
*/
export function isRTL(code: string): boolean {
const info = LANGUAGES[code as LanguageCode];
return info?.rtl === true;
}
/**
* Get display name for a language (native name with emoji)
*/
export function getLanguageDisplayName(code: string): string {
const info = LANGUAGES[code as LanguageCode];
if (!info) return code;
return `${info.emoji} ${info.nativeName}`;
}
/**
* Common locale groups for filtering
*/
export const LOCALE_GROUPS = {
/** European Union official languages */
eu: ['en', 'de', 'fr', 'es', 'it', 'pt', 'nl', 'pl', 'cs', 'da', 'fi', 'sv', 'el', 'hu', 'ro', 'bg', 'hr', 'sk', 'sl', 'lt', 'lv', 'et', 'mt', 'ga'] as LanguageCode[],
/** Major world languages */
major: ['en', 'de', 'fr', 'es', 'it', 'pt', 'ru', 'ja', 'ko', 'zh', 'ar'] as LanguageCode[],
/** DACH region (German-speaking) */
dach: ['de'] as LanguageCode[],
/** Nordic countries */
nordic: ['sv', 'da', 'fi', 'nb'] as LanguageCode[],
/** RTL languages */
rtl: ['ar', 'fa', 'he', 'ur'] as LanguageCode[],
};
/**
* Get languages by group
*/
export function getLanguagesByGroup(group: keyof typeof LOCALE_GROUPS): LanguageCode[] {
return LOCALE_GROUPS[group];
}

View file

@ -0,0 +1,172 @@
{
"common": {
"actions": {
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"edit": "Bearbeiten",
"create": "Erstellen",
"update": "Aktualisieren",
"close": "Schließen",
"confirm": "Bestätigen",
"submit": "Absenden",
"back": "Zurück",
"next": "Weiter",
"done": "Fertig",
"retry": "Erneut versuchen",
"refresh": "Aktualisieren",
"search": "Suchen",
"filter": "Filtern",
"sort": "Sortieren",
"share": "Teilen",
"copy": "Kopieren",
"download": "Herunterladen",
"upload": "Hochladen",
"select": "Auswählen",
"clear": "Leeren",
"reset": "Zurücksetzen",
"apply": "Anwenden",
"continue": "Fortfahren",
"skip": "Überspringen",
"yes": "Ja",
"no": "Nein",
"ok": "OK"
},
"labels": {
"loading": "Lädt...",
"saving": "Speichert...",
"deleting": "Löscht...",
"processing": "Verarbeitet...",
"uploading": "Lädt hoch...",
"downloading": "Lädt herunter...",
"searching": "Sucht...",
"noResults": "Keine Ergebnisse gefunden",
"noData": "Keine Daten verfügbar",
"empty": "Leer",
"all": "Alle",
"none": "Keine",
"other": "Andere",
"more": "Mehr",
"less": "Weniger",
"showMore": "Mehr anzeigen",
"showLess": "Weniger anzeigen",
"viewAll": "Alle anzeigen",
"required": "Erforderlich",
"optional": "Optional",
"new": "Neu",
"recent": "Aktuell",
"popular": "Beliebt",
"featured": "Empfohlen"
},
"time": {
"now": "Jetzt",
"today": "Heute",
"yesterday": "Gestern",
"tomorrow": "Morgen",
"thisWeek": "Diese Woche",
"lastWeek": "Letzte Woche",
"thisMonth": "Diesen Monat",
"lastMonth": "Letzten Monat",
"thisYear": "Dieses Jahr",
"ago": "vor",
"in": "in"
},
"status": {
"active": "Aktiv",
"inactive": "Inaktiv",
"pending": "Ausstehend",
"completed": "Abgeschlossen",
"failed": "Fehlgeschlagen",
"cancelled": "Abgebrochen",
"success": "Erfolg",
"error": "Fehler",
"warning": "Warnung",
"info": "Info"
}
},
"errors": {
"generic": "Etwas ist schief gelaufen. Bitte versuche es erneut.",
"network": "Netzwerkfehler. Bitte überprüfe deine Verbindung.",
"timeout": "Zeitüberschreitung. Bitte versuche es erneut.",
"notFound": "Das angeforderte Element wurde nicht gefunden.",
"unauthorized": "Du bist nicht berechtigt, diese Aktion durchzuführen.",
"forbidden": "Zugriff verweigert.",
"serverError": "Serverfehler. Bitte versuche es später erneut.",
"validation": "Bitte überprüfe deine Eingabe und versuche es erneut.",
"unknown": "Ein unbekannter Fehler ist aufgetreten.",
"offline": "Du bist offline. Bitte überprüfe deine Internetverbindung.",
"sessionExpired": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.",
"rateLimited": "Zu viele Anfragen. Bitte warte einen Moment und versuche es erneut."
},
"validation": {
"required": "Dieses Feld ist erforderlich",
"email": "Bitte gib eine gültige E-Mail-Adresse ein",
"minLength": "Muss mindestens {min} Zeichen lang sein",
"maxLength": "Darf höchstens {max} Zeichen lang sein",
"min": "Muss mindestens {min} sein",
"max": "Darf höchstens {max} sein",
"pattern": "Ungültiges Format",
"match": "Felder stimmen nicht überein",
"unique": "Dieser Wert wird bereits verwendet",
"invalid": "Ungültiger Wert",
"url": "Bitte gib eine gültige URL ein",
"phone": "Bitte gib eine gültige Telefonnummer ein",
"number": "Bitte gib eine gültige Zahl ein",
"integer": "Bitte gib eine ganze Zahl ein",
"positive": "Muss eine positive Zahl sein",
"date": "Bitte gib ein gültiges Datum ein",
"futureDate": "Datum muss in der Zukunft liegen",
"pastDate": "Datum muss in der Vergangenheit liegen",
"password": {
"minLength": "Passwort muss mindestens {min} Zeichen lang sein",
"uppercase": "Passwort muss einen Großbuchstaben enthalten",
"lowercase": "Passwort muss einen Kleinbuchstaben enthalten",
"number": "Passwort muss eine Zahl enthalten",
"special": "Passwort muss ein Sonderzeichen enthalten",
"weak": "Passwort ist zu schwach"
}
},
"auth": {
"signIn": "Anmelden",
"signOut": "Abmelden",
"signUp": "Registrieren",
"forgotPassword": "Passwort vergessen?",
"resetPassword": "Passwort zurücksetzen",
"changePassword": "Passwort ändern",
"email": "E-Mail",
"password": "Passwort",
"confirmPassword": "Passwort bestätigen",
"rememberMe": "Angemeldet bleiben",
"orContinueWith": "Oder fortfahren mit",
"alreadyHaveAccount": "Bereits ein Konto?",
"dontHaveAccount": "Noch kein Konto?",
"errors": {
"invalidCredentials": "Ungültige E-Mail oder Passwort",
"emailInUse": "Diese E-Mail wird bereits verwendet",
"weakPassword": "Passwort ist zu schwach",
"userNotFound": "Benutzer nicht gefunden",
"tooManyAttempts": "Zu viele Versuche. Bitte versuche es später erneut."
}
},
"settings": {
"title": "Einstellungen",
"account": "Konto",
"profile": "Profil",
"preferences": "Einstellungen",
"notifications": "Benachrichtigungen",
"privacy": "Datenschutz",
"security": "Sicherheit",
"language": "Sprache",
"theme": "Design",
"appearance": "Erscheinungsbild",
"darkMode": "Dunkelmodus",
"lightMode": "Hellmodus",
"systemDefault": "Systemstandard",
"about": "Über",
"help": "Hilfe",
"feedback": "Feedback",
"terms": "Nutzungsbedingungen",
"privacyPolicy": "Datenschutzrichtlinie",
"version": "Version"
}
}

View file

@ -0,0 +1,172 @@
{
"common": {
"actions": {
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"edit": "Edit",
"create": "Create",
"update": "Update",
"close": "Close",
"confirm": "Confirm",
"submit": "Submit",
"back": "Back",
"next": "Next",
"done": "Done",
"retry": "Retry",
"refresh": "Refresh",
"search": "Search",
"filter": "Filter",
"sort": "Sort",
"share": "Share",
"copy": "Copy",
"download": "Download",
"upload": "Upload",
"select": "Select",
"clear": "Clear",
"reset": "Reset",
"apply": "Apply",
"continue": "Continue",
"skip": "Skip",
"yes": "Yes",
"no": "No",
"ok": "OK"
},
"labels": {
"loading": "Loading...",
"saving": "Saving...",
"deleting": "Deleting...",
"processing": "Processing...",
"uploading": "Uploading...",
"downloading": "Downloading...",
"searching": "Searching...",
"noResults": "No results found",
"noData": "No data available",
"empty": "Empty",
"all": "All",
"none": "None",
"other": "Other",
"more": "More",
"less": "Less",
"showMore": "Show more",
"showLess": "Show less",
"viewAll": "View all",
"required": "Required",
"optional": "Optional",
"new": "New",
"recent": "Recent",
"popular": "Popular",
"featured": "Featured"
},
"time": {
"now": "Now",
"today": "Today",
"yesterday": "Yesterday",
"tomorrow": "Tomorrow",
"thisWeek": "This week",
"lastWeek": "Last week",
"thisMonth": "This month",
"lastMonth": "Last month",
"thisYear": "This year",
"ago": "ago",
"in": "in"
},
"status": {
"active": "Active",
"inactive": "Inactive",
"pending": "Pending",
"completed": "Completed",
"failed": "Failed",
"cancelled": "Cancelled",
"success": "Success",
"error": "Error",
"warning": "Warning",
"info": "Info"
}
},
"errors": {
"generic": "Something went wrong. Please try again.",
"network": "Network error. Please check your connection.",
"timeout": "Request timed out. Please try again.",
"notFound": "The requested item was not found.",
"unauthorized": "You are not authorized to perform this action.",
"forbidden": "Access denied.",
"serverError": "Server error. Please try again later.",
"validation": "Please check your input and try again.",
"unknown": "An unknown error occurred.",
"offline": "You are offline. Please check your internet connection.",
"sessionExpired": "Your session has expired. Please sign in again.",
"rateLimited": "Too many requests. Please wait a moment and try again."
},
"validation": {
"required": "This field is required",
"email": "Please enter a valid email address",
"minLength": "Must be at least {min} characters",
"maxLength": "Must be at most {max} characters",
"min": "Must be at least {min}",
"max": "Must be at most {max}",
"pattern": "Invalid format",
"match": "Fields do not match",
"unique": "This value is already in use",
"invalid": "Invalid value",
"url": "Please enter a valid URL",
"phone": "Please enter a valid phone number",
"number": "Please enter a valid number",
"integer": "Please enter a whole number",
"positive": "Must be a positive number",
"date": "Please enter a valid date",
"futureDate": "Date must be in the future",
"pastDate": "Date must be in the past",
"password": {
"minLength": "Password must be at least {min} characters",
"uppercase": "Password must contain an uppercase letter",
"lowercase": "Password must contain a lowercase letter",
"number": "Password must contain a number",
"special": "Password must contain a special character",
"weak": "Password is too weak"
}
},
"auth": {
"signIn": "Sign in",
"signOut": "Sign out",
"signUp": "Sign up",
"forgotPassword": "Forgot password?",
"resetPassword": "Reset password",
"changePassword": "Change password",
"email": "Email",
"password": "Password",
"confirmPassword": "Confirm password",
"rememberMe": "Remember me",
"orContinueWith": "Or continue with",
"alreadyHaveAccount": "Already have an account?",
"dontHaveAccount": "Don't have an account?",
"errors": {
"invalidCredentials": "Invalid email or password",
"emailInUse": "This email is already in use",
"weakPassword": "Password is too weak",
"userNotFound": "User not found",
"tooManyAttempts": "Too many attempts. Please try again later."
}
},
"settings": {
"title": "Settings",
"account": "Account",
"profile": "Profile",
"preferences": "Preferences",
"notifications": "Notifications",
"privacy": "Privacy",
"security": "Security",
"language": "Language",
"theme": "Theme",
"appearance": "Appearance",
"darkMode": "Dark mode",
"lightMode": "Light mode",
"systemDefault": "System default",
"about": "About",
"help": "Help",
"feedback": "Feedback",
"terms": "Terms of Service",
"privacyPolicy": "Privacy Policy",
"version": "Version"
}
}

View file

@ -0,0 +1,37 @@
/**
* Common translations exports
*/
import en from './en.json';
import de from './de.json';
export { en, de };
/**
* Common translations type
*/
export type CommonTranslations = typeof en;
/**
* Get common translations by locale
*/
export function getCommonTranslations(locale: string): CommonTranslations {
switch (locale) {
case 'de':
return de;
case 'en':
default:
return en;
}
}
/**
* Merge common translations with app-specific translations
*/
export function mergeWithCommon<T extends Record<string, unknown>>(
locale: string,
appTranslations: T
): T & CommonTranslations {
const common = getCommonTranslations(locale);
return { ...common, ...appTranslations } as T & CommonTranslations;
}

View file

@ -0,0 +1,249 @@
/**
* i18n utility functions
*/
import { type LanguageCode, isLanguageSupported } from './languages';
/**
* Detect user's preferred locale from browser
* Works in browser environment only
*/
export function detectBrowserLocale(
supportedLocales: readonly string[],
defaultLocale: string = 'en'
): string {
if (typeof navigator === 'undefined') {
return defaultLocale;
}
// Try navigator.language first
const browserLang = navigator.language;
// Check exact match (e.g., 'pt-BR')
if (supportedLocales.includes(browserLang)) {
return browserLang;
}
// Check base language (e.g., 'pt' from 'pt-BR')
const baseLang = browserLang.split('-')[0];
if (supportedLocales.includes(baseLang)) {
return baseLang;
}
// Try navigator.languages array
if (navigator.languages) {
for (const lang of navigator.languages) {
if (supportedLocales.includes(lang)) {
return lang;
}
const base = lang.split('-')[0];
if (supportedLocales.includes(base)) {
return base;
}
}
}
return defaultLocale;
}
/**
* Get locale from localStorage with validation
*/
export function getStoredLocale(
storageKey: string,
supportedLocales: readonly string[]
): string | null {
if (typeof localStorage === 'undefined') {
return null;
}
const stored = localStorage.getItem(storageKey);
if (stored && supportedLocales.includes(stored)) {
return stored;
}
return null;
}
/**
* Store locale in localStorage
*/
export function storeLocale(storageKey: string, locale: string): void {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.setItem(storageKey, locale);
}
/**
* Get initial locale with priority:
* 1. localStorage
* 2. Browser language
* 3. Default locale
*/
export function getInitialLocale(
storageKey: string,
supportedLocales: readonly string[],
defaultLocale: string = 'en'
): string {
// Check localStorage first
const stored = getStoredLocale(storageKey, supportedLocales);
if (stored) {
return stored;
}
// Fall back to browser language
return detectBrowserLocale(supportedLocales, defaultLocale);
}
/**
* Normalize locale code to standard format
* Examples: 'en-us' -> 'en-US', 'pt_BR' -> 'pt-BR'
*/
export function normalizeLocale(locale: string): string {
const parts = locale.replace('_', '-').split('-');
if (parts.length === 1) {
return parts[0].toLowerCase();
}
return `${parts[0].toLowerCase()}-${parts[1].toUpperCase()}`;
}
/**
* Get base language from locale code
* Examples: 'pt-BR' -> 'pt', 'en-US' -> 'en'
*/
export function getBaseLanguage(locale: string): string {
return locale.split('-')[0].toLowerCase();
}
/**
* Check if locale matches a language (including variants)
* Examples: matchesLanguage('pt-BR', 'pt') -> true
*/
export function matchesLanguage(locale: string, language: string): boolean {
const normalizedLocale = normalizeLocale(locale);
const normalizedLanguage = language.toLowerCase();
return (
normalizedLocale === normalizedLanguage ||
getBaseLanguage(normalizedLocale) === normalizedLanguage
);
}
/**
* Find best matching locale from supported list
*/
export function findBestMatch(
preferredLocale: string,
supportedLocales: readonly string[],
defaultLocale: string = 'en'
): string {
const normalized = normalizeLocale(preferredLocale);
// Exact match
if (supportedLocales.includes(normalized)) {
return normalized;
}
// Base language match
const base = getBaseLanguage(normalized);
if (supportedLocales.includes(base)) {
return base;
}
// Find any variant of the same language
const variant = supportedLocales.find(loc => getBaseLanguage(loc) === base);
if (variant) {
return variant;
}
return defaultLocale;
}
/**
* Format number according to locale
*/
export function formatLocalizedNumber(
value: number,
locale: string = 'en',
options?: Intl.NumberFormatOptions
): string {
return new Intl.NumberFormat(locale, options).format(value);
}
/**
* Format date according to locale
*/
export function formatLocalizedDate(
date: Date | string | number,
locale: string = 'en',
options?: Intl.DateTimeFormatOptions
): string {
const dateObj = date instanceof Date ? date : new Date(date);
return new Intl.DateTimeFormat(locale, options).format(dateObj);
}
/**
* Format relative time according to locale
*/
export function formatRelativeTime(
date: Date | string | number,
locale: string = 'en',
style: 'long' | 'short' | 'narrow' = 'long'
): string {
const dateObj = date instanceof Date ? date : new Date(date);
const now = new Date();
const diffMs = dateObj.getTime() - now.getTime();
const diffSecs = Math.round(diffMs / 1000);
const diffMins = Math.round(diffSecs / 60);
const diffHours = Math.round(diffMins / 60);
const diffDays = Math.round(diffHours / 24);
const diffWeeks = Math.round(diffDays / 7);
const diffMonths = Math.round(diffDays / 30);
const diffYears = Math.round(diffDays / 365);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto', style });
if (Math.abs(diffSecs) < 60) {
return rtf.format(diffSecs, 'second');
} else if (Math.abs(diffMins) < 60) {
return rtf.format(diffMins, 'minute');
} else if (Math.abs(diffHours) < 24) {
return rtf.format(diffHours, 'hour');
} else if (Math.abs(diffDays) < 7) {
return rtf.format(diffDays, 'day');
} else if (Math.abs(diffWeeks) < 4) {
return rtf.format(diffWeeks, 'week');
} else if (Math.abs(diffMonths) < 12) {
return rtf.format(diffMonths, 'month');
} else {
return rtf.format(diffYears, 'year');
}
}
/**
* Get plural form category
*/
export function getPluralCategory(
count: number,
locale: string = 'en'
): Intl.LDMLPluralRule {
const pr = new Intl.PluralRules(locale);
return pr.select(count);
}
/**
* Interpolate values into a translation string
* Example: interpolate("Hello {name}!", { name: "World" }) -> "Hello World!"
*/
export function interpolate(
text: string,
values: Record<string, string | number>
): string {
return text.replace(/\{(\w+)\}/g, (match, key) => {
return key in values ? String(values[key]) : match;
});
}

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,34 @@
{
"name": "@manacore/shared-icons",
"version": "0.1.0",
"private": true,
"description": "Shared Phosphor Icons (Bold) for Manacore SvelteKit web apps",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": {
"svelte": "./src/index.ts",
"default": "./src/index.ts"
},
"./Icon.svelte": {
"svelte": "./src/Icon.svelte",
"default": "./src/Icon.svelte"
},
"./iconPaths": {
"default": "./src/iconPaths.ts"
}
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"peerDependencies": {
"svelte": "^5.0.0"
},
"devDependencies": {
"svelte": "^5.16.6",
"svelte-check": "^4.2.1",
"typescript": "^5.9.3"
}
}

View file

@ -0,0 +1,39 @@
<script lang="ts">
/**
* Shared Icon Component for Manacore Web Apps
* Uses Phosphor Icons (Bold weight)
*
* Usage:
* import { Icon } from '@manacore/shared-icons';
* <Icon name="user-plus" size={24} />
* <Icon name="sign-in" size={20} class="text-primary" />
*/
import { iconPaths } from './iconPaths';
interface Props {
name: keyof typeof iconPaths;
size?: number;
class?: string;
color?: string;
}
let { name, size = 24, class: className = '', color }: Props = $props();
const path = $derived(iconPaths[name]);
</script>
{#if path}
<svg
xmlns="http://www.w3.org/2000/svg"
width={size}
height={size}
fill={color || 'currentColor'}
viewBox="0 0 256 256"
class={className}
aria-hidden="true"
>
{@html path}
</svg>
{:else}
<span class="text-red-500" title="Icon '{name}' not found"></span>
{/if}

View file

@ -0,0 +1,131 @@
/**
* Phosphor Icons (Bold weight) - Shared icons for Manacore web apps
*
* This is a centralized icon catalog for all SvelteKit applications.
* All icons use the Bold weight for consistency.
*
* To add new icons:
* 1. Find the icon at https://phosphoricons.com/
* 2. Copy the SVG content (the <path> tag) from the Bold variant
* 3. Add it to this file with a descriptive key
*
* Usage:
* import { Icon } from '@manacore/shared-icons';
* <Icon name="user-plus" size={24} />
*/
export const iconPaths = {
// Auth & User
'user-plus':
'<path d="M256,136a12,12,0,0,1-12,12h-8v8a12,12,0,0,1-24,0v-8h-8a12,12,0,0,1,0-24h8v-8a12,12,0,0,1,24,0v8h8A12,12,0,0,1,256,136Zm-54.81,56.28a12,12,0,1,1-18.38,15.44C169.12,191.42,145,172,108,172c-28.89,0-55.46,12.68-74.81,35.72a12,12,0,0,1-18.38-15.44A124.08,124.08,0,0,1,63.5,156.53a72,72,0,1,1,89,0A124,124,0,0,1,201.19,192.28ZM108,148a48,48,0,1,0-48-48A48.05,48.05,0,0,0,108,148Z"/>',
'sign-in':
'<path d="M144.49,136.49l-40,40a12,12,0,0,1-17-17L107,140H24a12,12,0,0,1,0-24h83L87.51,96.49a12,12,0,0,1,17-17l40,40A12,12,0,0,1,144.49,136.49ZM200,28H136a12,12,0,0,0,0,24h52V204H136a12,12,0,0,0,0,24h64a12,12,0,0,0,12-12V40A12,12,0,0,0,200,28Z"/>',
'sign-out':
'<path d="M116,216a12,12,0,0,1-12,12H48a20,20,0,0,1-20-20V48A20,20,0,0,1,48,28h56a12,12,0,0,1,0,24H52V204h52A12,12,0,0,1,116,216Zm108.49-96.49-40-40a12,12,0,0,0-17,17L187,116H104a12,12,0,0,0,0,24h83l-19.52,19.51a12,12,0,0,0,17,17l40-40A12,12,0,0,0,224.49,119.51Z"/>',
user: '<path d="M234.38,210a123.36,123.36,0,0,0-60.78-53.23,76,76,0,1,0-91.2,0A123.36,123.36,0,0,0,21.62,210a12,12,0,1,0,20.77,12c18.12-31.32,50.12-50,85.61-50s67.49,18.69,85.61,50a12,12,0,0,0,20.77-12ZM76,96a52,52,0,1,1,52,52A52.06,52.06,0,0,1,76,96Z"/>',
users: '<path d="M125.18,156.94a64,64,0,1,0-82.36,0,100.23,100.23,0,0,0-39.49,32,12,12,0,0,0,20.54,12.08C36.18,184.88,55.25,172,76,172c22.44,0,41.84,14.31,54.86,40.39a12,12,0,1,0,21.19-11.17A99.33,99.33,0,0,0,125.18,156.94ZM44,108a40,40,0,1,1,40,40A40,40,0,0,1,44,108Zm206.1,97.67a12,12,0,0,1-16.78-2.57C221.44,183.6,202,172,180,172a41.86,41.86,0,0,0-13.54,2.24,12,12,0,0,1-7.84-22.68,64.36,64.36,0,0,0,35.69-27.15,64,64,0,1,0-43.49-45.85,12,12,0,0,1-23.71-3.15,88,88,0,1,1,73.54,84.28,99.33,99.33,0,0,1,26.84,44.53A12,12,0,0,1,250.1,205.67Z"/>',
// Navigation & Arrows
'arrow-left':
'<path d="M228,128a12,12,0,0,1-12,12H69l51.52,51.51a12,12,0,0,1-17,17l-72-72a12,12,0,0,1,0-17l72-72a12,12,0,0,1,17,17L69,116H216A12,12,0,0,1,228,128Z"/>',
'arrow-right':
'<path d="M224.49,136.49l-72,72a12,12,0,0,1-17-17L187,140H40a12,12,0,0,1,0-24H187L135.51,64.48a12,12,0,0,1,17-17l72,72A12,12,0,0,1,224.49,136.49Z"/>',
'arrow-up':
'<path d="M208.49,152.49a12,12,0,0,1-17,17L140,118v98a12,12,0,0,1-24,0V118L64.49,169.51a12,12,0,0,1-17-17l72-72a12,12,0,0,1,17,0Z"/>',
'arrow-down':
'<path d="M208.49,168.49l-72,72a12,12,0,0,1-17,0l-72-72a12,12,0,0,1,17-17L116,203V40a12,12,0,0,1,24,0V203l51.51-51.52a12,12,0,0,1,17,17Z"/>',
'caret-down':
'<path d="M216.49,104.49l-80,80a12,12,0,0,1-17,0l-80-80a12,12,0,0,1,17-17L128,159l71.51-71.52a12,12,0,0,1,17,17Z"/>',
'caret-up':
'<path d="M216.49,168.49a12,12,0,0,1-17,17L128,114,56.49,185.51a12,12,0,0,1-17-17l80-80a12,12,0,0,1,17,0Z"/>',
'caret-left':
'<path d="M168.49,199.51a12,12,0,0,1-17,17l-80-80a12,12,0,0,1,0-17l80-80a12,12,0,0,1,17,17L97,128Z"/>',
'caret-right':
'<path d="M184.49,136.49l-80,80a12,12,0,0,1-17-17L159,128,87.51,56.49a12,12,0,0,1,17-17l80,80A12,12,0,0,1,184.49,136.49Z"/>',
// Actions
plus: '<path d="M228,128a12,12,0,0,1-12,12H140v76a12,12,0,0,1-24,0V140H40a12,12,0,0,1,0-24h76V40a12,12,0,0,1,24,0v76h76A12,12,0,0,1,228,128Z"/>',
minus: '<path d="M228,128a12,12,0,0,1-12,12H40a12,12,0,0,1,0-24H216A12,12,0,0,1,228,128Z"/>',
x: '<path d="M208.49,191.51a12,12,0,0,1-17,17L128,145,64.49,208.49a12,12,0,0,1-17-17L111,128,47.51,64.49a12,12,0,0,1,17-17L128,111l63.51-63.52a12,12,0,0,1,17,17L145,128Z"/>',
check: '<path d="M232.49,80.49l-128,128a12,12,0,0,1-17,0l-56-56a12,12,0,1,1,17-17L96,183,215.51,63.51a12,12,0,0,1,17,17Z"/>',
trash: '<path d="M216,48H180V36A28,28,0,0,0,152,8H104A28,28,0,0,0,76,36V48H40a12,12,0,0,0,0,24h4V208a20,20,0,0,0,20,20H192a20,20,0,0,0,20-20V72h4a12,12,0,0,0,0-24ZM100,36a4,4,0,0,1,4-4h48a4,4,0,0,1,4,4V48H100Zm88,168H68V72H188ZM116,104v64a12,12,0,0,1-24,0V104a12,12,0,0,1,24,0Zm48,0v64a12,12,0,0,1-24,0V104a12,12,0,0,1,24,0Z"/>',
copy: '<path d="M216,28H88A12,12,0,0,0,76,40V76H40A12,12,0,0,0,28,88V216a12,12,0,0,0,12,12H168a12,12,0,0,0,12-12V180h36a12,12,0,0,0,12-12V40A12,12,0,0,0,216,28ZM156,204H52V100H156Zm48-48H180V88a12,12,0,0,0-12-12H100V52H204Z"/>',
// Media
play: '<path d="M240.82,114.67,88.82,26.67a16,16,0,0,0-16.12-.41A15.68,15.68,0,0,0,64,40.36V215.64a15.68,15.68,0,0,0,8.7,14.1,16,16,0,0,0,16.12-.41l152-88a15.76,15.76,0,0,0,0-26.66ZM88,199.93V56.07L216.16,128Z"/>',
pause: '<path d="M200,28H160a20,20,0,0,0-20,20V208a20,20,0,0,0,20,20h40a20,20,0,0,0,20-20V48A20,20,0,0,0,200,28Zm-4,176H164V52h32ZM96,28H56A20,20,0,0,0,36,48V208a20,20,0,0,0,20,20H96a20,20,0,0,0,20-20V48A20,20,0,0,0,96,28ZM92,204H60V52H92Z"/>',
microphone:
'<path d="M128,176a52.06,52.06,0,0,0,52-52V64a52,52,0,0,0-104,0v60A52.06,52.06,0,0,0,128,176ZM100,64a28,28,0,0,1,56,0v60a28,28,0,0,1-56,0Zm112,56a12,12,0,0,1-24,0,76,76,0,0,0-152,0,12,12,0,0,1-24,0,100.11,100.11,0,0,1,88-99.26V8a12,12,0,0,1,24,0V20.74A100.11,100.11,0,0,1,212,120Z"/>',
'skip-back':
'<path d="M201.75,30.52a20,20,0,0,0-20.3.53L68,102V40a12,12,0,0,0-24,0V216a12,12,0,0,0,24,0V154l113.45,71A20,20,0,0,0,212,208.12V47.88A19.86,19.86,0,0,0,201.75,30.52ZM188,200.73,71.7,128,188,55.27Z"/>',
'skip-forward':
'<path d="M200,28a12,12,0,0,0-12,12v62l-113.45-71A20,20,0,0,0,44,47.88V208.12A20,20,0,0,0,74.55,225L188,154v62a12,12,0,0,0,24,0V40A12,12,0,0,0,200,28ZM68,200.73V55.27L184.3,128Z"/>',
// Edit
pencil: '<path d="M230.14,70.54,185.46,25.85a20,20,0,0,0-28.29,0L33.86,149.17A19.85,19.85,0,0,0,28,163.31V208a20,20,0,0,0,20,20H92.69a19.86,19.86,0,0,0,14.14-5.86L230.14,98.82a20,20,0,0,0,0-28.28ZM91,204H52V165l84-84,39,39ZM192,103,153,64l18.34-18.34,39,39Z"/>',
pen: '<path d="M228.12,67.07,188.93,27.88a20,20,0,0,0-28.28,0L33.88,154.64A19.86,19.86,0,0,0,28,168.78V208a20,20,0,0,0,20,20H87.22a19.86,19.86,0,0,0,14.14-5.86L228.12,95.35a20,20,0,0,0,0-28.28ZM91,204H52V165l84-84,39,39ZM192,103,153,64l21.52-21.52L213.49,81.5Z"/>',
'note-pencil':
'<path d="M228,128v80a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V48A20,20,0,0,1,48,28h80a12,12,0,0,1,0,24H52V204H204V132a12,12,0,0,1,24,0Zm-26.34-84.49a12,12,0,0,0-17,0L112.3,115.86a20,20,0,0,0-5.59,11.63l-5.46,32.74a12,12,0,0,0,14.79,14.05l32.73-8.73a20.08,20.08,0,0,0,11.07-6.41l72.4-72.39a12,12,0,0,0,0-17Zm-47.29,63.9-18.64,5-3.53-3.52,4.95-18.63,40.31-40.31,16.72,16.72Z"/>',
// Files & Folders
folder: '<path d="M216,68H130.67L102.93,51.2a20.12,20.12,0,0,0-11.07-3.2H40A20,20,0,0,0,20,68V200a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V88A20,20,0,0,0,216,68Zm-4,128H44V92H212Z"/>',
'folder-open':
'<path d="M248.23,112.31l-60,112A12,12,0,0,1,177.6,228H36a20,20,0,0,1-20-20V88A20,20,0,0,1,36,68H88a11.93,11.93,0,0,1,7.88,2.93l29.51,25.89A4,4,0,0,0,128,98h88a12,12,0,0,1,10.62,6.38A11.88,11.88,0,0,1,248.23,112.31ZM218.72,122H123.88L96,99.47,40,92v112H172.52Z"/>',
file: '<path d="M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A20,20,0,0,0,36,44V212a20,20,0,0,0,20,20H200a20,20,0,0,0,20-20V88A8,8,0,0,0,213.66,82.34ZM160,51.31,188.69,80H160ZM196,208H60V48h76V92a12,12,0,0,0,12,12h48Z"/>',
// UI Elements
'dots-three':
'<path d="M144,128a16,16,0,1,1-16-16A16,16,0,0,1,144,128Zm56-16a16,16,0,1,0,16,16A16,16,0,0,0,200,112ZM56,112a16,16,0,1,0,16,16A16,16,0,0,0,56,112Z"/>',
'dots-three-vertical':
'<path d="M128,112a16,16,0,1,0,16,16A16,16,0,0,0,128,112Zm0-56a16,16,0,1,0-16-16A16,16,0,0,0,128,56Zm0,144a16,16,0,1,0,16,16A16,16,0,0,0,128,200Z"/>',
list: '<path d="M228,128a12,12,0,0,1-12,12H40a12,12,0,0,1,0-24H216A12,12,0,0,1,228,128ZM40,76H216a12,12,0,0,0,0-24H40a12,12,0,0,0,0,24ZM216,180H40a12,12,0,0,0,0,24H216a12,12,0,0,0,0-24Z"/>',
'magnifying-glass':
'<path d="M232.49,215.51,185,168a92.12,92.12,0,1,0-17,17l47.53,47.54a12,12,0,0,0,17-17ZM44,112a68,68,0,1,1,68,68A68.07,68.07,0,0,1,44,112Z"/>',
// Misc
key: '<path d="M196,76a16,16,0,1,1-16-16A16,16,0,0,1,196,76Zm48,22.74A84.3,84.3,0,0,1,160.11,180H160a83.52,83.52,0,0,1-23.65-3.38l-7.86,7.87A12,12,0,0,1,120,188H108v12a12,12,0,0,1-12,12H84v12a12,12,0,0,1-12,12H40a20,20,0,0,1-20-20V187.31a19.86,19.86,0,0,1,5.86-14.14l53.52-53.52A84,84,0,1,1,244,98.74ZM202.43,53.57A59.48,59.48,0,0,0,158,36c-32,1-58,27.89-58,59.89a59.69,59.69,0,0,0,4.2,22.19,12,12,0,0,1-2.55,13.21L44,189v23H60V200a12,12,0,0,1,12-12H84V176a12,12,0,0,1,12-12h19l9.65-9.65a12,12,0,0,1,13.22-2.55A59.58,59.58,0,0,0,160,156h.08c32,0,58.87-26.07,59.89-58A59.55,59.55,0,0,0,202.43,53.57Z"/>',
info: '<path d="M108,84a16,16,0,1,1,16,16A16,16,0,0,1,108,84Zm128,44A108,108,0,1,1,128,20,108.12,108.12,0,0,1,236,128Zm-24,0a84,84,0,1,0-84,84A84.09,84.09,0,0,0,212,128Zm-72,36.68V132a20,20,0,0,0-20-20,12,12,0,0,0-4,23.32V168a20,20,0,0,0,20,20,12,12,0,0,0,4-23.32Z"/>',
tag: '<path d="M246.66,123.56,201,55.13A20,20,0,0,0,184.06,44H39.38A20.07,20.07,0,0,0,20,63.38V192.62A20.07,20.07,0,0,0,39.38,212H184.06a20,20,0,0,0,16.94-11.13l45.66-68.43A12,12,0,0,0,246.66,123.56ZM180.91,188H44V68H180.91l40,60ZM96,84a16,16,0,1,1-16,16A16,16,0,0,1,96,84Z"/>',
share: '<path d="M236,200a12,12,0,0,1-12,12H32a12,12,0,0,1,0-24H224A12,12,0,0,1,236,200ZM88.49,80.49,116,53v83a12,12,0,0,0,24,0V53l27.51,27.52a12,12,0,1,0,17-17l-48-48a12,12,0,0,0-17,0l-48,48a12,12,0,1,0,17,17Z"/>',
download:
'<path d="M228,144v64a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V144a12,12,0,0,1,24,0v60H204V144a12,12,0,0,1,24,0Zm-108.49,8.49a12,12,0,0,0,17,0l40-40a12,12,0,0,0-17-17L140,115V24a12,12,0,0,0-24,0v91L96.49,95.51a12,12,0,0,0-17,17Z"/>',
upload: '<path d="M228,144v64a20,20,0,0,1-20,20H48a20,20,0,0,1-20-20V144a12,12,0,0,1,24,0v60H204V144a12,12,0,0,1,24,0Zm-108.49-4.49a12,12,0,0,0,17,0l40-40a12,12,0,0,0-17-17L140,101V24a12,12,0,0,0-24,0v77L96.49,81.51a12,12,0,1,0-17,17Z"/>',
link: '<path d="M122.34,109.66a12,12,0,0,1,0-17l32-32a52,52,0,0,1,73.56,73.56L196,166.14a52,52,0,0,1-73.56,0,12,12,0,1,1,17-17,28,28,0,0,0,39.6,0l31.89-31.88a28,28,0,0,0-39.6-39.6l-32,32A12,12,0,0,1,122.34,109.66Zm-20.68,36.68-32,32a28,28,0,0,0,39.6,39.6l31.89-31.88a28,28,0,0,0,0-39.6,12,12,0,0,0-17,17,4,4,0,0,1,0,5.66L92.22,200.94a4,4,0,0,1-5.66,0,4,4,0,0,1,0-5.66l32-32a12,12,0,0,0-17-17Z"/>',
eye: '<path d="M251.89,122.47a140.13,140.13,0,0,0-46.65-50.37C185,60,157.49,52,128,52S71,60,50.76,72.1a140.13,140.13,0,0,0-46.65,50.37,12.44,12.44,0,0,0,0,11.06,140.13,140.13,0,0,0,46.65,50.37C71,196,98.51,204,128,204s57-8,77.24-20.1a140.13,140.13,0,0,0,46.65-50.37A12.44,12.44,0,0,0,251.89,122.47ZM128,180c-42.86,0-78.64-23.44-105.73-63C49.36,77.44,85.14,54,128,54s78.64,23.44,105.73,63C206.64,156.56,170.86,180,128,180Zm0-98a34,34,0,1,0,34,34A34,34,0,0,0,128,82Zm0,44a10,10,0,1,1,10-10A10,10,0,0,1,128,126Z"/>',
'eye-slash':
'<path d="M55.58,37.76a12,12,0,0,0-17,17l20.7,22.77C29.09,102.4,11.72,137.48,4.09,151.56a12.44,12.44,0,0,0,0,11.06A140.13,140.13,0,0,0,50.74,213c20.26,12.12,47.75,20.1,77.26,20.1a127.36,127.36,0,0,0,51.71-10.52l22.71,25a12,12,0,0,0,17-17Zm49.77,120A34,34,0,0,1,94,128a33.55,33.55,0,0,1,1-8.27l45.64,50.21A33.56,33.56,0,0,1,128,172,34,34,0,0,1,105.35,157.76Zm116.81,53.3a12,12,0,0,1-6.16,16,128,128,0,0,1-51.62,9.78,34,34,0,0,1-45.31-50.35L98.11,163.44a58.05,58.05,0,0,0,79.46,19.74,34,34,0,0,0,12.33-12A127.85,127.85,0,0,0,206.16,211.06ZM237.94,133.56a140.13,140.13,0,0,1-46.65,50.37A130.12,130.12,0,0,1,128,203.93a57.6,57.6,0,0,1-15.47-1.9l-19.24-21.16a82.06,82.06,0,0,1,0-117.74A82,82,0,0,1,128,50h.09a133.85,133.85,0,0,1,63,19.93,140.13,140.13,0,0,1,46.65,50.37A12.44,12.44,0,0,1,237.94,133.56ZM215.82,128C188.83,87.44,153,64,128,64a58.1,58.1,0,0,0-40.66,16.55L128,123.3a10,10,0,0,1,0,14.13,10.19,10.19,0,0,1-14.13,0l-40.66,44.72A82,82,0,0,0,128,204c25,0,60.83-23.44,87.82-64C215.82,128,215.82,128,215.82,128Z"/>',
// Alias for eye-slash
'eye-off':
'<path d="M55.58,37.76a12,12,0,0,0-17,17l20.7,22.77C29.09,102.4,11.72,137.48,4.09,151.56a12.44,12.44,0,0,0,0,11.06A140.13,140.13,0,0,0,50.74,213c20.26,12.12,47.75,20.1,77.26,20.1a127.36,127.36,0,0,0,51.71-10.52l22.71,25a12,12,0,0,0,17-17Zm49.77,120A34,34,0,0,1,94,128a33.55,33.55,0,0,1,1-8.27l45.64,50.21A33.56,33.56,0,0,1,128,172,34,34,0,0,1,105.35,157.76Zm116.81,53.3a12,12,0,0,1-6.16,16,128,128,0,0,1-51.62,9.78,34,34,0,0,1-45.31-50.35L98.11,163.44a58.05,58.05,0,0,0,79.46,19.74,34,34,0,0,0,12.33-12A127.85,127.85,0,0,0,206.16,211.06ZM237.94,133.56a140.13,140.13,0,0,1-46.65,50.37A130.12,130.12,0,0,1,128,203.93a57.6,57.6,0,0,1-15.47-1.9l-19.24-21.16a82.06,82.06,0,0,1,0-117.74A82,82,0,0,1,128,50h.09a133.85,133.85,0,0,1,63,19.93,140.13,140.13,0,0,1,46.65,50.37A12.44,12.44,0,0,1,237.94,133.56ZM215.82,128C188.83,87.44,153,64,128,64a58.1,58.1,0,0,0-40.66,16.55L128,123.3a10,10,0,0,1,0,14.13,10.19,10.19,0,0,1-14.13,0l-40.66,44.72A82,82,0,0,0,128,204c25,0,60.83-23.44,87.82-64C215.82,128,215.82,128,215.82,128Z"/>',
lock: '<path d="M208,76H180V56a52,52,0,0,0-104,0V76H48A20,20,0,0,0,28,96V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V96A20,20,0,0,0,208,76ZM100,56a28,28,0,0,1,56,0V76H100ZM204,204H52V100H204Zm-72-76a16,16,0,1,1-16-16A16,16,0,0,1,132,128Z"/>',
star: '<path d="M234.29,114.85l-45,38.83L203,211.75a20.81,20.81,0,0,1-7.24,21.31,20.25,20.25,0,0,1-21.7,1.32L128,213.15,81.94,234.38a20.25,20.25,0,0,1-21.7-1.32,20.81,20.81,0,0,1-7.24-21.31l13.72-58.07-45-38.83A20.86,20.86,0,0,1,33.31,86.56l59.51-5.16,23.17-55.23a20.88,20.88,0,0,1,38,0l23.17,55.23,59.51,5.16a20.86,20.86,0,0,1,11.58,28.29Zm-74.81-28.8-18.28-43.54a.84.84,0,0,0-1.58,0L121.34,86.05a12,12,0,0,1-10.09,7.34L51.58,98.83a.87.87,0,0,0-.49,1.53L89.83,131.8a12,12,0,0,1,3.86,11.93L82.84,201.1a.87.87,0,0,0,.34.94.88.88,0,0,0,1,0l43.45-19.95a12.09,12.09,0,0,1,10.68,0l43.45,19.95a.88.88,0,0,0,1,0,.87.87,0,0,0,.34-.94l-10.85-57.37a12,12,0,0,1,3.86-11.93l38.74-33.44a.87.87,0,0,0-.49-1.53l-59.67-5.17A12,12,0,0,1,159.48,86.05Z"/>',
heart: '<path d="M178,36c-21.4,0-39.31,10.64-50,27.14C117.31,46.64,99.4,36,78,36a66.08,66.08,0,0,0-66,66c0,72.25,105.53,130.44,110.66,132.94a12,12,0,0,0,10.68,0C138.47,232.44,244,174.25,244,102A66.08,66.08,0,0,0,178,36Zm-5.42,142.24c-18.3,10.88-38.26,21.14-44.58,24.78-6.32-3.64-26.28-13.9-44.58-24.78C56.75,159.16,28,136,28,102A42,42,0,0,1,70,60c18.12,0,33.05,9.48,42.64,27.22a12,12,0,0,0,20.72,0C142.94,69.48,157.88,60,176,60a42,42,0,0,1,42,42C220,136,191.25,159.16,172.58,178.24Z"/>',
bell: '<path d="M225.29,165.93C216.61,151,212,129.57,212,104a84,84,0,0,0-168,0c0,25.58-4.59,47-13.27,61.93A20.08,20.08,0,0,0,30.66,186a19.77,19.77,0,0,0,17.79,11H94.2a36,36,0,0,0,67.6,0H207.55a19.77,19.77,0,0,0,17.79-11A20.08,20.08,0,0,0,225.29,165.93ZM128,216a12,12,0,0,1-11.62-9h23.24A12,12,0,0,1,128,216Zm-76.55-39C62.19,157.17,68,133.1,68,104a60,60,0,0,1,120,0c0,29.1,5.81,53.17,16.55,73Z"/>',
calendar:
'<path d="M208,28H188V24a12,12,0,0,0-24,0v4H92V24a12,12,0,0,0-24,0v4H48A20,20,0,0,0,28,48V208a20,20,0,0,0,20,20H208a20,20,0,0,0,20-20V48A20,20,0,0,0,208,28ZM68,52a12,12,0,0,0,24,0h72a12,12,0,0,0,24,0h16V76H52V52ZM52,204V100H204V204Z"/>',
clock: '<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm76-84a12,12,0,0,1-12,12H128a12,12,0,0,1-12-12V72a12,12,0,0,1,24,0v44h52A12,12,0,0,1,204,128Z"/>',
image: '<path d="M216,36H40A20,20,0,0,0,20,56V200a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V56A20,20,0,0,0,216,36Zm-4,24V158.75l-26.07-26.06a20,20,0,0,0-28.28,0l-23.23,23.23-59.8-59.8a20,20,0,0,0-28.28,0L44,98.75V60ZM44,196V121.66l56-56,59.8,59.8a20,20,0,0,0,28.28,0l23.23-23.23L212,103V196Zm28-96a20,20,0,1,0-20-20A20,20,0,0,0,72,100Z"/>',
'shield-check':
'<path d="M208,36H48A20,20,0,0,0,28,56v56c0,54.29,26.32,87.22,48.4,105.29,23.71,19.39,47.44,26,48.44,26.29a12.1,12.1,0,0,0,6.32,0c1-.28,24.73-6.9,48.44-26.29,22.08-18.07,48.4-51,48.4-105.29V56A20,20,0,0,0,208,36Zm-4,76c0,35.71-13.09,64.69-38.91,86.15A126.28,126.28,0,0,1,128,219.38a126.14,126.14,0,0,1-37.09-21.23C65.09,176.69,52,147.71,52,112V60H204ZM79.51,144.49a12,12,0,1,1,17-17L112,143l47.51-47.52a12,12,0,0,1,17,17l-56,56a12,12,0,0,1-17,0Z"/>',
envelope:
'<path d="M224,44H32A12,12,0,0,0,20,56V192a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V56A12,12,0,0,0,224,44Zm-96,83.72L62.85,68h130.3ZM92.79,128,44,172.72V83.28Zm17.76,16.28,9.34,8.57a12,12,0,0,0,16.22,0l9.34-8.57L193.15,188H62.85ZM163.21,128,212,83.28v89.44Z"/>',
'envelope-open':
'<path d="M228.44,89.34l-96-64a12,12,0,0,0-13.32,0L23.56,89.34A12,12,0,0,0,20,98.67V200a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V98.67A12,12,0,0,0,228.44,89.34ZM128,49.81l68.32,45.55L132,136.83a8,8,0,0,1-8,0L59.68,95.36ZM44,196V112.52l64.55,38.73a32,32,0,0,0,30.9,0L204,112.52V196Z"/>',
'mail-open':
'<path d="M228.44,89.34l-96-64a12,12,0,0,0-13.32,0L23.56,89.34A12,12,0,0,0,20,98.67V200a20,20,0,0,0,20,20H216a20,20,0,0,0,20-20V98.67A12,12,0,0,0,228.44,89.34ZM128,49.81l68.32,45.55L132,136.83a8,8,0,0,1-8,0L59.68,95.36ZM44,196V112.52l64.55,38.73a32,32,0,0,0,30.9,0L204,112.52V196Z"/>',
'arrows-left-right':
'<path d="M216.49,184.49l-32,32a12,12,0,0,1-17-17L179,188H48a12,12,0,0,1,0-24H179l-11.52-11.51a12,12,0,0,1,17-17l32,32A12,12,0,0,1,216.49,184.49Zm-145-64a12,12,0,0,0,17-17L77,92H208a12,12,0,0,0,0-24H77L88.49,56.49a12,12,0,0,0-17-17l-32,32a12,12,0,0,0,0,17Z"/>',
globe: '<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm0-160a76,76,0,1,0,76,76A76.08,76.08,0,0,0,128,52Zm0,128a51.6,51.6,0,0,1-19.08-39h38.16A51.6,51.6,0,0,1,128,180Zm-19.08-63a51.6,51.6,0,0,1,38.16,0A51.6,51.6,0,0,1,108.92,117Zm38.16-24H108.92a51.6,51.6,0,0,1,38.16,0ZM92,128a52.06,52.06,0,0,1,8.92-29.31A75.51,75.51,0,0,0,76.39,128,75.51,75.51,0,0,0,100.92,157.31,52.06,52.06,0,0,1,92,128Zm63.08,29.31A52.06,52.06,0,0,1,164,128a52.06,52.06,0,0,1-8.92,29.31A75.51,75.51,0,0,0,179.61,128,75.51,75.51,0,0,0,155.08,157.31Z"/>',
gear: '<path d="M128,76a52,52,0,1,0,52,52A52.06,52.06,0,0,0,128,76Zm0,80a28,28,0,1,1,28-28A28,28,0,0,1,128,156Zm92-28a92.11,92.11,0,0,1-2.33,20.84A12,12,0,0,1,206.5,158l-16.29-6.73a68.35,68.35,0,0,1-24.91,14.4L163.37,182a12,12,0,0,1-10.06,9.89,92.75,92.75,0,0,1-25.44,0A12,12,0,0,1,117.81,182l-1.93-16.29a68.35,68.35,0,0,1-24.91-14.4L74.68,158a12,12,0,0,1-11.17-9.16,92.11,92.11,0,0,1,0-41.68A12,12,0,0,1,74.68,98l16.29,6.73a68.35,68.35,0,0,1,24.91-14.4L117.81,74a12,12,0,0,1,10.06-9.89,92.75,92.75,0,0,1,25.44,0A12,12,0,0,1,163.37,74l1.93,16.29a68.35,68.35,0,0,1,24.91,14.4L206.5,98a12,12,0,0,1,11.17,9.16A92.11,92.11,0,0,1,220,128Z"/>',
'warning':
'<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm-12-80V80a12,12,0,0,1,24,0v52a12,12,0,0,1-24,0Zm28,40a16,16,0,1,1-16-16A16,16,0,0,1,144,172Z"/>',
'question':
'<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm0-140a32,32,0,0,0-32,32,12,12,0,0,0,24,0,8,8,0,1,1,8,8,12,12,0,0,0-12,12v8a12,12,0,0,0,24,0v-1.26A32,32,0,0,0,128,72Zm12,100a16,16,0,1,1-16-16A16,16,0,0,1,140,172Z"/>',
'house':
'<path d="M224,120v96a8,8,0,0,1-8,8H160a8,8,0,0,1-8-8V164a4,4,0,0,0-4-4H108a4,4,0,0,0-4,4v52a8,8,0,0,1-8,8H40a8,8,0,0,1-8-8V120a16,16,0,0,1,5.17-11.78l80-75.48.11-.11a16,16,0,0,1,21.53,0,1.14,1.14,0,0,0,.11.11l80,75.48A16,16,0,0,1,224,120Z"/>',
music: '<path d="M212.92,17.69a12,12,0,0,0-9.73-2.8L94.66,30.79A12,12,0,0,0,84,42.68V174.78A48,48,0,1,0,108,208V86.68l84.47-12.07a12,12,0,0,0,0-23.62L108,63.06V42.68l84.47-12.07A48,48,0,1,0,212.92,17.69ZM60,232a24,24,0,1,1,24-24A24,24,0,0,1,60,232Zm132-64a24,24,0,1,1,24-24A24,24,0,0,1,192,168Z"/>',
refresh:
'<path d="M228,128a100,100,0,0,1-98.66,100H128a99.39,99.39,0,0,1-68.62-27.29,12,12,0,0,1,16.48-17.46,76,76,0,1,0-1.57-109c-.13.13-.25.25-.39.37L48,99.27V72a12,12,0,0,0-24,0v48a12,12,0,0,0,12,12H84a12,12,0,0,0,0-24H55.93l27.35-25.29A51.5,51.5,0,0,1,128,52a52,52,0,1,1,0,104,12,12,0,0,0,0,24A100,100,0,0,0,228,128Z"/>'
} as const;
export type IconName = keyof typeof iconPaths;

View file

@ -0,0 +1,2 @@
export { default as Icon } from './Icon.svelte';
export { iconPaths, type IconName } from './iconPaths';

View file

@ -0,0 +1,16 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}

View file

@ -0,0 +1,20 @@
{
"name": "@manacore/shared-subscription-types",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./plans": "./src/plans.ts",
"./usage": "./src/usage.ts",
"./revenueCat": "./src/revenueCat.ts"
},
"scripts": {
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.7.3"
}
}

View file

@ -0,0 +1,39 @@
/**
* Shared subscription types for Manacore monorepo
*
* This package contains TypeScript types for subscription plans,
* mana packages, usage tracking, and RevenueCat integration.
*/
// Plan types
export {
type BillingCycle,
type PlanCategory,
type SubscriptionPlan,
type ManaPackage,
type ProductMapping,
type PackageMapping,
type FreeTierConfig,
DEFAULT_FREE_TIER,
} from './plans';
// Usage types
export {
type UsageData,
type UsageHistoryEntry,
type CostItem,
type ManaBalance,
type CreditTransaction,
type OperationPricing,
} from './usage';
// RevenueCat types
export {
type RevenueCatSubscriptionPlan,
type RevenueCatManaPackage,
type SubscriptionServiceData,
type PurchaseResult,
type CustomerSubscriptionStatus,
type RestorePurchasesResult,
type RevenueCatOffering,
} from './revenueCat';

View file

@ -0,0 +1,136 @@
/**
* 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

@ -0,0 +1,99 @@
/**
* 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

@ -0,0 +1,93 @@
/**
* 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

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,33 @@
{
"name": "@manacore/shared-subscription-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts",
"./SubscriptionCard.svelte": "./src/SubscriptionCard.svelte",
"./PackageCard.svelte": "./src/PackageCard.svelte",
"./BillingToggle.svelte": "./src/BillingToggle.svelte",
"./UsageCard.svelte": "./src/UsageCard.svelte",
"./CostCard.svelte": "./src/CostCard.svelte",
"./SubscriptionButton.svelte": "./src/SubscriptionButton.svelte",
"./ManaIcon.svelte": "./src/ManaIcon.svelte"
},
"scripts": {
"check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@manacore/shared-subscription-types": "workspace:*"
},
"devDependencies": {
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.7.3"
},
"peerDependencies": {
"svelte": "^5.0.0"
}
}

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';

View file

@ -0,0 +1,19 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"verbatimModuleSyntax": true
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,20 @@
{
"name": "@manacore/shared-tailwind",
"version": "1.0.0",
"private": true,
"type": "module",
"main": "./src/index.js",
"exports": {
".": "./src/index.js",
"./preset": "./src/preset.js",
"./colors": "./src/colors.js",
"./theme.css": "./src/theme-variables.css",
"./components.css": "./src/components.css"
},
"peerDependencies": {
"tailwindcss": "^3.0.0 || ^4.0.0"
},
"devDependencies": {
"@tailwindcss/typography": "^0.5.19"
}
}

View file

@ -0,0 +1,243 @@
/**
* Shared color palette for all ManaCore apps
*
* Theme structure:
* - Each theme has light and dark variants
* - Semantic color tokens for consistent UI
*/
export const colors = {
// Brand color used across subscription/pricing
mana: '#4287f5',
// App-specific primary colors
brand: {
memoro: '#f8d62b', // Gold
manacore: '#6366f1', // Indigo
manadeck: '#6366f1', // Indigo
storyteller: '#6366f1', // Indigo
},
// Primary color scale (for general use)
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
950: '#172554'
},
// Lume Theme - Modern Gold & Dark
lume: {
light: {
primary: '#f8d62b',
primaryButton: '#f8d62b',
primaryButtonText: '#000000',
secondary: '#D4B200',
secondaryButton: '#FFE9A3',
contentBackground: '#ffffff',
contentBackgroundHover: '#f5f5f5',
contentPageBackground: '#ffffff',
menuBackground: '#dddddd',
menuBackgroundHover: '#cccccc',
pageBackground: '#dddddd',
text: '#2c2c2c',
textSecondary: '#666666',
borderLight: '#f2f2f2',
border: '#e6e6e6',
borderStrong: '#cccccc',
error: '#e74c3c',
success: '#27ae60',
warning: '#f39c12'
},
dark: {
primary: '#f8d62b',
primaryButton: '#7C6B16',
primaryButtonText: '#ffffff',
secondary: '#D4B200',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#333333',
contentPageBackground: '#121212',
menuBackground: '#101010',
menuBackgroundHover: '#333333',
pageBackground: '#101010',
text: '#ffffff',
textSecondary: '#a0a0a0',
borderLight: '#333333',
border: '#424242',
borderStrong: '#616161',
error: '#e74c3c',
success: '#2ecc71',
warning: '#f1c40f'
}
},
// Nature Theme - Soothing Green
nature: {
light: {
primary: '#4CAF50',
primaryButton: '#A08500',
primaryButtonText: '#ffffff',
secondary: '#81C784',
secondaryButton: '#F1F8E9',
contentBackground: '#F1F8E9',
contentBackgroundHover: '#E8F5E9',
contentPageBackground: '#ffffff',
menuBackground: '#E8F5E9',
menuBackgroundHover: '#C8E6C9',
pageBackground: '#FBFDF8',
text: '#1B5E20',
textSecondary: '#388E3C',
borderLight: '#E8F5E9',
border: '#C8E6C9',
borderStrong: '#A5D6A7',
error: '#E57373',
success: '#66BB6A',
warning: '#FFB74D'
},
dark: {
primary: '#4CAF50',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#81C784',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#2E7D32',
contentPageBackground: '#121212',
menuBackground: '#252525',
menuBackgroundHover: '#2E7D32',
pageBackground: '#121212',
text: '#FFFFFF',
textSecondary: '#A5D6A7',
borderLight: '#1B5E20',
border: '#2E7D32',
borderStrong: '#388E3C',
error: '#CF6679',
success: '#81C784',
warning: '#FFD54F'
}
},
// Stone Theme - Elegant Slate
stone: {
light: {
primary: '#607D8B',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#90A4AE',
secondaryButton: '#ECEFF1',
contentBackground: '#ECEFF1',
contentBackgroundHover: '#E0E6EA',
contentPageBackground: '#ffffff',
menuBackground: '#E0E6EA',
menuBackgroundHover: '#CFD8DC',
pageBackground: '#F5F7F9',
text: '#263238',
textSecondary: '#546E7A',
borderLight: '#ECEFF1',
border: '#CFD8DC',
borderStrong: '#B0BEC5',
error: '#EF5350',
success: '#66BB6A',
warning: '#FFA726'
},
dark: {
primary: '#78909C',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#90A4AE',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#37474F',
contentPageBackground: '#121212',
menuBackground: '#252525',
menuBackgroundHover: '#37474F',
pageBackground: '#121212',
text: '#FFFFFF',
textSecondary: '#B0BEC5',
borderLight: '#37474F',
border: '#455A64',
borderStrong: '#546E7A',
error: '#CF6679',
success: '#81C784',
warning: '#FFD54F'
}
},
// Ocean Theme - Tranquil Blue
ocean: {
light: {
primary: '#039BE5',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#4FC3F7',
secondaryButton: '#E1F5FE',
contentBackground: '#E1F5FE',
contentBackgroundHover: '#B3E5FC',
contentPageBackground: '#ffffff',
menuBackground: '#E1F5FE',
menuBackgroundHover: '#B3E5FC',
pageBackground: '#F5FCFF',
text: '#01579B',
textSecondary: '#0277BD',
borderLight: '#E1F5FE',
border: '#B3E5FC',
borderStrong: '#81D4FA',
error: '#EF5350',
success: '#66BB6A',
warning: '#FFA726'
},
dark: {
primary: '#039BE5',
primaryButton: '#FF9500',
primaryButtonText: '#000000',
secondary: '#4FC3F7',
secondaryButton: '#1E1E1E',
contentBackground: '#1E1E1E',
contentBackgroundHover: '#0277BD',
contentPageBackground: '#121212',
menuBackground: '#252525',
menuBackgroundHover: '#0277BD',
pageBackground: '#121212',
text: '#FFFFFF',
textSecondary: '#81D4FA',
borderLight: '#01579B',
border: '#0277BD',
borderStrong: '#0288D1',
error: '#CF6679',
success: '#81C784',
warning: '#FFD54F'
}
}
};
// Flat theme colors for direct use in Tailwind configs
export const themeColors = {
mana: colors.mana,
primary: colors.primary,
lume: {
...colors.lume.light,
dark: colors.lume.dark,
},
nature: {
...colors.nature.light,
dark: colors.nature.dark,
},
stone: {
...colors.stone.light,
dark: colors.stone.dark,
},
ocean: {
...colors.ocean.light,
dark: colors.ocean.dark,
},
};
export default colors;

View file

@ -0,0 +1,236 @@
/* Shared Component Styles for Manacore Apps
* Import this in your app.css: @import '@manacore/shared-tailwind/components.css';
*
* Requires theme-variables.css to be imported first for CSS variable support
*/
body {
margin: 0;
font-family: var(--font-body);
}
html {
margin: 0;
padding: 0;
}
@layer base {
h1 {
@apply text-3xl font-bold;
color: var(--color-text);
}
h2 {
@apply text-2xl font-semibold;
color: var(--color-text);
}
h3 {
@apply text-xl font-semibold;
color: var(--color-text);
}
}
@layer components {
.btn-primary {
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
background-color: var(--color-primary-button);
color: var(--color-primary-button-text);
}
.btn-primary:hover {
opacity: 0.9;
}
.btn-secondary {
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
background-color: var(--color-secondary-button);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.btn-secondary:hover {
background-color: var(--color-content-bg-hover);
}
.btn-danger {
@apply rounded-lg px-4 py-2 font-semibold transition-colors;
background-color: var(--color-error);
color: #ffffff;
}
.btn-danger:hover {
opacity: 0.9;
}
.input-field {
@apply w-full rounded-lg px-4 py-2 transition-colors;
background-color: var(--color-content-bg);
color: var(--color-text);
border: 1px solid var(--color-border);
}
.input-field:focus {
outline: none;
border-color: var(--color-primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-primary) 20%, transparent);
}
.card {
@apply rounded-lg p-6 shadow-sm;
background-color: var(--color-content-bg);
color: var(--color-text);
}
/* Header & Navigation */
.header-style {
background-color: var(--color-menu-bg);
border-bottom: 1px solid var(--color-border);
}
.logo-text {
@apply text-2xl font-bold;
color: var(--color-primary);
}
.nav-link {
@apply transition-colors;
color: var(--color-text);
}
.nav-link:hover {
color: var(--color-primary);
}
.user-email {
@apply text-sm;
color: var(--color-text);
opacity: 0.7;
}
/* Main Content Area */
.main-content {
background-color: var(--color-page-bg);
}
/* Selected/Active State */
.bg-selected {
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
}
/* Status Badge Colors */
.status-completed {
background-color: rgba(76, 175, 80, 0.15);
color: #4caf50;
}
.status-processing {
background-color: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.status-failed {
background-color: color-mix(in srgb, var(--color-error) 15%, transparent);
color: var(--color-error);
}
.status-default {
background-color: color-mix(in srgb, var(--color-text) 10%, transparent);
color: var(--color-text);
}
/* Info/Alert Boxes */
.info-box {
background-color: color-mix(in srgb, var(--color-primary) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--color-primary) 30%, transparent);
}
/* Loading Spinner */
.spinner-border {
border-color: var(--color-primary);
}
/* Focus Ring */
.focus\:ring-primary:focus {
--tw-ring-color: var(--color-primary);
}
.focus\:ring-2:focus {
box-shadow: 0 0 0 2px var(--tw-ring-color, var(--color-primary));
}
}
@layer utilities {
/* Theme Color Utilities - in utilities layer for @apply support */
.bg-content {
background-color: var(--color-content-bg);
}
.bg-content-hover {
background-color: var(--color-content-bg-hover);
}
.hover\:bg-content-hover:hover {
background-color: var(--color-content-bg-hover);
}
.bg-menu {
background-color: var(--color-menu-bg);
}
.bg-menu-hover {
background-color: var(--color-menu-bg-hover);
}
.hover\:bg-menu-hover:hover {
background-color: var(--color-menu-bg-hover);
}
.bg-panel {
background-color: var(--color-panel-bg);
}
.bg-page {
background-color: var(--color-page-bg);
}
.border-theme {
border-color: var(--color-border);
}
.border-theme-light {
border-color: var(--color-border-light);
}
.text-theme {
color: var(--color-text);
}
.text-theme-secondary {
color: var(--color-text);
opacity: 0.6;
}
.text-theme-muted {
color: var(--color-text);
opacity: 0.4;
}
.text-primary {
color: var(--color-primary);
}
.bg-primary {
background-color: var(--color-primary);
}
.bg-primary-button {
background-color: var(--color-primary-button);
}
.text-primary-button-text {
color: var(--color-primary-button-text);
}
.bg-secondary-button {
background-color: var(--color-secondary-button);
}
}

View file

@ -0,0 +1,15 @@
/**
* @manacore/shared-tailwind
*
* Shared Tailwind CSS configuration for all ManaCore apps.
*
* Exports:
* - preset: Tailwind preset with colors, themes, and design tokens
* - colors: Color palette definitions
*
* Also available:
* - @manacore/shared-tailwind/themes.css: CSS custom properties for runtime theming
*/
export { default as preset } from './preset.js';
export { colors, default as defaultColors } from './colors.js';

View file

@ -0,0 +1,158 @@
/**
* Shared Tailwind CSS preset for all ManaCore apps
*
* Usage in tailwind.config.js:
* ```
* import sharedPreset from '@manacore/shared-tailwind/preset';
*
* export default {
* presets: [sharedPreset],
* content: ['./src/**\/*.{html,js,svelte,ts}'],
* // app-specific overrides...
* }
* ```
*/
import { colors } from './colors.js';
/** @type {import('tailwindcss').Config} */
const preset = {
darkMode: 'class',
theme: {
extend: {
colors: {
// Brand colors
mana: colors.mana,
// Primary scale
primary: colors.primary,
// Semantic colors using CSS custom properties
// These can be changed at runtime via themes.css
background: 'var(--color-background)',
foreground: 'var(--color-foreground)',
// Content areas
content: {
DEFAULT: 'var(--color-content)',
hover: 'var(--color-content-hover)',
page: 'var(--color-content-page)',
},
// Menu/sidebar
menu: {
DEFAULT: 'var(--color-menu)',
hover: 'var(--color-menu-hover)',
},
// Text
theme: {
DEFAULT: 'var(--color-text)',
secondary: 'var(--color-text-secondary)',
},
// Borders
border: {
light: 'var(--color-border-light)',
DEFAULT: 'var(--color-border)',
strong: 'var(--color-border-strong)',
},
// Buttons
'primary-btn': {
DEFAULT: 'var(--color-primary-button)',
text: 'var(--color-primary-button-text)',
},
'secondary-btn': 'var(--color-secondary-button)',
// Feedback colors
error: 'var(--color-error)',
success: 'var(--color-success)',
warning: 'var(--color-warning)',
// Direct theme colors (for apps that don't use CSS vars)
lume: {
...colors.lume.light,
dark: colors.lume.dark,
},
nature: {
...colors.nature.light,
dark: colors.nature.dark,
},
stone: {
...colors.stone.light,
dark: colors.stone.dark,
},
ocean: {
...colors.ocean.light,
dark: colors.ocean.dark,
},
},
// Border radius tokens
borderRadius: {
'none': '0',
'sm': '0.25rem',
DEFAULT: '0.375rem',
'md': '0.5rem',
'lg': '0.75rem',
'xl': '1rem',
'2xl': '1.5rem',
'3xl': '2rem',
'full': '9999px',
},
// Box shadow tokens
boxShadow: {
'sm': '0 1px 2px 0 rgb(0 0 0 / 0.05)',
DEFAULT: '0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1)',
'md': '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)',
'lg': '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)',
'xl': '0 20px 25px -5px rgb(0 0 0 / 0.1), 0 8px 10px -6px rgb(0 0 0 / 0.1)',
'2xl': '0 25px 50px -12px rgb(0 0 0 / 0.25)',
'inner': 'inset 0 2px 4px 0 rgb(0 0 0 / 0.05)',
'none': 'none',
},
// Font family
fontFamily: {
sans: [
'Inter',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'Segoe UI',
'Roboto',
'Helvetica Neue',
'Arial',
'sans-serif',
],
mono: [
'JetBrains Mono',
'Fira Code',
'Menlo',
'Monaco',
'Consolas',
'monospace',
],
},
// Animation
animation: {
'spin-slow': 'spin 3s linear infinite',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
'bounce-slow': 'bounce 2s infinite',
},
// Transition
transitionDuration: {
'250': '250ms',
'350': '350ms',
'400': '400ms',
},
},
},
plugins: [],
};
export default preset;

View file

@ -0,0 +1,183 @@
/* Shared Theme CSS Variables for Manacore Apps
* Import this in your app.css: @import '@manacore/shared-tailwind/theme.css';
*
* Features:
* - 4 Theme Variants: Lume (default), Nature, Stone, Ocean
* - Light and Dark mode for each theme
* - Uses data-theme attribute for variant switching
* - Uses .dark class for dark mode
*/
:root {
--font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu,
Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
--font-mono: 'Fira Mono', monospace;
}
/* Default Theme: Lume Light */
:root {
--color-primary: #f8d62b;
--color-primary-button: #f8d62b;
--color-primary-button-text: #000000;
--color-secondary: #d4b200;
--color-secondary-button: #ffe9a3;
--color-content-bg: #ffffff;
--color-content-bg-hover: #f5f5f5;
--color-content-page-bg: #ffffff;
--color-menu-bg: #dddddd;
--color-menu-bg-hover: #cccccc;
--color-panel-bg: #e8e8e8;
--color-page-bg: #dddddd;
--color-text: #2c2c2c;
--color-border-light: #f2f2f2;
--color-border: #999999;
--color-border-strong: #cccccc;
--color-error: #e74c3c;
}
/* Lume Dark */
:root.dark {
--color-primary: #f8d62b;
--color-primary-button: #7c6b16;
--color-primary-button-text: #ffffff;
--color-secondary: #d4b200;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #333333;
--color-content-page-bg: #121212;
--color-menu-bg: #101010;
--color-menu-bg-hover: #333333;
--color-panel-bg: #1a1a1a;
--color-page-bg: #101010;
--color-text: #ffffff;
--color-border-light: #333333;
--color-border: #424242;
--color-border-strong: #616161;
--color-error: #e74c3c;
}
/* Nature Light */
:root[data-theme='nature'] {
--color-primary: #4caf50;
--color-primary-button: #a08500;
--color-primary-button-text: #ffffff;
--color-secondary: #81c784;
--color-secondary-button: #f1f8e9;
--color-content-bg: #f1f8e9;
--color-content-bg-hover: #e8f5e9;
--color-content-page-bg: #ffffff;
--color-menu-bg: #e8f5e9;
--color-menu-bg-hover: #c8e6c9;
--color-panel-bg: #eff8f0;
--color-page-bg: #fbfdf8;
--color-text: #1b5e20;
--color-border-light: #e8f5e9;
--color-border: #c8e6c9;
--color-border-strong: #a5d6a7;
--color-error: #e57373;
}
/* Nature Dark */
:root[data-theme='nature'].dark {
--color-primary: #4caf50;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #81c784;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #2e7d32;
--color-content-page-bg: #121212;
--color-menu-bg: #252525;
--color-menu-bg-hover: #2e7d32;
--color-panel-bg: #2a2a2a;
--color-page-bg: #121212;
--color-text: #ffffff;
--color-border-light: #1b5e20;
--color-border: #2e7d32;
--color-border-strong: #388e3c;
--color-error: #cf6679;
}
/* Stone Light */
:root[data-theme='stone'] {
--color-primary: #607d8b;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #90a4ae;
--color-secondary-button: #eceff1;
--color-content-bg: #eceff1;
--color-content-bg-hover: #e0e6ea;
--color-content-page-bg: #ffffff;
--color-menu-bg: #e0e6ea;
--color-menu-bg-hover: #cfd8dc;
--color-panel-bg: #e8edf0;
--color-page-bg: #f5f7f9;
--color-text: #263238;
--color-border-light: #eceff1;
--color-border: #cfd8dc;
--color-border-strong: #b0bec5;
--color-error: #ef5350;
}
/* Stone Dark */
:root[data-theme='stone'].dark {
--color-primary: #78909c;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #90a4ae;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #37474f;
--color-content-page-bg: #121212;
--color-menu-bg: #252525;
--color-menu-bg-hover: #37474f;
--color-panel-bg: #2a2a2a;
--color-page-bg: #121212;
--color-text: #ffffff;
--color-border-light: #37474f;
--color-border: #455a64;
--color-border-strong: #546e7a;
--color-error: #cf6679;
}
/* Ocean Light */
:root[data-theme='ocean'] {
--color-primary: #039be5;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #4fc3f7;
--color-secondary-button: #e1f5fe;
--color-content-bg: #e1f5fe;
--color-content-bg-hover: #b3e5fc;
--color-content-page-bg: #ffffff;
--color-menu-bg: #e1f5fe;
--color-menu-bg-hover: #b3e5fc;
--color-panel-bg: #ecf8fe;
--color-page-bg: #f5fcff;
--color-text: #01579b;
--color-border-light: #e1f5fe;
--color-border: #b3e5fc;
--color-border-strong: #81d4fa;
--color-error: #ef5350;
}
/* Ocean Dark */
:root[data-theme='ocean'].dark {
--color-primary: #039be5;
--color-primary-button: #ff9500;
--color-primary-button-text: #000000;
--color-secondary: #4fc3f7;
--color-secondary-button: #1e1e1e;
--color-content-bg: #1e1e1e;
--color-content-bg-hover: #0277bd;
--color-content-page-bg: #121212;
--color-menu-bg: #252525;
--color-menu-bg-hover: #0277bd;
--color-panel-bg: #2a2a2a;
--color-page-bg: #121212;
--color-text: #ffffff;
--color-border-light: #01579b;
--color-border: #0277bd;
--color-border-strong: #0288d1;
--color-error: #cf6679;
}

View file

@ -0,0 +1,265 @@
/**
* CSS Custom Properties for ManaCore themes
*
* Usage:
* 1. Import in your app.css: @import '@manacore/shared-tailwind/themes.css';
* 2. Set theme with data-theme attribute: <html data-theme="lume">
* 3. Dark mode is automatic with .dark class or prefers-color-scheme
*/
/* Default: Lume Light Theme */
:root,
[data-theme="lume"] {
--color-primary: #f8d62b;
--color-primary-button: #f8d62b;
--color-primary-button-text: #000000;
--color-secondary: #D4B200;
--color-secondary-button: #FFE9A3;
--color-background: #dddddd;
--color-foreground: #2c2c2c;
--color-content: #ffffff;
--color-content-hover: #f5f5f5;
--color-content-page: #ffffff;
--color-menu: #dddddd;
--color-menu-hover: #cccccc;
--color-text: #2c2c2c;
--color-text-secondary: #666666;
--color-border-light: #f2f2f2;
--color-border: #e6e6e6;
--color-border-strong: #cccccc;
--color-error: #e74c3c;
--color-success: #27ae60;
--color-warning: #f39c12;
}
/* Lume Dark */
.dark,
[data-theme="lume"].dark,
[data-theme="lume"] .dark {
--color-primary: #f8d62b;
--color-primary-button: #7C6B16;
--color-primary-button-text: #ffffff;
--color-secondary: #D4B200;
--color-secondary-button: #1E1E1E;
--color-background: #101010;
--color-foreground: #ffffff;
--color-content: #1E1E1E;
--color-content-hover: #333333;
--color-content-page: #121212;
--color-menu: #101010;
--color-menu-hover: #333333;
--color-text: #ffffff;
--color-text-secondary: #a0a0a0;
--color-border-light: #333333;
--color-border: #424242;
--color-border-strong: #616161;
--color-error: #e74c3c;
--color-success: #2ecc71;
--color-warning: #f1c40f;
}
/* Nature Theme */
[data-theme="nature"] {
--color-primary: #4CAF50;
--color-primary-button: #A08500;
--color-primary-button-text: #ffffff;
--color-secondary: #81C784;
--color-secondary-button: #F1F8E9;
--color-background: #FBFDF8;
--color-foreground: #1B5E20;
--color-content: #F1F8E9;
--color-content-hover: #E8F5E9;
--color-content-page: #ffffff;
--color-menu: #E8F5E9;
--color-menu-hover: #C8E6C9;
--color-text: #1B5E20;
--color-text-secondary: #388E3C;
--color-border-light: #E8F5E9;
--color-border: #C8E6C9;
--color-border-strong: #A5D6A7;
--color-error: #E57373;
--color-success: #66BB6A;
--color-warning: #FFB74D;
}
[data-theme="nature"].dark,
[data-theme="nature"] .dark {
--color-primary: #4CAF50;
--color-primary-button: #FF9500;
--color-primary-button-text: #000000;
--color-secondary: #81C784;
--color-secondary-button: #1E1E1E;
--color-background: #121212;
--color-foreground: #FFFFFF;
--color-content: #1E1E1E;
--color-content-hover: #2E7D32;
--color-content-page: #121212;
--color-menu: #252525;
--color-menu-hover: #2E7D32;
--color-text: #FFFFFF;
--color-text-secondary: #A5D6A7;
--color-border-light: #1B5E20;
--color-border: #2E7D32;
--color-border-strong: #388E3C;
--color-error: #CF6679;
--color-success: #81C784;
--color-warning: #FFD54F;
}
/* Stone Theme */
[data-theme="stone"] {
--color-primary: #607D8B;
--color-primary-button: #FF9500;
--color-primary-button-text: #000000;
--color-secondary: #90A4AE;
--color-secondary-button: #ECEFF1;
--color-background: #F5F7F9;
--color-foreground: #263238;
--color-content: #ECEFF1;
--color-content-hover: #E0E6EA;
--color-content-page: #ffffff;
--color-menu: #E0E6EA;
--color-menu-hover: #CFD8DC;
--color-text: #263238;
--color-text-secondary: #546E7A;
--color-border-light: #ECEFF1;
--color-border: #CFD8DC;
--color-border-strong: #B0BEC5;
--color-error: #EF5350;
--color-success: #66BB6A;
--color-warning: #FFA726;
}
[data-theme="stone"].dark,
[data-theme="stone"] .dark {
--color-primary: #78909C;
--color-primary-button: #FF9500;
--color-primary-button-text: #000000;
--color-secondary: #90A4AE;
--color-secondary-button: #1E1E1E;
--color-background: #121212;
--color-foreground: #FFFFFF;
--color-content: #1E1E1E;
--color-content-hover: #37474F;
--color-content-page: #121212;
--color-menu: #252525;
--color-menu-hover: #37474F;
--color-text: #FFFFFF;
--color-text-secondary: #B0BEC5;
--color-border-light: #37474F;
--color-border: #455A64;
--color-border-strong: #546E7A;
--color-error: #CF6679;
--color-success: #81C784;
--color-warning: #FFD54F;
}
/* Ocean Theme */
[data-theme="ocean"] {
--color-primary: #039BE5;
--color-primary-button: #FF9500;
--color-primary-button-text: #000000;
--color-secondary: #4FC3F7;
--color-secondary-button: #E1F5FE;
--color-background: #F5FCFF;
--color-foreground: #01579B;
--color-content: #E1F5FE;
--color-content-hover: #B3E5FC;
--color-content-page: #ffffff;
--color-menu: #E1F5FE;
--color-menu-hover: #B3E5FC;
--color-text: #01579B;
--color-text-secondary: #0277BD;
--color-border-light: #E1F5FE;
--color-border: #B3E5FC;
--color-border-strong: #81D4FA;
--color-error: #EF5350;
--color-success: #66BB6A;
--color-warning: #FFA726;
}
[data-theme="ocean"].dark,
[data-theme="ocean"] .dark {
--color-primary: #039BE5;
--color-primary-button: #FF9500;
--color-primary-button-text: #000000;
--color-secondary: #4FC3F7;
--color-secondary-button: #1E1E1E;
--color-background: #121212;
--color-foreground: #FFFFFF;
--color-content: #1E1E1E;
--color-content-hover: #0277BD;
--color-content-page: #121212;
--color-menu: #252525;
--color-menu-hover: #0277BD;
--color-text: #FFFFFF;
--color-text-secondary: #81D4FA;
--color-border-light: #01579B;
--color-border: #0277BD;
--color-border-strong: #0288D1;
--color-error: #CF6679;
--color-success: #81C784;
--color-warning: #FFD54F;
}
/* Dark mode via media query */
@media (prefers-color-scheme: dark) {
:root:not([data-theme]) {
--color-primary: #f8d62b;
--color-primary-button: #7C6B16;
--color-primary-button-text: #ffffff;
--color-secondary: #D4B200;
--color-secondary-button: #1E1E1E;
--color-background: #101010;
--color-foreground: #ffffff;
--color-content: #1E1E1E;
--color-content-hover: #333333;
--color-content-page: #121212;
--color-menu: #101010;
--color-menu-hover: #333333;
--color-text: #ffffff;
--color-text-secondary: #a0a0a0;
--color-border-light: #333333;
--color-border: #424242;
--color-border-strong: #616161;
--color-error: #e74c3c;
--color-success: #2ecc71;
--color-warning: #f1c40f;
}
}

View file

@ -0,0 +1,93 @@
/**
* Authentication-related types
*/
/**
* Authentication state
*/
export type AuthState = 'loading' | 'authenticated' | 'unauthenticated';
/**
* User session
*/
export interface Session {
accessToken: string;
refreshToken: string;
expiresAt: number;
user: AuthUser;
}
/**
* Authenticated user
*/
export interface AuthUser {
id: string;
email: string;
emailConfirmed?: boolean;
phone?: string;
phoneConfirmed?: boolean;
createdAt: string;
updatedAt: string;
lastSignInAt?: string;
appMetadata?: Record<string, unknown>;
userMetadata?: Record<string, unknown>;
}
/**
* Sign in credentials
*/
export interface SignInCredentials {
email: string;
password: string;
}
/**
* Sign up credentials
*/
export interface SignUpCredentials {
email: string;
password: string;
metadata?: Record<string, unknown>;
}
/**
* OAuth provider
*/
export type OAuthProvider = 'google' | 'apple' | 'github' | 'facebook';
/**
* Auth result for operations
*/
export interface AuthResult {
success: boolean;
error?: string;
session?: Session;
user?: AuthUser;
}
/**
* Password reset request
*/
export interface PasswordResetRequest {
email: string;
}
/**
* Password update request
*/
export interface PasswordUpdateRequest {
password: string;
}
/**
* Auth context value
*/
export interface AuthContextValue {
state: AuthState;
user: AuthUser | null;
session: Session | null;
signIn: (credentials: SignInCredentials) => Promise<AuthResult>;
signUp: (credentials: SignUpCredentials) => Promise<AuthResult>;
signOut: () => Promise<void>;
resetPassword: (email: string) => Promise<AuthResult>;
}

View file

@ -0,0 +1,131 @@
/**
* Common utility types
*/
/**
* Result type for operations that can fail
*/
export type Result<T, E = Error> =
| { success: true; data: T }
| { success: false; error: E };
/**
* Async result type
*/
export type AsyncResult<T, E = Error> = Promise<Result<T, E>>;
/**
* Make all properties optional recursively
*/
export type DeepPartial<T> = {
[P in keyof T]?: T[P] extends object ? DeepPartial<T[P]> : T[P];
};
/**
* Make specific properties required
*/
export type RequiredFields<T, K extends keyof T> = T & Required<Pick<T, K>>;
/**
* Make specific properties optional
*/
export type OptionalFields<T, K extends keyof T> = Omit<T, K> & Partial<Pick<T, K>>;
/**
* Extract the type from a Promise
*/
export type Awaited<T> = T extends Promise<infer U> ? U : T;
/**
* Nullable type
*/
export type Nullable<T> = T | null;
/**
* Maybe type (nullable and optional)
*/
export type Maybe<T> = T | null | undefined;
/**
* Dictionary type
*/
export type Dictionary<T = unknown> = Record<string, T>;
/**
* ID type (for entities)
*/
export type ID = string;
/**
* Timestamp type
*/
export type Timestamp = string; // ISO 8601 format
/**
* Sort direction
*/
export type SortDirection = 'asc' | 'desc';
/**
* Sort configuration
*/
export interface SortConfig<T = string> {
field: T;
direction: SortDirection;
}
/**
* Filter operator
*/
export type FilterOperator = 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'like' | 'in';
/**
* Filter configuration
*/
export interface FilterConfig<T = string> {
field: T;
operator: FilterOperator;
value: unknown;
}
/**
* Entity with timestamps
*/
export interface TimestampedEntity {
created_at: Timestamp;
updated_at: Timestamp;
}
/**
* Entity with user ownership
*/
export interface OwnedEntity extends TimestampedEntity {
user_id: ID;
}
/**
* Locale code
*/
export type LocaleCode = 'en' | 'de' | 'fr' | 'es' | 'it' | 'pt' | 'nl' | 'pl' | 'ru' | 'ja' | 'ko' | 'zh';
/**
* Localized string
*/
export type LocalizedString = {
[key in LocaleCode]?: string;
};
/**
* Event handler type
*/
export type EventHandler<T = void> = (event: T) => void;
/**
* Callback type
*/
export type Callback<T = void> = () => T;
/**
* Async callback type
*/
export type AsyncCallback<T = void> = () => Promise<T>;

View file

@ -4,7 +4,19 @@
* This package contains common TypeScript types used across all projects.
*/
// Common user types
// Theme types
export * from './theme';
// Auth types
export * from './auth';
// UI types
export * from './ui';
// Common utility types
export * from './common';
// API types
export interface User {
id: string;
email: string;
@ -12,7 +24,6 @@ export interface User {
updated_at: string;
}
// Common API response types
export interface ApiResponse<T> {
data: T | null;
error: ApiError | null;

View file

@ -0,0 +1,67 @@
/**
* Theme-related types
*/
/**
* Available theme names
*/
export type ThemeName = 'lume' | 'nature' | 'stone' | 'ocean';
/**
* Color mode
*/
export type ColorMode = 'light' | 'dark' | 'system';
/**
* Theme configuration
*/
export interface ThemeConfig {
name: ThemeName;
mode: ColorMode;
}
/**
* Theme color tokens
*/
export interface ThemeColors {
primary: string;
primaryButton: string;
primaryButtonText: string;
secondary: string;
secondaryButton: string;
contentBackground: string;
contentBackgroundHover: string;
contentPageBackground: string;
menuBackground: string;
menuBackgroundHover: string;
pageBackground: string;
text: string;
textSecondary: string;
borderLight: string;
border: string;
borderStrong: string;
error: string;
success: string;
warning: string;
}
/**
* Complete theme with light and dark variants
*/
export interface Theme {
name: ThemeName;
light: ThemeColors;
dark: ThemeColors;
}
/**
* Theme context value
*/
export interface ThemeContextValue {
theme: ThemeName;
mode: ColorMode;
isDark: boolean;
setTheme: (theme: ThemeName) => void;
setMode: (mode: ColorMode) => void;
toggleMode: () => void;
}

View file

@ -0,0 +1,109 @@
/**
* UI-related types
*/
/**
* Common size variants
*/
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
/**
* Button variants
*/
export type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger' | 'link';
/**
* Text variants
*/
export type TextVariant = 'h1' | 'h2' | 'h3' | 'h4' | 'body' | 'small' | 'muted' | 'code';
/**
* Font weight
*/
export type FontWeight = 'normal' | 'medium' | 'semibold' | 'bold';
/**
* Badge variants
*/
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'error';
/**
* Input types
*/
export type InputType = 'text' | 'email' | 'password' | 'number' | 'tel' | 'url' | 'search';
/**
* Toast/notification types
*/
export type ToastType = 'info' | 'success' | 'warning' | 'error';
/**
* Toast notification
*/
export interface Toast {
id: string;
type: ToastType;
message: string;
title?: string;
duration?: number;
dismissible?: boolean;
}
/**
* Modal configuration
*/
export interface ModalConfig {
title?: string;
description?: string;
confirmText?: string;
cancelText?: string;
dangerous?: boolean;
}
/**
* Dropdown/Select option
*/
export interface SelectOption<T = string> {
value: T;
label: string;
disabled?: boolean;
icon?: string;
}
/**
* Tab item
*/
export interface TabItem {
id: string;
label: string;
icon?: string;
disabled?: boolean;
badge?: string | number;
}
/**
* Menu item
*/
export interface MenuItem {
id: string;
label: string;
icon?: string;
href?: string;
onClick?: () => void;
disabled?: boolean;
danger?: boolean;
divider?: boolean;
}
/**
* Breadcrumb item
*/
export interface BreadcrumbItem {
label: string;
href?: string;
}
/**
* Loading state
*/
export type LoadingState = 'idle' | 'loading' | 'success' | 'error';

View file

@ -2,22 +2,20 @@
"name": "@manacore/shared-ui",
"version": "0.1.0",
"private": true,
"description": "Shared React Native UI components for Manacore monorepo",
"type": "module",
"svelte": "./src/index.ts",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"type-check": "tsc --noEmit",
"clean": "rm -rf dist"
".": "./src/index.ts",
"./atoms": "./src/atoms/index.ts",
"./molecules": "./src/molecules/index.ts",
"./organisms": "./src/organisms/index.ts"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.70.0"
"svelte": "^5.0.0"
},
"devDependencies": {
"@types/react": "^18.3.0",
"typescript": "^5.9.3"
"dependencies": {
"@manacore/shared-icons": "workspace:*"
}
}

View file

@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type BadgeVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
type BadgeSize = 'sm' | 'md';
interface Props {
variant?: BadgeVariant;
size?: BadgeSize;
class?: string;
children?: Snippet;
}
let {
variant = 'default',
size = 'md',
class: className = '',
children
}: Props = $props();
const variantClasses: Record<BadgeVariant, string> = {
default: 'bg-menu text-theme border-theme',
primary: 'bg-primary/20 text-primary border-primary/30',
success: 'bg-green-500/20 text-green-600 dark:text-green-400 border-green-500/30',
warning: 'bg-yellow-500/20 text-yellow-600 dark:text-yellow-400 border-yellow-500/30',
danger: 'bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/30',
info: 'bg-blue-500/20 text-blue-600 dark:text-blue-400 border-blue-500/30'
};
const sizeClasses: Record<BadgeSize, string> = {
sm: 'px-1.5 py-0.5 text-xs',
md: 'px-2 py-1 text-sm'
};
const classes = $derived(
`inline-flex items-center rounded-full border font-medium ${variantClasses[variant]} ${sizeClasses[size]} ${className}`
);
</script>
<span class={classes}>
{@render children?.()}
</span>

View file

@ -0,0 +1,60 @@
<script lang="ts">
import type { Snippet } from 'svelte';
type ButtonVariant = 'primary' | 'secondary' | 'ghost' | 'danger';
type ButtonSize = 'sm' | 'md' | 'lg';
interface Props {
variant?: ButtonVariant;
size?: ButtonSize;
disabled?: boolean;
loading?: boolean;
class?: string;
onclick?: (e: MouseEvent) => void;
type?: 'button' | 'submit' | 'reset';
children?: Snippet;
}
let {
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
class: className = '',
onclick,
type = 'button',
children
}: Props = $props();
const variantClasses: Record<ButtonVariant, string> = {
primary: 'bg-primary text-white hover:bg-primary/90 border-transparent',
secondary: 'bg-menu text-theme hover:bg-menu-hover border-theme',
ghost: 'bg-transparent text-theme hover:bg-menu-hover border-transparent',
danger: 'bg-red-600 text-white hover:bg-red-700 border-transparent'
};
const sizeClasses: Record<ButtonSize, string> = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-base',
lg: 'px-6 py-3 text-lg'
};
const classes = $derived(
`inline-flex items-center justify-center gap-2 rounded-lg border font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed ${variantClasses[variant]} ${sizeClasses[size]} ${className}`
);
</script>
<button
{type}
class={classes}
disabled={disabled || loading}
{onclick}
>
{#if loading}
<svg class="h-4 w-4 animate-spin" fill="none" viewBox="0 0 24 24">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
{/if}
{@render children?.()}
</button>

View file

@ -0,0 +1,53 @@
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
type TextVariant = 'body' | 'body-secondary' | 'small' | 'large' | 'muted';
type TextAlign = 'left' | 'center' | 'right';
type TextWeight = 'normal' | 'medium' | 'semibold' | 'bold';
interface Props extends HTMLAttributes<HTMLParagraphElement> {
variant?: TextVariant;
align?: TextAlign;
weight?: TextWeight;
class?: string;
children?: any;
}
let {
variant = 'body',
align = 'left',
weight = 'normal',
class: className = '',
children,
...restProps
}: Props = $props();
const variantClasses: Record<TextVariant, string> = {
body: 'text-base text-theme leading-relaxed',
'body-secondary': 'text-base text-theme-secondary leading-relaxed',
small: 'text-sm text-theme',
large: 'text-lg text-theme leading-relaxed',
muted: 'text-sm text-theme-muted'
};
const alignClasses: Record<TextAlign, string> = {
left: 'text-left',
center: 'text-center',
right: 'text-right'
};
const weightClasses: Record<TextWeight, string> = {
normal: 'font-normal',
medium: 'font-medium',
semibold: 'font-semibold',
bold: 'font-bold'
};
const classes = $derived(
`${variantClasses[variant]} ${alignClasses[align]} ${weightClasses[weight]} ${className}`
);
</script>
<p class={classes} {...restProps}>
{@render children?.()}
</p>

View file

@ -0,0 +1,3 @@
export { default as Text } from './Text.svelte';
export { default as Button } from './Button.svelte';
export { default as Badge } from './Badge.svelte';

View file

@ -1,24 +1,8 @@
/**
* Shared React Native UI components for Manacore monorepo
*
* This package will contain common UI components used across all mobile apps.
*
* Planned components:
* - Button
* - Text
* - Input
* - Card
* - Modal
* - Loading indicators
* - Icons
*/
// Atoms
export { Text, Button, Badge } from './atoms';
// Placeholder export until components are migrated
export const SHARED_UI_VERSION = '0.1.0';
// Molecules
export { Toggle, Input } from './molecules';
// Future exports will include:
// export { Button } from './components/Button';
// export { Text } from './components/Text';
// export { Input } from './components/Input';
// export { Card } from './components/Card';
// export { Modal } from './components/Modal';
// Organisms
export { Modal } from './organisms';

View file

@ -0,0 +1,71 @@
<script lang="ts">
import type { HTMLInputAttributes } from 'svelte/elements';
interface Props {
value: string;
oninput?: (value: string) => void;
onchange?: (value: string) => void;
label?: string;
placeholder?: string;
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
error?: string;
disabled?: boolean;
required?: boolean;
autocomplete?: HTMLInputAttributes['autocomplete'];
class?: string;
}
let {
value = $bindable(),
oninput,
onchange,
label,
placeholder,
type = 'text',
error,
disabled = false,
required = false,
autocomplete,
class: className = ''
}: Props = $props();
function handleInput(e: Event) {
const target = e.target as HTMLInputElement;
value = target.value;
oninput?.(target.value);
}
function handleChange(e: Event) {
const target = e.target as HTMLInputElement;
onchange?.(target.value);
}
</script>
<div class="flex flex-col gap-1.5 {className}">
{#if label}
<label class="text-sm font-medium text-theme">
{label}
{#if required}
<span class="text-red-500">*</span>
{/if}
</label>
{/if}
<input
{type}
{value}
{placeholder}
{disabled}
{required}
autocomplete={autocomplete as HTMLInputAttributes['autocomplete']}
oninput={handleInput}
onchange={handleChange}
class="w-full rounded-lg border px-4 py-2.5 text-theme bg-content transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 disabled:opacity-50 disabled:cursor-not-allowed {error
? 'border-red-500 focus:ring-red-500/50'
: 'border-theme'}"
/>
{#if error}
<p class="text-sm text-red-500">{error}</p>
{/if}
</div>

View file

@ -0,0 +1,37 @@
<script lang="ts">
interface Props {
isOn: boolean;
onToggle: (value: boolean) => void;
disabled?: boolean;
size?: 'sm' | 'md';
}
let { isOn = false, onToggle, disabled = false, size = 'md' }: Props = $props();
function handleToggle() {
if (!disabled) {
onToggle(!isOn);
}
}
const sizeClasses = {
sm: { track: 'h-6 w-10', thumb: 'h-4 w-4 top-1 left-1', translate: 'translate-x-4' },
md: { track: 'h-8 w-14', thumb: 'h-6 w-6 top-1 left-1', translate: 'translate-x-6' }
};
</script>
<button
onclick={handleToggle}
class="relative {sizeClasses[size].track} flex-shrink-0 rounded-full transition-colors {isOn
? 'bg-primary'
: 'bg-menu'} {disabled ? 'cursor-not-allowed opacity-50' : 'cursor-pointer'}"
role="switch"
aria-checked={isOn}
{disabled}
>
<span
class="absolute {sizeClasses[size].thumb} rounded-full bg-white shadow-md transition-transform {isOn
? sizeClasses[size].translate
: 'translate-x-0'}"
></span>
</button>

View file

@ -0,0 +1,2 @@
export { default as Toggle } from './Toggle.svelte';
export { default as Input } from './Input.svelte';

View file

@ -0,0 +1,92 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Icon } from '@manacore/shared-icons';
import Text from '../atoms/Text.svelte';
interface Props {
visible: boolean;
onClose: () => void;
title?: string;
icon?: Snippet;
children: Snippet;
footer?: Snippet;
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl';
showHeader?: boolean;
}
let { visible, onClose, title, icon, children, footer, maxWidth = 'lg', showHeader = true }: Props = $props();
const maxWidthClasses = {
sm: 'max-w-sm',
md: 'max-w-md',
lg: 'max-w-lg',
xl: 'max-w-xl',
'2xl': 'max-w-2xl',
'3xl': 'max-w-3xl'
};
function handleBackdropClick(e: MouseEvent) {
if (e.target === e.currentTarget) {
onClose();
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && visible) {
onClose();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
{#if visible}
<!-- Modal Backdrop -->
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"
onclick={handleBackdropClick}
role="dialog"
aria-modal="true"
>
<!-- Modal Content -->
<div
class="relative flex max-h-[90vh] w-full {maxWidthClasses[maxWidth]} flex-col rounded-xl border border-theme bg-menu shadow-xl"
onclick={(e) => e.stopPropagation()}
>
{#if showHeader}
<!-- Header -->
<div class="flex items-center justify-between p-6 border-b border-theme">
<div class="flex items-center gap-2 flex-1">
{#if icon}
{@render icon()}
{/if}
{#if title}
<Text variant="large" weight="semibold">
{title}
</Text>
{/if}
</div>
<button
onclick={onClose}
class="p-2 rounded-full hover:bg-menu-hover transition-colors"
aria-label="Close"
>
<Icon name="x" size={20} class="text-theme-muted" />
</button>
</div>
{/if}
<!-- Body (scrollable) -->
<div class="flex-1 overflow-y-auto p-6">
{@render children()}
</div>
<!-- Footer (optional) -->
{#if footer}
<div class="border-t border-theme p-6">
{@render footer()}
</div>
{/if}
</div>
</div>
{/if}

View file

@ -0,0 +1 @@
export { default as Modal } from './Modal.svelte';

View file

@ -1,18 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ES2020"],
"jsx": "react-native",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"declaration": true,
"declarationMap": true,
"outDir": "./dist"
"types": ["svelte"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
"exclude": ["node_modules"]
}

View file

@ -0,0 +1,152 @@
/**
* Formatting utility functions
*/
/**
* Format duration from seconds to MM:SS or HH:MM:SS format
*/
export function formatDuration(seconds: number): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return '--:--';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}:${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`;
}
return `${minutes}:${remainingSeconds.toString().padStart(2, '0')}`;
}
/**
* Format duration from milliseconds
*/
export function formatDurationFromMs(milliseconds: number): string {
return formatDuration(milliseconds / 1000);
}
/**
* Format duration with units (e.g., "2 min 30 sec" or "1h 23m")
*/
export function formatDurationWithUnits(
seconds: number,
locale: 'en' | 'de' = 'en'
): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return locale === 'de' ? 'keine Zeit' : 'no time';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
if (hours > 0) {
return minutes > 0 ? `${hours}h ${minutes}m` : `${hours}h`;
}
if (minutes > 0) {
return remainingSeconds > 0 && minutes < 5
? `${minutes}m ${remainingSeconds}s`
: `${minutes}m`;
}
return `${remainingSeconds}s`;
}
/**
* Format duration to human readable text
*/
export function formatDurationHumanReadable(
seconds: number,
locale: 'en' | 'de' = 'de'
): string {
if (!Number.isFinite(seconds) || seconds < 0) {
return locale === 'de' ? 'keine Zeit' : 'no time';
}
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const remainingSeconds = Math.floor(seconds % 60);
const parts: string[] = [];
if (locale === 'de') {
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? 'Stunde' : 'Stunden'}`);
}
if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? 'Minute' : 'Minuten'}`);
}
if (remainingSeconds > 0 && hours === 0) {
parts.push(`${remainingSeconds} ${remainingSeconds === 1 ? 'Sekunde' : 'Sekunden'}`);
}
return parts.length === 0 ? '0 Sekunden' : parts.join(' ');
} else {
if (hours > 0) {
parts.push(`${hours} ${hours === 1 ? 'hour' : 'hours'}`);
}
if (minutes > 0) {
parts.push(`${minutes} ${minutes === 1 ? 'minute' : 'minutes'}`);
}
if (remainingSeconds > 0 && hours === 0) {
parts.push(`${remainingSeconds} ${remainingSeconds === 1 ? 'second' : 'seconds'}`);
}
return parts.length === 0 ? '0 seconds' : parts.join(' ');
}
}
/**
* Format file size from bytes to human readable string
*/
export function formatFileSize(bytes: number, decimals: number = 1): string {
if (bytes === 0) return '0 B';
if (!Number.isFinite(bytes) || bytes < 0) return '--';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(decimals))} ${sizes[i]}`;
}
/**
* Format number with thousands separator
*/
export function formatNumber(
num: number,
locale: string = 'de-DE'
): string {
return num.toLocaleString(locale);
}
/**
* Format currency
*/
export function formatCurrency(
amount: number,
currency: string = 'EUR',
locale: string = 'de-DE'
): string {
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
}).format(amount);
}
/**
* Format percentage
*/
export function formatPercent(
value: number,
decimals: number = 0,
locale: string = 'de-DE'
): string {
return new Intl.NumberFormat(locale, {
style: 'percent',
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(value);
}

View file

@ -10,3 +10,9 @@ export * from './string';
// Async utilities
export * from './async';
// Format utilities
export * from './format';
// Validation utilities
export * from './validation';

View file

@ -0,0 +1,102 @@
/**
* Validation utility functions
*/
/**
* Validate email address format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Validate URL format
*/
export function isValidUrl(url: string): boolean {
try {
new URL(url);
return true;
} catch {
return false;
}
}
/**
* Validate phone number (basic international format)
*/
export function isValidPhone(phone: string): boolean {
// Allows: +49123456789, 0123456789, +1 (555) 123-4567
const phoneRegex = /^[+]?[(]?[0-9]{1,4}[)]?[-\s./0-9]*$/;
return phoneRegex.test(phone) && phone.replace(/\D/g, '').length >= 6;
}
/**
* Validate password strength
* Returns an object with validation details
*/
export function validatePassword(password: string): {
isValid: boolean;
hasMinLength: boolean;
hasUppercase: boolean;
hasLowercase: boolean;
hasNumber: boolean;
hasSpecialChar: boolean;
score: number;
} {
const hasMinLength = password.length >= 8;
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /[0-9]/.test(password);
const hasSpecialChar = /[!@#$%^&*(),.?":{}|<>]/.test(password);
const score = [hasMinLength, hasUppercase, hasLowercase, hasNumber, hasSpecialChar]
.filter(Boolean).length;
return {
isValid: hasMinLength && hasUppercase && hasLowercase && hasNumber,
hasMinLength,
hasUppercase,
hasLowercase,
hasNumber,
hasSpecialChar,
score,
};
}
/**
* Check if a string is empty or only whitespace
*/
export function isEmpty(value: string | null | undefined): boolean {
return !value || value.trim().length === 0;
}
/**
* Check if a string exceeds max length
*/
export function isWithinMaxLength(value: string, maxLength: number): boolean {
return value.length <= maxLength;
}
/**
* Check if a string meets minimum length
*/
export function meetsMinLength(value: string, minLength: number): boolean {
return value.length >= minLength;
}
/**
* Validate UUID format
*/
export function isValidUuid(uuid: string): boolean {
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
return uuidRegex.test(uuid);
}
/**
* Validate hex color format
*/
export function isValidHexColor(color: string): boolean {
const hexRegex = /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/;
return hexRegex.test(color);
}