mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01:08 +02:00
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:
parent
725db638ea
commit
ef70a1af0b
198 changed files with 11113 additions and 3656 deletions
27
packages/shared-auth-ui/package.json
Normal file
27
packages/shared-auth-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
@ -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>
|
||||
30
packages/shared-auth-ui/src/components/Icon.svelte
Normal file
30
packages/shared-auth-ui/src/components/Icon.svelte
Normal 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}
|
||||
37
packages/shared-auth-ui/src/icons/iconPaths.ts
Normal file
37
packages/shared-auth-ui/src/icons/iconPaths.ts
Normal 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;
|
||||
40
packages/shared-auth-ui/src/index.ts
Normal file
40
packages/shared-auth-ui/src/index.ts
Normal 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';
|
||||
438
packages/shared-auth-ui/src/pages/LoginPage.svelte
Normal file
438
packages/shared-auth-ui/src/pages/LoginPage.svelte
Normal 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>
|
||||
310
packages/shared-auth-ui/src/pages/RegisterPage.svelte
Normal file
310
packages/shared-auth-ui/src/pages/RegisterPage.svelte
Normal 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>
|
||||
80
packages/shared-auth-ui/src/types.ts
Normal file
80
packages/shared-auth-ui/src/types.ts
Normal 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';
|
||||
216
packages/shared-auth-ui/src/utils/appleAuth.ts
Normal file
216
packages/shared-auth-ui/src/utils/appleAuth.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
174
packages/shared-auth-ui/src/utils/googleAuth.ts
Normal file
174
packages/shared-auth-ui/src/utils/googleAuth.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
21
packages/shared-auth-ui/tsconfig.json
Normal file
21
packages/shared-auth-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
210
packages/shared-auth/README.md
Normal file
210
packages/shared-auth/README.md
Normal 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
|
||||
37
packages/shared-auth/package.json
Normal file
37
packages/shared-auth/package.json
Normal 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"
|
||||
}
|
||||
81
packages/shared-auth/src/adapters/device.ts
Normal file
81
packages/shared-auth/src/adapters/device.ts
Normal 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');
|
||||
},
|
||||
};
|
||||
}
|
||||
55
packages/shared-auth/src/adapters/network.ts
Normal file
55
packages/shared-auth/src/adapters/network.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
89
packages/shared-auth/src/adapters/storage.ts
Normal file
89
packages/shared-auth/src/adapters/storage.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
546
packages/shared-auth/src/core/authService.ts
Normal file
546
packages/shared-auth/src/core/authService.ts
Normal 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>;
|
||||
160
packages/shared-auth/src/core/jwtUtils.ts
Normal file
160
packages/shared-auth/src/core/jwtUtils.ts
Normal 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;
|
||||
}
|
||||
464
packages/shared-auth/src/core/tokenManager.ts
Normal file
464
packages/shared-auth/src/core/tokenManager.ts
Normal 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>;
|
||||
99
packages/shared-auth/src/index.ts
Normal file
99
packages/shared-auth/src/index.ts
Normal 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 };
|
||||
}
|
||||
220
packages/shared-auth/src/interceptors/fetchInterceptor.ts
Normal file
220
packages/shared-auth/src/interceptors/fetchInterceptor.ts
Normal 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(),
|
||||
};
|
||||
}
|
||||
178
packages/shared-auth/src/types/index.ts
Normal file
178
packages/shared-auth/src/types/index.ts
Normal 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;
|
||||
}
|
||||
18
packages/shared-auth/tsconfig.json
Normal file
18
packages/shared-auth/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
packages/shared-config/package.json
Normal file
23
packages/shared-config/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
207
packages/shared-config/src/api.ts
Normal file
207
packages/shared-config/src/api.ts
Normal 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;
|
||||
}
|
||||
173
packages/shared-config/src/env.ts
Normal file
173
packages/shared-config/src/env.ts
Normal 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';
|
||||
}
|
||||
173
packages/shared-config/src/features.ts
Normal file
173
packages/shared-config/src/features.ts
Normal 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;
|
||||
}
|
||||
48
packages/shared-config/src/index.ts
Normal file
48
packages/shared-config/src/index.ts
Normal 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';
|
||||
19
packages/shared-config/tsconfig.json
Normal file
19
packages/shared-config/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
packages/shared-i18n/package.json
Normal file
20
packages/shared-i18n/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
46
packages/shared-i18n/src/index.ts
Normal file
46
packages/shared-i18n/src/index.ts
Normal 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';
|
||||
159
packages/shared-i18n/src/languages.ts
Normal file
159
packages/shared-i18n/src/languages.ts
Normal 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];
|
||||
}
|
||||
172
packages/shared-i18n/src/translations/common/de.json
Normal file
172
packages/shared-i18n/src/translations/common/de.json
Normal 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"
|
||||
}
|
||||
}
|
||||
172
packages/shared-i18n/src/translations/common/en.json
Normal file
172
packages/shared-i18n/src/translations/common/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
37
packages/shared-i18n/src/translations/common/index.ts
Normal file
37
packages/shared-i18n/src/translations/common/index.ts
Normal 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;
|
||||
}
|
||||
249
packages/shared-i18n/src/utils.ts
Normal file
249
packages/shared-i18n/src/utils.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
19
packages/shared-i18n/tsconfig.json
Normal file
19
packages/shared-i18n/tsconfig.json
Normal 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"]
|
||||
}
|
||||
34
packages/shared-icons/package.json
Normal file
34
packages/shared-icons/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
39
packages/shared-icons/src/Icon.svelte
Normal file
39
packages/shared-icons/src/Icon.svelte
Normal 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}
|
||||
131
packages/shared-icons/src/iconPaths.ts
Normal file
131
packages/shared-icons/src/iconPaths.ts
Normal 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;
|
||||
2
packages/shared-icons/src/index.ts
Normal file
2
packages/shared-icons/src/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Icon } from './Icon.svelte';
|
||||
export { iconPaths, type IconName } from './iconPaths';
|
||||
16
packages/shared-icons/tsconfig.json
Normal file
16
packages/shared-icons/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
packages/shared-subscription-types/package.json
Normal file
20
packages/shared-subscription-types/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
39
packages/shared-subscription-types/src/index.ts
Normal file
39
packages/shared-subscription-types/src/index.ts
Normal 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';
|
||||
136
packages/shared-subscription-types/src/plans.ts
Normal file
136
packages/shared-subscription-types/src/plans.ts
Normal 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,
|
||||
};
|
||||
99
packages/shared-subscription-types/src/revenueCat.ts
Normal file
99
packages/shared-subscription-types/src/revenueCat.ts
Normal 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;
|
||||
}
|
||||
93
packages/shared-subscription-types/src/usage.ts
Normal file
93
packages/shared-subscription-types/src/usage.ts
Normal 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';
|
||||
}
|
||||
19
packages/shared-subscription-types/tsconfig.json
Normal file
19
packages/shared-subscription-types/tsconfig.json
Normal 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"]
|
||||
}
|
||||
33
packages/shared-subscription-ui/package.json
Normal file
33
packages/shared-subscription-ui/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
52
packages/shared-subscription-ui/src/BillingToggle.svelte
Normal file
52
packages/shared-subscription-ui/src/BillingToggle.svelte
Normal 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>
|
||||
57
packages/shared-subscription-ui/src/CostCard.svelte
Normal file
57
packages/shared-subscription-ui/src/CostCard.svelte
Normal 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>
|
||||
16
packages/shared-subscription-ui/src/ManaIcon.svelte
Normal file
16
packages/shared-subscription-ui/src/ManaIcon.svelte
Normal 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>
|
||||
99
packages/shared-subscription-ui/src/PackageCard.svelte
Normal file
99
packages/shared-subscription-ui/src/PackageCard.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
134
packages/shared-subscription-ui/src/SubscriptionCard.svelte
Normal file
134
packages/shared-subscription-ui/src/SubscriptionCard.svelte
Normal 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>
|
||||
81
packages/shared-subscription-ui/src/UsageCard.svelte
Normal file
81
packages/shared-subscription-ui/src/UsageCard.svelte
Normal 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>
|
||||
24
packages/shared-subscription-ui/src/index.ts
Normal file
24
packages/shared-subscription-ui/src/index.ts
Normal 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';
|
||||
19
packages/shared-subscription-ui/tsconfig.json
Normal file
19
packages/shared-subscription-ui/tsconfig.json
Normal 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"]
|
||||
}
|
||||
20
packages/shared-tailwind/package.json
Normal file
20
packages/shared-tailwind/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
243
packages/shared-tailwind/src/colors.js
Normal file
243
packages/shared-tailwind/src/colors.js
Normal 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;
|
||||
236
packages/shared-tailwind/src/components.css
Normal file
236
packages/shared-tailwind/src/components.css
Normal 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);
|
||||
}
|
||||
}
|
||||
15
packages/shared-tailwind/src/index.js
Normal file
15
packages/shared-tailwind/src/index.js
Normal 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';
|
||||
158
packages/shared-tailwind/src/preset.js
Normal file
158
packages/shared-tailwind/src/preset.js
Normal 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;
|
||||
183
packages/shared-tailwind/src/theme-variables.css
Normal file
183
packages/shared-tailwind/src/theme-variables.css
Normal 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;
|
||||
}
|
||||
265
packages/shared-tailwind/src/themes.css
Normal file
265
packages/shared-tailwind/src/themes.css
Normal 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;
|
||||
}
|
||||
}
|
||||
93
packages/shared-types/src/auth.ts
Normal file
93
packages/shared-types/src/auth.ts
Normal 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>;
|
||||
}
|
||||
131
packages/shared-types/src/common.ts
Normal file
131
packages/shared-types/src/common.ts
Normal 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>;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
67
packages/shared-types/src/theme.ts
Normal file
67
packages/shared-types/src/theme.ts
Normal 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;
|
||||
}
|
||||
109
packages/shared-types/src/ui.ts
Normal file
109
packages/shared-types/src/ui.ts
Normal 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';
|
||||
|
|
@ -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:*"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
42
packages/shared-ui/src/atoms/Badge.svelte
Normal file
42
packages/shared-ui/src/atoms/Badge.svelte
Normal 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>
|
||||
60
packages/shared-ui/src/atoms/Button.svelte
Normal file
60
packages/shared-ui/src/atoms/Button.svelte
Normal 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>
|
||||
53
packages/shared-ui/src/atoms/Text.svelte
Normal file
53
packages/shared-ui/src/atoms/Text.svelte
Normal 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>
|
||||
3
packages/shared-ui/src/atoms/index.ts
Normal file
3
packages/shared-ui/src/atoms/index.ts
Normal 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';
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
71
packages/shared-ui/src/molecules/Input.svelte
Normal file
71
packages/shared-ui/src/molecules/Input.svelte
Normal 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>
|
||||
37
packages/shared-ui/src/molecules/Toggle.svelte
Normal file
37
packages/shared-ui/src/molecules/Toggle.svelte
Normal 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>
|
||||
2
packages/shared-ui/src/molecules/index.ts
Normal file
2
packages/shared-ui/src/molecules/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { default as Toggle } from './Toggle.svelte';
|
||||
export { default as Input } from './Input.svelte';
|
||||
92
packages/shared-ui/src/organisms/Modal.svelte
Normal file
92
packages/shared-ui/src/organisms/Modal.svelte
Normal 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}
|
||||
1
packages/shared-ui/src/organisms/index.ts
Normal file
1
packages/shared-ui/src/organisms/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default as Modal } from './Modal.svelte';
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
|||
152
packages/shared-utils/src/format.ts
Normal file
152
packages/shared-utils/src/format.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -10,3 +10,9 @@ export * from './string';
|
|||
|
||||
// Async utilities
|
||||
export * from './async';
|
||||
|
||||
// Format utilities
|
||||
export * from './format';
|
||||
|
||||
// Validation utilities
|
||||
export * from './validation';
|
||||
|
|
|
|||
102
packages/shared-utils/src/validation.ts
Normal file
102
packages/shared-utils/src/validation.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue