mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
refactor(packages): consolidate 3 credit packages into @manacore/credits
Merged credit-operations + shared-credit-service + shared-credit-ui into @manacore/credits with sub-path exports: - @manacore/credits — operations, costs, service - @manacore/credits/web — Svelte components - @manacore/credits/mobile — React Native components Package count: 47 → 44 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b7e67aef21
commit
11db6c60dc
23 changed files with 100 additions and 293 deletions
27
packages/credits/package.json
Normal file
27
packages/credits/package.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"name": "@manacore/credits",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "Unified credit package — operations, service, and UI components",
|
||||
"type": "module",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./web": {
|
||||
"svelte": "./src/web/index.ts",
|
||||
"default": "./src/web/index.ts"
|
||||
},
|
||||
"./mobile": "./src/mobile/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"svelte": "^5.0.0",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"svelte": "^5.0.0"
|
||||
}
|
||||
}
|
||||
329
packages/credits/src/createCreditService.ts
Normal file
329
packages/credits/src/createCreditService.ts
Normal file
|
|
@ -0,0 +1,329 @@
|
|||
/**
|
||||
* Credit Service Factory
|
||||
*
|
||||
* Creates a credit service instance configured for a specific app.
|
||||
* Handles credit balance fetching, pricing, and consumption notifications.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* import { createCreditService } from './createCreditService';
|
||||
* import { auth } from '$lib/stores/auth';
|
||||
*
|
||||
* export const creditService = createCreditService({
|
||||
* apiUrl: 'https://api.example.com',
|
||||
* pricingEndpoint: '/credits/pricing',
|
||||
* balanceEndpoint: '/auth/credits',
|
||||
* getAuthToken: () => auth.getToken(),
|
||||
* fallbackPricing: {
|
||||
* STORY_CREATION: 10,
|
||||
* CHARACTER_CREATION: 20
|
||||
* }
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
|
||||
import type {
|
||||
CreditServiceConfig,
|
||||
CreditBalance,
|
||||
CreditCheckResponse,
|
||||
PricingResponse,
|
||||
CreditUpdateCallback,
|
||||
StandardOperationType,
|
||||
} from './service-types';
|
||||
import { DEFAULT_OPERATION_PRICING } from './service-types';
|
||||
|
||||
/**
|
||||
* Create a credit service instance
|
||||
*/
|
||||
export function createCreditService(config: CreditServiceConfig) {
|
||||
const {
|
||||
apiUrl,
|
||||
balanceEndpoint = '/auth/credits',
|
||||
pricingEndpoint = '/credits/pricing',
|
||||
cacheDuration = 30 * 60 * 1000, // 30 minutes default
|
||||
fallbackPricing = {},
|
||||
getAuthToken,
|
||||
} = config;
|
||||
|
||||
// Normalize API URL (remove trailing slash)
|
||||
const baseUrl = apiUrl.replace(/\/$/, '');
|
||||
|
||||
// Internal state
|
||||
let cachedPricing: PricingResponse | null = null;
|
||||
let pricingLastFetched = 0;
|
||||
const creditUpdateCallbacks: CreditUpdateCallback[] = [];
|
||||
|
||||
// Merge fallback pricing with defaults
|
||||
const mergedFallbackPricing = {
|
||||
...DEFAULT_OPERATION_PRICING,
|
||||
...fallbackPricing,
|
||||
};
|
||||
|
||||
/**
|
||||
* Initialize the credit service by preloading pricing
|
||||
*/
|
||||
async function initialize(): Promise<void> {
|
||||
try {
|
||||
await getPricing();
|
||||
console.log('[CreditService] Initialized with backend pricing');
|
||||
} catch (error) {
|
||||
console.warn('[CreditService] Initialization failed, using fallback pricing:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback for credit consumption notifications
|
||||
* @returns Unsubscribe function
|
||||
*/
|
||||
function onCreditUpdate(callback: CreditUpdateCallback): () => void {
|
||||
creditUpdateCallbacks.push(callback);
|
||||
|
||||
return () => {
|
||||
const index = creditUpdateCallbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
creditUpdateCallbacks.splice(index, 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all callbacks about credit consumption
|
||||
*/
|
||||
function notifyCreditUpdate(creditsConsumed: number, operation?: string): void {
|
||||
creditUpdateCallbacks.forEach((callback) => {
|
||||
try {
|
||||
callback(creditsConsumed, operation);
|
||||
} catch (error) {
|
||||
console.error('[CreditService] Error in credit update callback:', error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger credit update notifications
|
||||
*/
|
||||
function triggerCreditUpdate(creditsConsumed: number, operation?: string): void {
|
||||
notifyCreditUpdate(creditsConsumed, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch pricing information from backend with caching
|
||||
*/
|
||||
async function getPricing(): Promise<PricingResponse> {
|
||||
const now = Date.now();
|
||||
|
||||
// Return cached pricing if still valid
|
||||
if (cachedPricing && now - pricingLastFetched < cacheDuration) {
|
||||
return cachedPricing;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}${pricingEndpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const pricing = await response.json();
|
||||
cachedPricing = pricing;
|
||||
pricingLastFetched = now;
|
||||
|
||||
return pricing;
|
||||
} catch (error) {
|
||||
console.error('[CreditService] Error fetching pricing:', error);
|
||||
|
||||
// Return cached pricing if available
|
||||
if (cachedPricing) {
|
||||
return cachedPricing;
|
||||
}
|
||||
|
||||
// Return fallback pricing
|
||||
return {
|
||||
operationCosts: mergedFallbackPricing,
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user's credit balance
|
||||
*/
|
||||
async function getBalance(): Promise<CreditBalance | null> {
|
||||
try {
|
||||
const token = await getAuthToken();
|
||||
|
||||
if (!token) {
|
||||
console.error('[CreditService] No authentication token available');
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}${balanceEndpoint}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Handle wrapped response structure
|
||||
if (data.data && typeof data.data.credits === 'number') {
|
||||
return {
|
||||
credits: data.data.credits,
|
||||
maxCreditLimit: data.data.maxCreditLimit ?? data.data.credits,
|
||||
userId: data.data.userId ?? '',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// Handle direct structure
|
||||
if (typeof data.credits === 'number') {
|
||||
return {
|
||||
credits: data.credits,
|
||||
maxCreditLimit: data.maxCreditLimit ?? data.credits,
|
||||
userId: data.userId ?? '',
|
||||
lastUpdated: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('[CreditService] Error fetching balance:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost for a specific operation (async with backend fetch)
|
||||
*/
|
||||
async function getOperationCost(operation: StandardOperationType): Promise<number> {
|
||||
try {
|
||||
const pricing = await getPricing();
|
||||
return pricing.operationCosts[operation] ?? mergedFallbackPricing[operation] ?? 0;
|
||||
} catch (error) {
|
||||
console.error('[CreditService] Error getting operation cost:', error);
|
||||
return mergedFallbackPricing[operation] ?? 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cost for a specific operation (sync, uses cached values)
|
||||
*/
|
||||
function getOperationCostSync(operation: StandardOperationType): number {
|
||||
if (cachedPricing?.operationCosts[operation] !== undefined) {
|
||||
return cachedPricing.operationCosts[operation];
|
||||
}
|
||||
return mergedFallbackPricing[operation] ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost for multiple units of an operation
|
||||
*/
|
||||
async function calculateCost(operation: StandardOperationType, quantity = 1): Promise<number> {
|
||||
const unitCost = await getOperationCost(operation);
|
||||
return unitCost * quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate cost synchronously (uses cached values)
|
||||
*/
|
||||
function calculateCostSync(operation: StandardOperationType, quantity = 1): number {
|
||||
const unitCost = getOperationCostSync(operation);
|
||||
return unitCost * quantity;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has enough credits for an operation
|
||||
*/
|
||||
async function checkBalance(
|
||||
requiredCredits: number,
|
||||
operation?: string
|
||||
): Promise<CreditCheckResponse> {
|
||||
const balance = await getBalance();
|
||||
|
||||
if (!balance) {
|
||||
return {
|
||||
hasEnoughCredits: false,
|
||||
currentCredits: 0,
|
||||
requiredCredits,
|
||||
deficit: requiredCredits,
|
||||
};
|
||||
}
|
||||
|
||||
const hasEnough = balance.credits >= requiredCredits;
|
||||
|
||||
return {
|
||||
hasEnoughCredits: hasEnough,
|
||||
currentCredits: balance.credits,
|
||||
requiredCredits,
|
||||
deficit: hasEnough ? undefined : requiredCredits - balance.credits,
|
||||
context: operation ? { operation } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user has enough credits for a specific operation
|
||||
*/
|
||||
async function checkOperationBalance(
|
||||
operation: StandardOperationType
|
||||
): Promise<CreditCheckResponse> {
|
||||
const cost = await getOperationCost(operation);
|
||||
return checkBalance(cost, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format credit amount for display
|
||||
*/
|
||||
function formatCredits(amount: number, locale = 'en-US'): string {
|
||||
return new Intl.NumberFormat(locale).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pricing cache (useful for testing or forced refresh)
|
||||
*/
|
||||
function clearCache(): void {
|
||||
cachedPricing = null;
|
||||
pricingLastFetched = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
// Initialization
|
||||
initialize,
|
||||
|
||||
// Balance operations
|
||||
getBalance,
|
||||
checkBalance,
|
||||
checkOperationBalance,
|
||||
|
||||
// Pricing operations
|
||||
getPricing,
|
||||
getOperationCost,
|
||||
getOperationCostSync,
|
||||
calculateCost,
|
||||
calculateCostSync,
|
||||
|
||||
// Notifications
|
||||
onCreditUpdate,
|
||||
triggerCreditUpdate,
|
||||
|
||||
// Utilities
|
||||
formatCredits,
|
||||
clearCache,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Type for the credit service instance
|
||||
*/
|
||||
export type CreditService = ReturnType<typeof createCreditService>;
|
||||
45
packages/credits/src/index.ts
Normal file
45
packages/credits/src/index.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
/**
|
||||
* @manacore/credits — Unified credit package
|
||||
*
|
||||
* Consolidates credit-operations + shared-credit-service + shared-credit-ui.
|
||||
*
|
||||
* Usage:
|
||||
* - Operations/costs: import { CreditOperationType, CREDIT_COSTS } from '@manacore/credits'
|
||||
* - Service: import { createCreditService } from '@manacore/credits'
|
||||
* - Web UI: import { CreditBalance } from '@manacore/credits/web'
|
||||
* - Mobile UI: import { CreditBalance } from '@manacore/credits/mobile'
|
||||
*/
|
||||
|
||||
// === Operations (costs, types, metadata) ===
|
||||
export {
|
||||
CreditOperationType,
|
||||
CreditCategory,
|
||||
CREDIT_COSTS,
|
||||
OPERATION_METADATA,
|
||||
FREE_OPERATIONS,
|
||||
getCreditCost,
|
||||
getOperationMetadata,
|
||||
getOperationsForApp,
|
||||
getOperationsByCategory,
|
||||
calculateBulkCost,
|
||||
isFreeOperation,
|
||||
isMicroCreditOperation,
|
||||
isAiOperation,
|
||||
formatCreditCost,
|
||||
getPricingTable,
|
||||
isFreeAction,
|
||||
type OperationMetadata,
|
||||
} from './operations';
|
||||
|
||||
// === Service (client-side credit management) ===
|
||||
export { createCreditService, type CreditService } from './createCreditService';
|
||||
export type {
|
||||
CreditServiceConfig,
|
||||
CreditBalance,
|
||||
CreditCheckResponse,
|
||||
CreditConsumptionResponse,
|
||||
PricingResponse,
|
||||
CreditUpdateCallback,
|
||||
StandardOperationType,
|
||||
} from './service-types';
|
||||
export { DEFAULT_OPERATION_PRICING } from './service-types';
|
||||
217
packages/credits/src/mobile/CreditBalance.tsx
Normal file
217
packages/credits/src/mobile/CreditBalance.tsx
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
import React from 'react';
|
||||
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
|
||||
import { formatCreditCost } from './operations';
|
||||
|
||||
interface CreditBalanceProps {
|
||||
/** Current credit balance */
|
||||
balance: number;
|
||||
/** Free credits remaining (optional) */
|
||||
freeCredits?: number;
|
||||
/** Whether the balance is loading */
|
||||
loading?: boolean;
|
||||
/** Callback when "Buy Credits" is pressed */
|
||||
onBuyPress?: () => void;
|
||||
/** Whether to show as compact (header) or expanded */
|
||||
variant?: 'compact' | 'expanded';
|
||||
/** Low balance threshold for warning */
|
||||
lowBalanceThreshold?: number;
|
||||
/** i18n labels */
|
||||
labels?: {
|
||||
credits?: string;
|
||||
freeCredits?: string;
|
||||
buyCredits?: string;
|
||||
lowBalance?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CreditBalance({
|
||||
balance,
|
||||
freeCredits = 0,
|
||||
loading = false,
|
||||
onBuyPress,
|
||||
variant = 'compact',
|
||||
lowBalanceThreshold = 10,
|
||||
labels = {},
|
||||
}: CreditBalanceProps) {
|
||||
const {
|
||||
credits: creditsLabel = 'Credits',
|
||||
freeCredits: freeCreditsLabel = 'free',
|
||||
buyCredits: buyCreditsLabel = 'Buy',
|
||||
lowBalance: lowBalanceLabel = 'Low balance',
|
||||
} = labels;
|
||||
|
||||
const totalCredits = balance + freeCredits;
|
||||
const isLowBalance = totalCredits < lowBalanceThreshold;
|
||||
const formattedBalance = formatCreditCost(totalCredits);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={[styles.container, variant === 'compact' ? styles.compact : styles.expanded]}>
|
||||
<ActivityIndicator size="small" color="#3b82f6" />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
if (variant === 'compact') {
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.compactButton, isLowBalance && styles.compactButtonLow]}
|
||||
onPress={onBuyPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.icon, isLowBalance && styles.iconLow]}>⚡</Text>
|
||||
<Text style={[styles.compactValue, isLowBalance && styles.valueLow]}>
|
||||
{formattedBalance}
|
||||
</Text>
|
||||
{onBuyPress && <Text style={styles.plusIcon}>+</Text>}
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={styles.expanded}>
|
||||
<View style={styles.header}>
|
||||
<View style={styles.titleRow}>
|
||||
<Text style={styles.iconLarge}>⚡</Text>
|
||||
<Text style={styles.title}>{creditsLabel}</Text>
|
||||
</View>
|
||||
<Text style={styles.largeValue}>{formattedBalance}</Text>
|
||||
</View>
|
||||
|
||||
{freeCredits > 0 && (
|
||||
<Text style={styles.freeCredits}>
|
||||
{formatCreditCost(freeCredits)} {freeCreditsLabel}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{isLowBalance && (
|
||||
<View style={styles.warning}>
|
||||
<Text style={styles.warningIcon}>⚠️</Text>
|
||||
<Text style={styles.warningText}>{lowBalanceLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{onBuyPress && (
|
||||
<TouchableOpacity style={styles.buyButton} onPress={onBuyPress} activeOpacity={0.8}>
|
||||
<Text style={styles.buyButtonText}>{buyCreditsLabel}</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
compact: {
|
||||
height: 32,
|
||||
},
|
||||
expanded: {
|
||||
padding: 16,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.9)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 2 },
|
||||
shadowOpacity: 0.1,
|
||||
shadowRadius: 4,
|
||||
elevation: 3,
|
||||
},
|
||||
compactButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: 999,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
},
|
||||
compactButtonLow: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
icon: {
|
||||
fontSize: 14,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
iconLow: {
|
||||
color: '#ef4444',
|
||||
},
|
||||
iconLarge: {
|
||||
fontSize: 18,
|
||||
},
|
||||
compactValue: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#1f2937',
|
||||
},
|
||||
valueLow: {
|
||||
color: '#dc2626',
|
||||
},
|
||||
plusIcon: {
|
||||
marginLeft: 2,
|
||||
fontSize: 14,
|
||||
fontWeight: '700',
|
||||
color: '#6b7280',
|
||||
opacity: 0.6,
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
titleRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
title: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#6b7280',
|
||||
},
|
||||
largeValue: {
|
||||
fontSize: 24,
|
||||
fontWeight: '700',
|
||||
color: '#1f2937',
|
||||
},
|
||||
freeCredits: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
marginBottom: 12,
|
||||
},
|
||||
warning: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
padding: 8,
|
||||
marginBottom: 12,
|
||||
borderRadius: 8,
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
warningIcon: {
|
||||
fontSize: 14,
|
||||
},
|
||||
warningText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '500',
|
||||
color: '#dc2626',
|
||||
},
|
||||
buyButton: {
|
||||
paddingVertical: 10,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 8,
|
||||
backgroundColor: '#3b82f6',
|
||||
alignItems: 'center',
|
||||
},
|
||||
buyButtonText: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
color: '#ffffff',
|
||||
},
|
||||
});
|
||||
|
||||
export default CreditBalance;
|
||||
253
packages/credits/src/mobile/CreditToast.tsx
Normal file
253
packages/credits/src/mobile/CreditToast.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import React, { useEffect } from 'react';
|
||||
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
|
||||
import { formatCreditCost } from './operations';
|
||||
|
||||
interface CreditToastProps {
|
||||
/** The operation name or description */
|
||||
operation: string;
|
||||
/** Amount of credits consumed (positive) or refunded (negative) */
|
||||
amount: number;
|
||||
/** Remaining balance after the transaction */
|
||||
remainingBalance?: number;
|
||||
/** Toast type */
|
||||
type?: 'success' | 'error' | 'warning';
|
||||
/** Whether the toast is visible */
|
||||
visible?: boolean;
|
||||
/** Callback when toast should be dismissed */
|
||||
onDismiss?: () => void;
|
||||
/** Auto-dismiss timeout in ms (0 = no auto-dismiss) */
|
||||
autoDismissMs?: number;
|
||||
/** i18n labels */
|
||||
labels?: {
|
||||
credits?: string;
|
||||
remaining?: string;
|
||||
insufficient?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function CreditToast({
|
||||
operation,
|
||||
amount,
|
||||
remainingBalance,
|
||||
type = 'success',
|
||||
visible = true,
|
||||
onDismiss,
|
||||
autoDismissMs = 4000,
|
||||
labels = {},
|
||||
}: CreditToastProps) {
|
||||
const {
|
||||
remaining: remainingLabel = 'remaining',
|
||||
insufficient: insufficientLabel = 'Insufficient credits',
|
||||
} = labels;
|
||||
|
||||
const fadeAnim = React.useRef(new Animated.Value(0)).current;
|
||||
const slideAnim = React.useRef(new Animated.Value(-20)).current;
|
||||
|
||||
const isDeduction = amount > 0;
|
||||
const formattedAmount = formatCreditCost(Math.abs(amount));
|
||||
const formattedRemaining =
|
||||
remainingBalance !== undefined ? formatCreditCost(remainingBalance) : null;
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 1,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: 0,
|
||||
duration: 200,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
|
||||
if (autoDismissMs > 0 && onDismiss) {
|
||||
const timer = setTimeout(onDismiss, autoDismissMs);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
} else {
|
||||
Animated.parallel([
|
||||
Animated.timing(fadeAnim, {
|
||||
toValue: 0,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
Animated.timing(slideAnim, {
|
||||
toValue: -20,
|
||||
duration: 150,
|
||||
useNativeDriver: true,
|
||||
}),
|
||||
]).start();
|
||||
}
|
||||
return undefined;
|
||||
}, [visible, autoDismissMs, onDismiss, fadeAnim, slideAnim]);
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
const getTypeStyles = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return {
|
||||
iconBg: styles.iconBgSuccess,
|
||||
icon: '✓',
|
||||
iconColor: '#22c55e',
|
||||
};
|
||||
case 'error':
|
||||
return {
|
||||
iconBg: styles.iconBgError,
|
||||
icon: '✕',
|
||||
iconColor: '#ef4444',
|
||||
};
|
||||
case 'warning':
|
||||
return {
|
||||
iconBg: styles.iconBgWarning,
|
||||
icon: '⚠',
|
||||
iconColor: '#f59e0b',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
iconBg: styles.iconBgSuccess,
|
||||
icon: '✓',
|
||||
iconColor: '#22c55e',
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const typeStyles = getTypeStyles();
|
||||
|
||||
return (
|
||||
<Animated.View
|
||||
style={[
|
||||
styles.container,
|
||||
{
|
||||
opacity: fadeAnim,
|
||||
transform: [{ translateY: slideAnim }],
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={[styles.iconWrapper, typeStyles.iconBg]}>
|
||||
<Text style={[styles.iconText, { color: typeStyles.iconColor }]}>{typeStyles.icon}</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.content}>
|
||||
<Text style={styles.operation} numberOfLines={1}>
|
||||
{operation}
|
||||
</Text>
|
||||
<View style={styles.details}>
|
||||
{type === 'error' ? (
|
||||
<Text style={styles.amountError}>{insufficientLabel}</Text>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[styles.amount, !isDeduction && styles.amountRefund]}>
|
||||
{isDeduction ? '-' : '+'}
|
||||
{formattedAmount}
|
||||
</Text>
|
||||
{formattedRemaining !== null && (
|
||||
<Text style={styles.remaining}>
|
||||
({formattedRemaining} {remainingLabel})
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{onDismiss && (
|
||||
<TouchableOpacity
|
||||
style={styles.dismissButton}
|
||||
onPress={onDismiss}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Text style={styles.dismissIcon}>✕</Text>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-start',
|
||||
gap: 12,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 14,
|
||||
borderRadius: 12,
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.95)',
|
||||
borderWidth: 1,
|
||||
borderColor: 'rgba(0, 0, 0, 0.1)',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 8,
|
||||
elevation: 5,
|
||||
minWidth: 280,
|
||||
maxWidth: 400,
|
||||
},
|
||||
iconWrapper: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
iconBgSuccess: {
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
},
|
||||
iconBgError: {
|
||||
backgroundColor: 'rgba(239, 68, 68, 0.1)',
|
||||
},
|
||||
iconBgWarning: {
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
},
|
||||
iconText: {
|
||||
fontSize: 12,
|
||||
fontWeight: '700',
|
||||
},
|
||||
content: {
|
||||
flex: 1,
|
||||
},
|
||||
operation: {
|
||||
fontSize: 14,
|
||||
fontWeight: '500',
|
||||
color: '#1f2937',
|
||||
marginBottom: 4,
|
||||
},
|
||||
details: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
},
|
||||
amount: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#ef4444',
|
||||
},
|
||||
amountRefund: {
|
||||
color: '#22c55e',
|
||||
},
|
||||
amountError: {
|
||||
fontSize: 12,
|
||||
fontWeight: '600',
|
||||
color: '#ef4444',
|
||||
},
|
||||
remaining: {
|
||||
fontSize: 12,
|
||||
color: '#6b7280',
|
||||
},
|
||||
dismissButton: {
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderRadius: 4,
|
||||
},
|
||||
dismissIcon: {
|
||||
fontSize: 12,
|
||||
color: '#9ca3af',
|
||||
},
|
||||
});
|
||||
|
||||
export default CreditToast;
|
||||
2
packages/credits/src/mobile/index.ts
Normal file
2
packages/credits/src/mobile/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { CreditBalance } from './CreditBalance';
|
||||
export { CreditToast } from './CreditToast';
|
||||
629
packages/credits/src/operations.ts
Normal file
629
packages/credits/src/operations.ts
Normal file
|
|
@ -0,0 +1,629 @@
|
|||
/**
|
||||
* @manacore/credit-operations
|
||||
*
|
||||
* Central credit operation definitions for all Mana apps.
|
||||
* This package defines operation types, costs, and helper functions
|
||||
* for the unified credit system across the ecosystem.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// Operation Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* All credit operations across the Mana ecosystem.
|
||||
* Operations are categorized by type: AI, productivity (micro), and premium.
|
||||
*/
|
||||
export enum CreditOperationType {
|
||||
// -------------------------------------------------------------------------
|
||||
// AI Operations (Standard Credits: 1-30)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Chat - AI conversations
|
||||
AI_CHAT_GPT4 = 'ai_chat_gpt4',
|
||||
AI_CHAT_CLAUDE = 'ai_chat_claude',
|
||||
AI_CHAT_GEMINI = 'ai_chat_gemini',
|
||||
AI_CHAT_QWEN = 'ai_chat_qwen',
|
||||
AI_CHAT_OLLAMA = 'ai_chat_ollama',
|
||||
|
||||
// Picture - Image generation
|
||||
AI_IMAGE_GENERATION = 'ai_image_generation',
|
||||
AI_IMAGE_UPSCALE = 'ai_image_upscale',
|
||||
|
||||
// Questions - Research
|
||||
AI_RESEARCH_QUICK = 'ai_research_quick',
|
||||
AI_RESEARCH_DEEP = 'ai_research_deep',
|
||||
|
||||
// NutriPhi - Food analysis
|
||||
AI_FOOD_ANALYSIS = 'ai_food_analysis',
|
||||
|
||||
// ManaDeck - AI deck generation
|
||||
AI_DECK_GENERATION = 'ai_deck_generation',
|
||||
AI_CARD_GENERATION = 'ai_card_generation',
|
||||
|
||||
// Zitare - AI explanations
|
||||
AI_QUOTE_EXPLANATION = 'ai_quote_explanation',
|
||||
|
||||
// Planta - Plant analysis
|
||||
AI_PLANT_ANALYSIS = 'ai_plant_analysis',
|
||||
|
||||
// Traces - City guide generation
|
||||
AI_GUIDE_GENERATION = 'ai_guide_generation',
|
||||
|
||||
// Context - AI text generation
|
||||
AI_CONTEXT_GENERATION = 'ai_context_generation',
|
||||
|
||||
// Matrix Bots - Bot chat
|
||||
AI_BOT_CHAT = 'ai_bot_chat',
|
||||
|
||||
// General AI features
|
||||
AI_SMART_SCHEDULING = 'ai_smart_scheduling',
|
||||
AI_SUGGESTIONS = 'ai_suggestions',
|
||||
AI_ENRICHMENT = 'ai_enrichment',
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Productivity Operations (Micro Credits: 0.01-0.10)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Todo
|
||||
TASK_CREATE = 'task_create',
|
||||
PROJECT_CREATE = 'project_create',
|
||||
|
||||
// Calendar
|
||||
EVENT_CREATE = 'event_create',
|
||||
CALENDAR_CREATE = 'calendar_create',
|
||||
|
||||
// Contacts
|
||||
CONTACT_CREATE = 'contact_create',
|
||||
|
||||
// Zitare
|
||||
COLLECTION_CREATE = 'collection_create',
|
||||
|
||||
// Presi
|
||||
PRESENTATION_CREATE = 'presentation_create',
|
||||
SLIDE_CREATE = 'slide_create',
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Premium Features (Standard Credits: 0.5-5)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
// Sync features
|
||||
CALDAV_SYNC = 'caldav_sync',
|
||||
GOOGLE_SYNC = 'google_sync',
|
||||
CLOUD_SYNC = 'cloud_sync',
|
||||
|
||||
// Import/Export
|
||||
BULK_IMPORT = 'bulk_import',
|
||||
PDF_EXPORT = 'pdf_export',
|
||||
|
||||
// Premium themes
|
||||
PREMIUM_THEME = 'premium_theme',
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Credit Costs
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Credit costs for each operation type.
|
||||
* Costs are in Credits (decimal values supported for micro-credits).
|
||||
*/
|
||||
export const CREDIT_COSTS: Record<CreditOperationType, number> = {
|
||||
// AI Operations (Standard Credits)
|
||||
[CreditOperationType.AI_CHAT_GPT4]: 5,
|
||||
[CreditOperationType.AI_CHAT_CLAUDE]: 5,
|
||||
[CreditOperationType.AI_CHAT_GEMINI]: 2,
|
||||
[CreditOperationType.AI_CHAT_QWEN]: 2,
|
||||
[CreditOperationType.AI_CHAT_OLLAMA]: 0.1,
|
||||
|
||||
[CreditOperationType.AI_IMAGE_GENERATION]: 10,
|
||||
[CreditOperationType.AI_IMAGE_UPSCALE]: 5,
|
||||
|
||||
[CreditOperationType.AI_RESEARCH_QUICK]: 5,
|
||||
[CreditOperationType.AI_RESEARCH_DEEP]: 25,
|
||||
|
||||
[CreditOperationType.AI_FOOD_ANALYSIS]: 3,
|
||||
|
||||
[CreditOperationType.AI_DECK_GENERATION]: 20,
|
||||
[CreditOperationType.AI_CARD_GENERATION]: 2,
|
||||
|
||||
[CreditOperationType.AI_QUOTE_EXPLANATION]: 2,
|
||||
|
||||
[CreditOperationType.AI_PLANT_ANALYSIS]: 2,
|
||||
[CreditOperationType.AI_GUIDE_GENERATION]: 5,
|
||||
[CreditOperationType.AI_CONTEXT_GENERATION]: 2,
|
||||
[CreditOperationType.AI_BOT_CHAT]: 0.1,
|
||||
|
||||
[CreditOperationType.AI_SMART_SCHEDULING]: 2,
|
||||
[CreditOperationType.AI_SUGGESTIONS]: 2,
|
||||
[CreditOperationType.AI_ENRICHMENT]: 2,
|
||||
|
||||
// Productivity Operations (Micro Credits)
|
||||
[CreditOperationType.TASK_CREATE]: 0.02,
|
||||
[CreditOperationType.PROJECT_CREATE]: 0.1,
|
||||
|
||||
[CreditOperationType.EVENT_CREATE]: 0.02,
|
||||
[CreditOperationType.CALENDAR_CREATE]: 0.1,
|
||||
|
||||
[CreditOperationType.CONTACT_CREATE]: 0.02,
|
||||
|
||||
[CreditOperationType.COLLECTION_CREATE]: 0.1,
|
||||
|
||||
[CreditOperationType.PRESENTATION_CREATE]: 0.5,
|
||||
[CreditOperationType.SLIDE_CREATE]: 0.02,
|
||||
|
||||
// Premium Features
|
||||
[CreditOperationType.CALDAV_SYNC]: 0.5,
|
||||
[CreditOperationType.GOOGLE_SYNC]: 0.5,
|
||||
[CreditOperationType.CLOUD_SYNC]: 5, // Monthly
|
||||
|
||||
[CreditOperationType.BULK_IMPORT]: 0.2, // Per 10 items
|
||||
[CreditOperationType.PDF_EXPORT]: 1,
|
||||
|
||||
[CreditOperationType.PREMIUM_THEME]: 3,
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Operation Metadata
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Category of credit operation for grouping and display.
|
||||
*/
|
||||
export enum CreditCategory {
|
||||
AI = 'ai',
|
||||
PRODUCTIVITY = 'productivity',
|
||||
PREMIUM = 'premium',
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata about each operation for UI display and documentation.
|
||||
*/
|
||||
export interface OperationMetadata {
|
||||
/** Human-readable name */
|
||||
name: string;
|
||||
/** Description for tooltips/help */
|
||||
description: string;
|
||||
/** Category for grouping */
|
||||
category: CreditCategory;
|
||||
/** Which app this operation belongs to */
|
||||
app: string;
|
||||
/** Is this a per-item cost (e.g., bulk import per 10 items) */
|
||||
perItem?: boolean;
|
||||
/** Item unit name if perItem is true */
|
||||
itemUnit?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata for all operations.
|
||||
*/
|
||||
export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata> = {
|
||||
// AI Chat
|
||||
[CreditOperationType.AI_CHAT_GPT4]: {
|
||||
name: 'GPT-4 Message',
|
||||
description: 'Send a message using GPT-4 or GPT-4o',
|
||||
category: CreditCategory.AI,
|
||||
app: 'chat',
|
||||
},
|
||||
[CreditOperationType.AI_CHAT_CLAUDE]: {
|
||||
name: 'Claude Message',
|
||||
description: 'Send a message using Claude (Anthropic)',
|
||||
category: CreditCategory.AI,
|
||||
app: 'chat',
|
||||
},
|
||||
[CreditOperationType.AI_CHAT_GEMINI]: {
|
||||
name: 'Gemini Message',
|
||||
description: 'Send a message using Google Gemini',
|
||||
category: CreditCategory.AI,
|
||||
app: 'chat',
|
||||
},
|
||||
[CreditOperationType.AI_CHAT_QWEN]: {
|
||||
name: 'Qwen Message',
|
||||
description: 'Send a message using Qwen',
|
||||
category: CreditCategory.AI,
|
||||
app: 'chat',
|
||||
},
|
||||
[CreditOperationType.AI_CHAT_OLLAMA]: {
|
||||
name: 'Ollama Message (Local)',
|
||||
description: 'Send a message using local Ollama models',
|
||||
category: CreditCategory.AI,
|
||||
app: 'chat',
|
||||
},
|
||||
|
||||
// Image Generation
|
||||
[CreditOperationType.AI_IMAGE_GENERATION]: {
|
||||
name: 'Generate Image',
|
||||
description: 'Generate an AI image',
|
||||
category: CreditCategory.AI,
|
||||
app: 'picture',
|
||||
},
|
||||
[CreditOperationType.AI_IMAGE_UPSCALE]: {
|
||||
name: 'Upscale Image',
|
||||
description: 'Upscale an image to higher resolution',
|
||||
category: CreditCategory.AI,
|
||||
app: 'picture',
|
||||
},
|
||||
|
||||
// Research
|
||||
[CreditOperationType.AI_RESEARCH_QUICK]: {
|
||||
name: 'Quick Research',
|
||||
description: 'Quick research with 5 sources',
|
||||
category: CreditCategory.AI,
|
||||
app: 'questions',
|
||||
},
|
||||
[CreditOperationType.AI_RESEARCH_DEEP]: {
|
||||
name: 'Deep Research',
|
||||
description: 'Comprehensive research with 30+ sources',
|
||||
category: CreditCategory.AI,
|
||||
app: 'questions',
|
||||
},
|
||||
|
||||
// Food Analysis
|
||||
[CreditOperationType.AI_FOOD_ANALYSIS]: {
|
||||
name: 'Analyze Food Photo',
|
||||
description: 'Analyze nutrition from a food photo',
|
||||
category: CreditCategory.AI,
|
||||
app: 'nutriphi',
|
||||
},
|
||||
|
||||
// Deck Generation
|
||||
[CreditOperationType.AI_DECK_GENERATION]: {
|
||||
name: 'Generate AI Deck',
|
||||
description: 'Generate a complete deck with AI (10 cards)',
|
||||
category: CreditCategory.AI,
|
||||
app: 'manadeck',
|
||||
},
|
||||
[CreditOperationType.AI_CARD_GENERATION]: {
|
||||
name: 'Generate AI Card',
|
||||
description: 'Generate a single card with AI',
|
||||
category: CreditCategory.AI,
|
||||
app: 'manadeck',
|
||||
},
|
||||
|
||||
// Quote Explanation
|
||||
[CreditOperationType.AI_QUOTE_EXPLANATION]: {
|
||||
name: 'Explain Quote',
|
||||
description: 'Get an AI explanation of a quote',
|
||||
category: CreditCategory.AI,
|
||||
app: 'zitare',
|
||||
},
|
||||
|
||||
// Planta
|
||||
[CreditOperationType.AI_PLANT_ANALYSIS]: {
|
||||
name: 'Plant Analysis',
|
||||
description: 'Identify and analyze a plant from a photo',
|
||||
category: CreditCategory.AI,
|
||||
app: 'planta',
|
||||
},
|
||||
|
||||
// Traces
|
||||
[CreditOperationType.AI_GUIDE_GENERATION]: {
|
||||
name: 'City Guide Generation',
|
||||
description: 'Generate an AI-powered city walking guide',
|
||||
category: CreditCategory.AI,
|
||||
app: 'traces',
|
||||
},
|
||||
|
||||
// Context
|
||||
[CreditOperationType.AI_CONTEXT_GENERATION]: {
|
||||
name: 'AI Text Generation',
|
||||
description: 'Generate or transform text with AI',
|
||||
category: CreditCategory.AI,
|
||||
app: 'context',
|
||||
},
|
||||
|
||||
// Matrix Bots
|
||||
[CreditOperationType.AI_BOT_CHAT]: {
|
||||
name: 'Bot Chat Message',
|
||||
description: 'Chat with AI via Matrix bot',
|
||||
category: CreditCategory.AI,
|
||||
app: 'matrix',
|
||||
},
|
||||
|
||||
// General AI
|
||||
[CreditOperationType.AI_SMART_SCHEDULING]: {
|
||||
name: 'Smart Scheduling',
|
||||
description: 'AI-powered task scheduling suggestions',
|
||||
category: CreditCategory.AI,
|
||||
app: 'todo',
|
||||
},
|
||||
[CreditOperationType.AI_SUGGESTIONS]: {
|
||||
name: 'AI Suggestions',
|
||||
description: 'Get AI-powered suggestions',
|
||||
category: CreditCategory.AI,
|
||||
app: 'general',
|
||||
},
|
||||
[CreditOperationType.AI_ENRICHMENT]: {
|
||||
name: 'AI Enrichment',
|
||||
description: 'Enrich data with AI-gathered information',
|
||||
category: CreditCategory.AI,
|
||||
app: 'contacts',
|
||||
},
|
||||
|
||||
// Productivity - Todo
|
||||
[CreditOperationType.TASK_CREATE]: {
|
||||
name: 'Create Task',
|
||||
description: 'Create a new task',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'todo',
|
||||
},
|
||||
[CreditOperationType.PROJECT_CREATE]: {
|
||||
name: 'Create Project',
|
||||
description: 'Create a new project',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'todo',
|
||||
},
|
||||
|
||||
// Productivity - Calendar
|
||||
[CreditOperationType.EVENT_CREATE]: {
|
||||
name: 'Create Event',
|
||||
description: 'Create a calendar event',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'calendar',
|
||||
},
|
||||
[CreditOperationType.CALENDAR_CREATE]: {
|
||||
name: 'Create Calendar',
|
||||
description: 'Create a new calendar',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'calendar',
|
||||
},
|
||||
|
||||
// Productivity - Contacts
|
||||
[CreditOperationType.CONTACT_CREATE]: {
|
||||
name: 'Create Contact',
|
||||
description: 'Create a new contact',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'contacts',
|
||||
},
|
||||
|
||||
// Productivity - Zitare
|
||||
[CreditOperationType.COLLECTION_CREATE]: {
|
||||
name: 'Create Collection',
|
||||
description: 'Create a quote collection',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'zitare',
|
||||
},
|
||||
|
||||
// Productivity - Presi
|
||||
[CreditOperationType.PRESENTATION_CREATE]: {
|
||||
name: 'Create Presentation',
|
||||
description: 'Create a new presentation',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'presi',
|
||||
},
|
||||
[CreditOperationType.SLIDE_CREATE]: {
|
||||
name: 'Create Slide',
|
||||
description: 'Add a slide to a presentation',
|
||||
category: CreditCategory.PRODUCTIVITY,
|
||||
app: 'presi',
|
||||
},
|
||||
|
||||
// Premium - Sync
|
||||
[CreditOperationType.CALDAV_SYNC]: {
|
||||
name: 'CalDAV Sync',
|
||||
description: 'Sync with CalDAV server',
|
||||
category: CreditCategory.PREMIUM,
|
||||
app: 'calendar',
|
||||
},
|
||||
[CreditOperationType.GOOGLE_SYNC]: {
|
||||
name: 'Google Sync',
|
||||
description: 'Sync with Google services',
|
||||
category: CreditCategory.PREMIUM,
|
||||
app: 'contacts',
|
||||
},
|
||||
[CreditOperationType.CLOUD_SYNC]: {
|
||||
name: 'Cloud Sync (Monthly)',
|
||||
description: 'Enable cloud synchronization',
|
||||
category: CreditCategory.PREMIUM,
|
||||
app: 'skilltree',
|
||||
},
|
||||
|
||||
// Premium - Import/Export
|
||||
[CreditOperationType.BULK_IMPORT]: {
|
||||
name: 'Bulk Import',
|
||||
description: 'Import items in bulk',
|
||||
category: CreditCategory.PREMIUM,
|
||||
app: 'general',
|
||||
perItem: true,
|
||||
itemUnit: '10 items',
|
||||
},
|
||||
[CreditOperationType.PDF_EXPORT]: {
|
||||
name: 'PDF Export',
|
||||
description: 'Export to PDF format',
|
||||
category: CreditCategory.PREMIUM,
|
||||
app: 'presi',
|
||||
},
|
||||
|
||||
// Premium - Themes
|
||||
[CreditOperationType.PREMIUM_THEME]: {
|
||||
name: 'Premium Theme',
|
||||
description: 'Use a premium theme',
|
||||
category: CreditCategory.PREMIUM,
|
||||
app: 'presi',
|
||||
},
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// Helper Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get the credit cost for an operation.
|
||||
* @param operation The operation type
|
||||
* @returns The cost in credits
|
||||
*/
|
||||
export function getCreditCost(operation: CreditOperationType): number {
|
||||
return CREDIT_COSTS[operation];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the metadata for an operation.
|
||||
* @param operation The operation type
|
||||
* @returns Operation metadata
|
||||
*/
|
||||
export function getOperationMetadata(operation: CreditOperationType): OperationMetadata {
|
||||
return OPERATION_METADATA[operation];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations for a specific app.
|
||||
* @param app The app name (e.g., 'chat', 'todo', 'calendar')
|
||||
* @returns Array of operations for that app
|
||||
*/
|
||||
export function getOperationsForApp(app: string): CreditOperationType[] {
|
||||
return Object.entries(OPERATION_METADATA)
|
||||
.filter(([, meta]) => meta.app === app)
|
||||
.map(([op]) => op as CreditOperationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all operations in a specific category.
|
||||
* @param category The category
|
||||
* @returns Array of operations in that category
|
||||
*/
|
||||
export function getOperationsByCategory(category: CreditCategory): CreditOperationType[] {
|
||||
return Object.entries(OPERATION_METADATA)
|
||||
.filter(([, meta]) => meta.category === category)
|
||||
.map(([op]) => op as CreditOperationType);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate total cost for bulk operations.
|
||||
* @param operation The operation type
|
||||
* @param count Number of items
|
||||
* @returns Total cost in credits
|
||||
*/
|
||||
export function calculateBulkCost(operation: CreditOperationType, count: number): number {
|
||||
const cost = CREDIT_COSTS[operation];
|
||||
const meta = OPERATION_METADATA[operation];
|
||||
|
||||
if (meta.perItem) {
|
||||
// For bulk operations, cost is per batch (e.g., per 10 items)
|
||||
return Math.ceil(count / 10) * cost;
|
||||
}
|
||||
|
||||
return cost * count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation is considered "free" (no credit cost).
|
||||
* @param operation The operation type
|
||||
* @returns True if the operation is free
|
||||
*/
|
||||
export function isFreeOperation(operation: CreditOperationType): boolean {
|
||||
return CREDIT_COSTS[operation] === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation is a micro-credit operation (< 0.5 credits).
|
||||
* @param operation The operation type
|
||||
* @returns True if micro-credit operation
|
||||
*/
|
||||
export function isMicroCreditOperation(operation: CreditOperationType): boolean {
|
||||
const cost = CREDIT_COSTS[operation];
|
||||
return cost > 0 && cost < 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an operation is an AI operation.
|
||||
* @param operation The operation type
|
||||
* @returns True if AI operation
|
||||
*/
|
||||
export function isAiOperation(operation: CreditOperationType): boolean {
|
||||
return OPERATION_METADATA[operation].category === CreditCategory.AI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format credit cost for display.
|
||||
* @param cost The credit cost
|
||||
* @returns Formatted string (e.g., "0.02" or "5")
|
||||
*/
|
||||
export function formatCreditCost(cost: number): string {
|
||||
if (cost === 0) return 'Free';
|
||||
if (cost < 1) return cost.toFixed(2);
|
||||
return cost.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a pricing table for an app (for display in UI).
|
||||
* @param app The app name
|
||||
* @returns Array of pricing entries
|
||||
*/
|
||||
export function getPricingTable(app: string): Array<{
|
||||
operation: CreditOperationType;
|
||||
name: string;
|
||||
description: string;
|
||||
cost: number;
|
||||
formattedCost: string;
|
||||
category: CreditCategory;
|
||||
}> {
|
||||
return getOperationsForApp(app).map((op) => {
|
||||
const meta = OPERATION_METADATA[op];
|
||||
const cost = CREDIT_COSTS[op];
|
||||
return {
|
||||
operation: op,
|
||||
name: meta.name,
|
||||
description: meta.description,
|
||||
cost,
|
||||
formattedCost: formatCreditCost(cost),
|
||||
category: meta.category,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Free Operations List
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Operations that are always free (no credit cost).
|
||||
* These are read operations, status checks, and engagement actions.
|
||||
*/
|
||||
export const FREE_OPERATIONS = [
|
||||
// Reading/viewing
|
||||
'read',
|
||||
'view',
|
||||
'list',
|
||||
'get',
|
||||
'search',
|
||||
'browse',
|
||||
|
||||
// Task completion (engagement)
|
||||
'complete',
|
||||
'check',
|
||||
'toggle',
|
||||
|
||||
// Editing (no new resource creation)
|
||||
'update',
|
||||
'edit',
|
||||
'modify',
|
||||
|
||||
// Deletion
|
||||
'delete',
|
||||
'remove',
|
||||
'archive',
|
||||
|
||||
// Organization
|
||||
'sort',
|
||||
'filter',
|
||||
'move',
|
||||
'reorder',
|
||||
|
||||
// Metadata
|
||||
'tag',
|
||||
'label',
|
||||
'favorite',
|
||||
'unfavorite',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Check if an action name represents a free operation.
|
||||
* @param action The action name (e.g., 'update', 'delete')
|
||||
* @returns True if the action is free
|
||||
*/
|
||||
export function isFreeAction(action: string): boolean {
|
||||
const normalizedAction = action.toLowerCase();
|
||||
return FREE_OPERATIONS.some(
|
||||
(freeOp) => normalizedAction === freeOp || normalizedAction.startsWith(`${freeOp}_`)
|
||||
);
|
||||
}
|
||||
152
packages/credits/src/service-types.ts
Normal file
152
packages/credits/src/service-types.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
/**
|
||||
* Credit Service Types
|
||||
*
|
||||
* Types for credit/mana operations across all apps
|
||||
*/
|
||||
|
||||
/**
|
||||
* Credit balance with additional metadata
|
||||
*/
|
||||
export interface CreditBalance {
|
||||
/** Current credit/mana amount */
|
||||
credits: number;
|
||||
/** Maximum credit limit */
|
||||
maxCreditLimit: number;
|
||||
/** User ID */
|
||||
userId: string;
|
||||
/** Currency identifier (default: 'mana') */
|
||||
currency?: string;
|
||||
/** Last updated timestamp */
|
||||
lastUpdated?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of checking if user has enough credits
|
||||
*/
|
||||
export interface CreditCheckResponse {
|
||||
/** Whether user has sufficient credits */
|
||||
hasEnoughCredits: boolean;
|
||||
/** Current credit balance */
|
||||
currentCredits: number;
|
||||
/** Credits required for operation */
|
||||
requiredCredits: number;
|
||||
/** Deficit amount (if insufficient) */
|
||||
deficit?: number;
|
||||
/** Credit source type */
|
||||
creditType?: 'user' | 'space';
|
||||
/** Additional context */
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of credit consumption
|
||||
*/
|
||||
export interface CreditConsumptionResponse {
|
||||
/** Whether consumption succeeded */
|
||||
success: boolean;
|
||||
/** Human-readable message */
|
||||
message: string;
|
||||
/** Amount of credits consumed */
|
||||
creditsConsumed: number;
|
||||
/** Credit source type */
|
||||
creditType: 'user' | 'space';
|
||||
/** Remaining balance after consumption */
|
||||
remainingCredits?: number;
|
||||
/** Related operation identifier */
|
||||
operationId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pricing response from backend
|
||||
*/
|
||||
export interface PricingResponse {
|
||||
/** Map of operation types to their costs */
|
||||
operationCosts: Record<string, number>;
|
||||
/** Cost per hour for time-based operations (e.g., transcription) */
|
||||
transcriptionPerHour?: number;
|
||||
/** When pricing was last updated */
|
||||
lastUpdated: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuration for creating a credit service instance
|
||||
*/
|
||||
export interface CreditServiceConfig {
|
||||
/** Base URL for credit/billing API */
|
||||
apiUrl: string;
|
||||
/** Endpoint for fetching balance (relative to apiUrl) */
|
||||
balanceEndpoint?: string;
|
||||
/** Endpoint for fetching pricing (relative to apiUrl) */
|
||||
pricingEndpoint?: string;
|
||||
/** How long to cache pricing (milliseconds, default: 30 minutes) */
|
||||
cacheDuration?: number;
|
||||
/** Fallback pricing if backend unavailable */
|
||||
fallbackPricing?: Record<string, number>;
|
||||
/** Function to get current auth token */
|
||||
getAuthToken: () => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Credit update callback type
|
||||
*/
|
||||
export type CreditUpdateCallback = (creditsConsumed: number, operation?: string) => void;
|
||||
|
||||
/**
|
||||
* Standard operation types across all apps
|
||||
*/
|
||||
export type StandardOperationType =
|
||||
// Memoro operations
|
||||
| 'TRANSCRIPTION_PER_HOUR'
|
||||
| 'HEADLINE_GENERATION'
|
||||
| 'MEMORY_CREATION'
|
||||
| 'BLUEPRINT_PROCESSING'
|
||||
| 'QUESTION_MEMO'
|
||||
| 'NEW_MEMORY'
|
||||
| 'MEMO_COMBINE'
|
||||
| 'MEMO_SHARING'
|
||||
| 'SPACE_OPERATION'
|
||||
// Maerchenzauber operations
|
||||
| 'CHARACTER_CREATION'
|
||||
| 'CHARACTER_GENERATION_FROM_IMAGE'
|
||||
| 'CHARACTER_IMPORT'
|
||||
| 'STORY_CREATION'
|
||||
| 'STORY_CONTINUATION'
|
||||
// ManaDeck operations
|
||||
| 'DECK_CREATION'
|
||||
| 'CARD_GENERATION'
|
||||
| 'AI_REVIEW'
|
||||
// Generic operations
|
||||
| 'AI_PROCESSING'
|
||||
| 'EXPORT'
|
||||
| 'IMPORT'
|
||||
| string; // Allow custom operation types
|
||||
|
||||
/**
|
||||
* Default pricing for operations (fallback values)
|
||||
*/
|
||||
export const DEFAULT_OPERATION_PRICING: Record<string, number> = {
|
||||
// Memoro
|
||||
TRANSCRIPTION_PER_HOUR: 120,
|
||||
HEADLINE_GENERATION: 10,
|
||||
MEMORY_CREATION: 10,
|
||||
BLUEPRINT_PROCESSING: 5,
|
||||
QUESTION_MEMO: 5,
|
||||
NEW_MEMORY: 5,
|
||||
MEMO_COMBINE: 5,
|
||||
MEMO_SHARING: 1,
|
||||
SPACE_OPERATION: 2,
|
||||
// Maerchenzauber
|
||||
CHARACTER_CREATION: 20,
|
||||
CHARACTER_GENERATION_FROM_IMAGE: 20,
|
||||
CHARACTER_IMPORT: 10,
|
||||
STORY_CREATION: 10,
|
||||
STORY_CONTINUATION: 5,
|
||||
// ManaDeck
|
||||
DECK_CREATION: 5,
|
||||
CARD_GENERATION: 2,
|
||||
AI_REVIEW: 10,
|
||||
// Generic
|
||||
AI_PROCESSING: 10,
|
||||
EXPORT: 1,
|
||||
IMPORT: 1,
|
||||
};
|
||||
339
packages/credits/src/web/CreditBalance.svelte
Normal file
339
packages/credits/src/web/CreditBalance.svelte
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<script lang="ts">
|
||||
import { formatCreditCost } from './operations';
|
||||
|
||||
interface Props {
|
||||
/** Current credit balance */
|
||||
balance: number;
|
||||
/** Free credits remaining (optional) */
|
||||
freeCredits?: number;
|
||||
/** Whether the balance is loading */
|
||||
loading?: boolean;
|
||||
/** Callback when "Buy Credits" is clicked */
|
||||
onBuyClick?: () => void;
|
||||
/** Whether to show as compact (header) or expanded */
|
||||
variant?: 'compact' | 'expanded';
|
||||
/** Low balance threshold for warning */
|
||||
lowBalanceThreshold?: number;
|
||||
/** i18n labels */
|
||||
creditsLabel?: string;
|
||||
freeCreditsLabel?: string;
|
||||
buyCreditsLabel?: string;
|
||||
lowBalanceLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
balance,
|
||||
freeCredits = 0,
|
||||
loading = false,
|
||||
onBuyClick,
|
||||
variant = 'compact',
|
||||
lowBalanceThreshold = 10,
|
||||
creditsLabel = 'Credits',
|
||||
freeCreditsLabel = 'free',
|
||||
buyCreditsLabel = 'Buy',
|
||||
lowBalanceLabel = 'Low balance',
|
||||
}: Props = $props();
|
||||
|
||||
const totalCredits = $derived(balance + freeCredits);
|
||||
const isLowBalance = $derived(totalCredits < lowBalanceThreshold);
|
||||
const formattedBalance = $derived(formatCreditCost(totalCredits));
|
||||
</script>
|
||||
|
||||
{#if variant === 'compact'}
|
||||
<div class="credit-balance credit-balance--compact" class:credit-balance--low={isLowBalance}>
|
||||
{#if loading}
|
||||
<div class="credit-balance__skeleton"></div>
|
||||
{:else}
|
||||
<button
|
||||
class="credit-balance__button"
|
||||
onclick={onBuyClick}
|
||||
title={isLowBalance ? lowBalanceLabel : `${formattedBalance} ${creditsLabel}`}
|
||||
>
|
||||
<svg class="credit-balance__icon" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<span class="credit-balance__value">{formattedBalance}</span>
|
||||
{#if onBuyClick}
|
||||
<span class="credit-balance__buy">+</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="credit-balance credit-balance--expanded" class:credit-balance--low={isLowBalance}>
|
||||
{#if loading}
|
||||
<div class="credit-balance__skeleton credit-balance__skeleton--expanded"></div>
|
||||
{:else}
|
||||
<div class="credit-balance__header">
|
||||
<div class="credit-balance__title-row">
|
||||
<svg
|
||||
class="credit-balance__icon credit-balance__icon--large"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M13 10V3L4 14h7v7l9-11h-7z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="credit-balance__title">{creditsLabel}</h3>
|
||||
</div>
|
||||
<div class="credit-balance__total">
|
||||
<span class="credit-balance__value credit-balance__value--large">{formattedBalance}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if freeCredits > 0}
|
||||
<p class="credit-balance__free">
|
||||
{formatCreditCost(freeCredits)}
|
||||
{freeCreditsLabel}
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#if isLowBalance}
|
||||
<div class="credit-balance__warning">
|
||||
<svg class="credit-balance__warning-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span>{lowBalanceLabel}</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if onBuyClick}
|
||||
<button class="credit-balance__buy-button" onclick={onBuyClick}>
|
||||
{buyCreditsLabel}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.credit-balance {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* Compact variant (header) */
|
||||
.credit-balance--compact {
|
||||
height: 2rem;
|
||||
}
|
||||
|
||||
.credit-balance__button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
:global(.dark) .credit-balance__button {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.credit-balance__button:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .credit-balance__button:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.credit-balance--low .credit-balance__button {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
|
||||
:global(.dark) .credit-balance--low .credit-balance__button {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
|
||||
.credit-balance__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
.credit-balance--low .credit-balance__icon {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
.credit-balance__value {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.credit-balance__buy {
|
||||
margin-left: 0.125rem;
|
||||
font-weight: 700;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.credit-balance__button:hover .credit-balance__buy {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Expanded variant */
|
||||
.credit-balance--expanded {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
padding: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
:global(.dark) .credit-balance--expanded {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.credit-balance__header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.credit-balance__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.credit-balance__icon--large {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.credit-balance__title {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.credit-balance__total {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.credit-balance__value--large {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.credit-balance__free {
|
||||
margin: 0 0 0.75rem 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.credit-balance__warning {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgb(220, 38, 38);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:global(.dark) .credit-balance__warning {
|
||||
background: rgba(239, 68, 68, 0.2);
|
||||
color: rgb(248, 113, 113);
|
||||
}
|
||||
|
||||
.credit-balance__warning-icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.credit-balance__buy-button {
|
||||
width: 100%;
|
||||
padding: 0.625rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: rgb(59, 130, 246);
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.credit-balance__buy-button:hover {
|
||||
background: rgb(37, 99, 235);
|
||||
}
|
||||
|
||||
/* Skeleton loading */
|
||||
.credit-balance__skeleton {
|
||||
width: 4rem;
|
||||
height: 2rem;
|
||||
border-radius: 9999px;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(0, 0, 0, 0.05) 25%,
|
||||
rgba(0, 0, 0, 0.1) 50%,
|
||||
rgba(0, 0, 0, 0.05) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
}
|
||||
|
||||
:global(.dark) .credit-balance__skeleton {
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(255, 255, 255, 0.05) 25%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
rgba(255, 255, 255, 0.05) 75%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
}
|
||||
|
||||
.credit-balance__skeleton--expanded {
|
||||
width: 100%;
|
||||
height: 6rem;
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% {
|
||||
background-position: 200% 0;
|
||||
}
|
||||
100% {
|
||||
background-position: -200% 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
324
packages/credits/src/web/CreditPricingTable.svelte
Normal file
324
packages/credits/src/web/CreditPricingTable.svelte
Normal file
|
|
@ -0,0 +1,324 @@
|
|||
<script lang="ts">
|
||||
import {
|
||||
getPricingTable,
|
||||
CreditCategory,
|
||||
type CreditOperationType,
|
||||
} from './operations';
|
||||
|
||||
interface Props {
|
||||
/** The app to show pricing for (e.g., 'todo', 'chat', 'calendar') */
|
||||
app: string;
|
||||
/** Title for the pricing table */
|
||||
title?: string;
|
||||
/** Whether to show category headers */
|
||||
showCategories?: boolean;
|
||||
/** Filter to specific categories */
|
||||
categories?: CreditCategory[];
|
||||
/** i18n labels */
|
||||
operationLabel?: string;
|
||||
costLabel?: string;
|
||||
freeLabel?: string;
|
||||
aiLabel?: string;
|
||||
productivityLabel?: string;
|
||||
premiumLabel?: string;
|
||||
creditsLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
app,
|
||||
title,
|
||||
showCategories = true,
|
||||
categories,
|
||||
operationLabel = 'Operation',
|
||||
costLabel = 'Cost',
|
||||
freeLabel = 'Free',
|
||||
aiLabel = 'AI Features',
|
||||
productivityLabel = 'Create',
|
||||
premiumLabel = 'Premium',
|
||||
creditsLabel = 'Credits',
|
||||
}: Props = $props();
|
||||
|
||||
const allOperations = $derived(getPricingTable(app));
|
||||
|
||||
const filteredOperations = $derived(
|
||||
categories ? allOperations.filter((op) => categories.includes(op.category)) : allOperations
|
||||
);
|
||||
|
||||
const groupedOperations = $derived(() => {
|
||||
if (!showCategories) return { all: filteredOperations };
|
||||
|
||||
const groups: Record<string, typeof filteredOperations> = {};
|
||||
|
||||
for (const op of filteredOperations) {
|
||||
const key = op.category;
|
||||
if (!groups[key]) groups[key] = [];
|
||||
groups[key].push(op);
|
||||
}
|
||||
|
||||
return groups;
|
||||
});
|
||||
|
||||
function getCategoryLabel(category: CreditCategory): string {
|
||||
switch (category) {
|
||||
case CreditCategory.AI:
|
||||
return aiLabel;
|
||||
case CreditCategory.PRODUCTIVITY:
|
||||
return productivityLabel;
|
||||
case CreditCategory.PREMIUM:
|
||||
return premiumLabel;
|
||||
default:
|
||||
return category;
|
||||
}
|
||||
}
|
||||
|
||||
function getCategoryIcon(category: CreditCategory): string {
|
||||
switch (category) {
|
||||
case CreditCategory.AI:
|
||||
return 'M9.813 15.904L9 18.75l-.813-2.846a4.5 4.5 0 00-3.09-3.09L2.25 12l2.846-.813a4.5 4.5 0 003.09-3.09L9 5.25l.813 2.846a4.5 4.5 0 003.09 3.09L15.75 12l-2.846.813a4.5 4.5 0 00-3.09 3.09zM18.259 8.715L18 9.75l-.259-1.035a3.375 3.375 0 00-2.455-2.456L14.25 6l1.036-.259a3.375 3.375 0 002.455-2.456L18 2.25l.259 1.035a3.375 3.375 0 002.456 2.456L21.75 6l-1.035.259a3.375 3.375 0 00-2.456 2.456zM16.894 20.567L16.5 21.75l-.394-1.183a2.25 2.25 0 00-1.423-1.423L13.5 18.75l1.183-.394a2.25 2.25 0 001.423-1.423l.394-1.183.394 1.183a2.25 2.25 0 001.423 1.423l1.183.394-1.183.394a2.25 2.25 0 00-1.423 1.423z';
|
||||
case CreditCategory.PRODUCTIVITY:
|
||||
return 'M12 4.5v15m7.5-7.5h-15';
|
||||
case CreditCategory.PREMIUM:
|
||||
return 'M11.48 3.499a.562.562 0 011.04 0l2.125 5.111a.563.563 0 00.475.345l5.518.442c.499.04.701.663.321.988l-4.204 3.602a.563.563 0 00-.182.557l1.285 5.385a.562.562 0 01-.84.61l-4.725-2.885a.563.563 0 00-.586 0L6.982 20.54a.562.562 0 01-.84-.61l1.285-5.386a.562.562 0 00-.182-.557l-4.204-3.602a.563.563 0 01.321-.988l5.518-.442a.563.563 0 00.475-.345L11.48 3.5z';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="pricing-table">
|
||||
{#if title}
|
||||
<h3 class="pricing-table__title">{title}</h3>
|
||||
{/if}
|
||||
|
||||
{#if filteredOperations.length === 0}
|
||||
<p class="pricing-table__empty">No pricing information available for this app.</p>
|
||||
{:else if showCategories}
|
||||
{@const groups = groupedOperations()}
|
||||
{#each Object.entries(groups) as [category, operations]}
|
||||
<div class="pricing-table__category">
|
||||
<div class="pricing-table__category-header">
|
||||
<svg
|
||||
class="pricing-table__category-icon"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.5"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
d={getCategoryIcon(category as CreditCategory)}
|
||||
/>
|
||||
</svg>
|
||||
<h4 class="pricing-table__category-title">
|
||||
{getCategoryLabel(category as CreditCategory)}
|
||||
</h4>
|
||||
</div>
|
||||
<ul class="pricing-table__list">
|
||||
{#each operations as op}
|
||||
<li class="pricing-table__item">
|
||||
<div class="pricing-table__item-info">
|
||||
<span class="pricing-table__item-name">{op.name}</span>
|
||||
<span class="pricing-table__item-description">{op.description}</span>
|
||||
</div>
|
||||
<div
|
||||
class="pricing-table__item-cost"
|
||||
class:pricing-table__item-cost--free={op.cost === 0}
|
||||
>
|
||||
{#if op.cost === 0}
|
||||
{freeLabel}
|
||||
{:else}
|
||||
{op.formattedCost}
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="pricing-table__simple">
|
||||
<div class="pricing-table__header-row">
|
||||
<span>{operationLabel}</span>
|
||||
<span>{costLabel}</span>
|
||||
</div>
|
||||
<ul class="pricing-table__list">
|
||||
{#each filteredOperations as op}
|
||||
<li class="pricing-table__item">
|
||||
<div class="pricing-table__item-info">
|
||||
<span class="pricing-table__item-name">{op.name}</span>
|
||||
</div>
|
||||
<div
|
||||
class="pricing-table__item-cost"
|
||||
class:pricing-table__item-cost--free={op.cost === 0}
|
||||
>
|
||||
{#if op.cost === 0}
|
||||
{freeLabel}
|
||||
{:else}
|
||||
{op.formattedCost}
|
||||
{/if}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="pricing-table__footer">
|
||||
{freeLabel}: Read, edit, delete, and organize items
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.pricing-table {
|
||||
padding: 1.25rem;
|
||||
border-radius: 1rem;
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .pricing-table {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
.pricing-table__title {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.pricing-table__empty {
|
||||
margin: 0;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.pricing-table__category {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.pricing-table__category:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.pricing-table__category-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(.dark) .pricing-table__category-header {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.pricing-table__category-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: rgb(59, 130, 246);
|
||||
}
|
||||
|
||||
.pricing-table__category-title {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.pricing-table__simple {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.pricing-table__header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
margin-bottom: 0.5rem;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
:global(.dark) .pricing-table__header-row {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.pricing-table__list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.pricing-table__item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:global(.dark) .pricing-table__item {
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
|
||||
.pricing-table__item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.pricing-table__item-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.pricing-table__item-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.pricing-table__item-description {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.pricing-table__item-cost {
|
||||
flex-shrink: 0;
|
||||
padding: 0.25rem 0.625rem;
|
||||
border-radius: 9999px;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
color: rgb(59, 130, 246);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.pricing-table__item-cost--free {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.pricing-table__footer {
|
||||
margin: 1rem 0 0 0;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.05);
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
:global(.dark) .pricing-table__footer {
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
</style>
|
||||
257
packages/credits/src/web/CreditToast.svelte
Normal file
257
packages/credits/src/web/CreditToast.svelte
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<script lang="ts">
|
||||
import { formatCreditCost } from './operations';
|
||||
|
||||
interface Props {
|
||||
/** The operation name or description */
|
||||
operation: string;
|
||||
/** Amount of credits consumed (positive) or refunded (negative) */
|
||||
amount: number;
|
||||
/** Remaining balance after the transaction */
|
||||
remainingBalance?: number;
|
||||
/** Toast type */
|
||||
type?: 'success' | 'error' | 'warning';
|
||||
/** Whether the toast is visible */
|
||||
visible?: boolean;
|
||||
/** Callback when toast should be dismissed */
|
||||
onDismiss?: () => void;
|
||||
/** Auto-dismiss timeout in ms (0 = no auto-dismiss) */
|
||||
autoDismissMs?: number;
|
||||
/** i18n labels */
|
||||
creditsLabel?: string;
|
||||
remainingLabel?: string;
|
||||
insufficientLabel?: string;
|
||||
}
|
||||
|
||||
let {
|
||||
operation,
|
||||
amount,
|
||||
remainingBalance,
|
||||
type = 'success',
|
||||
visible = true,
|
||||
onDismiss,
|
||||
autoDismissMs = 4000,
|
||||
creditsLabel = 'Credits',
|
||||
remainingLabel = 'remaining',
|
||||
insufficientLabel = 'Insufficient credits',
|
||||
}: Props = $props();
|
||||
|
||||
const isDeduction = $derived(amount > 0);
|
||||
const formattedAmount = $derived(formatCreditCost(Math.abs(amount)));
|
||||
const formattedRemaining = $derived(
|
||||
remainingBalance !== undefined ? formatCreditCost(remainingBalance) : null
|
||||
);
|
||||
|
||||
// Auto-dismiss logic
|
||||
$effect(() => {
|
||||
if (visible && autoDismissMs > 0 && onDismiss) {
|
||||
const timer = setTimeout(() => {
|
||||
onDismiss();
|
||||
}, autoDismissMs);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if visible}
|
||||
<div
|
||||
class="credit-toast"
|
||||
class:credit-toast--success={type === 'success'}
|
||||
class:credit-toast--error={type === 'error'}
|
||||
class:credit-toast--warning={type === 'warning'}
|
||||
role="alert"
|
||||
>
|
||||
<div class="credit-toast__icon-wrapper">
|
||||
{#if type === 'success'}
|
||||
<svg class="credit-toast__icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else if type === 'error'}
|
||||
<svg class="credit-toast__icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="credit-toast__icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M8.485 2.495c.673-1.167 2.357-1.167 3.03 0l6.28 10.875c.673 1.167-.17 2.625-1.516 2.625H3.72c-1.347 0-2.189-1.458-1.515-2.625L8.485 2.495zM10 5a.75.75 0 01.75.75v3.5a.75.75 0 01-1.5 0v-3.5A.75.75 0 0110 5zm0 9a1 1 0 100-2 1 1 0 000 2z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="credit-toast__content">
|
||||
<p class="credit-toast__operation">{operation}</p>
|
||||
<div class="credit-toast__details">
|
||||
{#if type === 'error'}
|
||||
<span class="credit-toast__amount credit-toast__amount--error">{insufficientLabel}</span>
|
||||
{:else}
|
||||
<span class="credit-toast__amount" class:credit-toast__amount--refund={!isDeduction}>
|
||||
{isDeduction ? '-' : '+'}{formattedAmount}
|
||||
</span>
|
||||
{#if formattedRemaining !== null}
|
||||
<span class="credit-toast__remaining">
|
||||
({formattedRemaining}
|
||||
{remainingLabel})
|
||||
</span>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if onDismiss}
|
||||
<button class="credit-toast__dismiss" onclick={onDismiss} aria-label="Dismiss">
|
||||
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||
<path
|
||||
fill-rule="evenodd"
|
||||
d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z"
|
||||
clip-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.credit-toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.875rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(0, 0, 0, 0.1);
|
||||
box-shadow:
|
||||
0 10px 15px -3px rgba(0, 0, 0, 0.1),
|
||||
0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
min-width: 280px;
|
||||
max-width: 400px;
|
||||
animation: slide-in 0.2s ease-out;
|
||||
}
|
||||
|
||||
:global(.dark) .credit-toast {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-0.5rem);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.credit-toast__icon-wrapper {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.credit-toast--success .credit-toast__icon-wrapper {
|
||||
background: rgba(34, 197, 94, 0.1);
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.credit-toast--error .credit-toast__icon-wrapper {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
.credit-toast--warning .credit-toast__icon-wrapper {
|
||||
background: rgba(245, 158, 11, 0.1);
|
||||
color: rgb(245, 158, 11);
|
||||
}
|
||||
|
||||
.credit-toast__icon {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.credit-toast__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.credit-toast__operation {
|
||||
margin: 0 0 0.25rem 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.credit-toast__details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.credit-toast__amount {
|
||||
font-weight: 600;
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
.credit-toast__amount--refund {
|
||||
color: rgb(34, 197, 94);
|
||||
}
|
||||
|
||||
.credit-toast__amount--error {
|
||||
color: rgb(239, 68, 68);
|
||||
}
|
||||
|
||||
.credit-toast__remaining {
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.credit-toast__dismiss {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.credit-toast__dismiss:hover {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
:global(.dark) .credit-toast__dismiss:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.credit-toast__dismiss svg {
|
||||
width: 0.875rem;
|
||||
height: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
3
packages/credits/src/web/index.ts
Normal file
3
packages/credits/src/web/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export { default as CreditBalance } from './CreditBalance.svelte';
|
||||
export { default as CreditPricingTable } from './CreditPricingTable.svelte';
|
||||
export { default as CreditToast } from './CreditToast.svelte';
|
||||
14
packages/credits/tsconfig.json
Normal file
14
packages/credits/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ES2022",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"outDir": "dist",
|
||||
"declaration": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.svelte"]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue