🐛 fix(mana-core-auth): use BASE_URL as JWT issuer for OIDC compatibility

OIDC providers like Synapse expect the JWT issuer claim to match the
discovery document's issuer URL. Changed JWT plugin config from
JWT_ISSUER to BASE_URL to ensure consistency.

Also adds:
- @manacore/credit-operations package with operation definitions
- @manacore/shared-credit-ui package with React Native and Svelte components
- CreditInterceptor and @UseCredits decorator in nestjs-integration
- Credit system integration in chat backend
This commit is contained in:
Till-JS 2026-02-01 13:55:05 +01:00
parent 075051a1d4
commit 8cd5021b50
29 changed files with 3351 additions and 329 deletions

View file

@ -0,0 +1,27 @@
{
"name": "@manacore/credit-operations",
"version": "1.0.0",
"private": true,
"description": "Central credit operation definitions and costs for all Mana apps",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"scripts": {
"build": "tsc",
"dev": "tsc --watch",
"clean": "rm -rf dist",
"type-check": "tsc --noEmit"
},
"devDependencies": {
"typescript": "^5.0.0"
},
"files": [
"dist"
]
}

View file

@ -0,0 +1,580 @@
/**
* @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',
// 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_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',
},
// 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}_`)
);
}

View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"lib": ["ES2020"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noImplicitThis": true,
"alwaysStrict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": false,
"moduleResolution": "node",
"baseUrl": ".",
"esModuleInterop": true,
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View file

@ -20,6 +20,11 @@
"types": "./dist/decorators/index.d.ts",
"import": "./dist/decorators/index.js",
"require": "./dist/decorators/index.js"
},
"./interceptors": {
"types": "./dist/interceptors/index.d.ts",
"import": "./dist/interceptors/index.js",
"require": "./dist/interceptors/index.js"
}
},
"scripts": {
@ -30,10 +35,12 @@
"type-check": "tsc --noEmit"
},
"dependencies": {
"@manacore/credit-operations": "workspace:*",
"@nestjs/common": "^10.0.0 || ^11.0.0",
"@nestjs/config": "^3.0.0 || ^4.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0",
"reflect-metadata": "^0.1.13 || ^0.2.0"
"reflect-metadata": "^0.1.13 || ^0.2.0",
"rxjs": "^7.0.0"
},
"devDependencies": {
"@types/node": "^20.0.0",

View file

@ -1,2 +1,3 @@
export { CurrentUser, JwtPayload } from './current-user.decorator';
export { Public, IS_PUBLIC_KEY } from './public.decorator';
export { UseCredits, CreditOperationConfig, CREDIT_OPERATION_KEY } from './use-credits.decorator';

View file

@ -0,0 +1,97 @@
import { SetMetadata, applyDecorators, UseInterceptors } from '@nestjs/common';
import { CreditInterceptor } from '../interceptors/credit.interceptor';
import { type CreditOperationType } from '@manacore/credit-operations';
/**
* Metadata key for credit operation configuration.
*/
export const CREDIT_OPERATION_KEY = 'credit_operation';
/**
* Configuration for credit consumption.
*/
export interface CreditOperationConfig {
/**
* The operation type from the credit-operations package.
*/
operation: CreditOperationType;
/**
* Custom cost override. If not specified, uses the default from CREDIT_COSTS.
*/
customCost?: number;
/**
* Whether to consume credits before or after the handler execution.
* - 'before': Validate and reserve credits before execution (default)
* - 'after': Consume credits only after successful execution
*/
consumeMode?: 'before' | 'after';
/**
* Optional function to calculate cost dynamically based on request.
* Receives the request object and should return the credit cost.
*/
dynamicCost?: (request: any) => number;
/**
* Optional function to generate description for the transaction.
* Receives the request object and should return a description string.
*/
descriptionFn?: (request: any) => string;
/**
* Whether to skip the credit check in development mode.
* Default: false
*/
skipInDev?: boolean;
}
/**
* Decorator to require credits for an endpoint.
*
* @example Simple usage with operation type:
* ```typescript
* @Post('tasks')
* @UseCredits(CreditOperationType.TASK_CREATE)
* async createTask(@Body() dto: CreateTaskDto) {
* return this.taskService.create(dto);
* }
* ```
*
* @example With configuration object:
* ```typescript
* @Post('generate')
* @UseCredits({
* operation: CreditOperationType.AI_IMAGE_GENERATION,
* consumeMode: 'after',
* descriptionFn: (req) => `Generated image: ${req.body.prompt}`,
* })
* async generateImage(@Body() dto: GenerateDto) {
* return this.imageService.generate(dto);
* }
* ```
*
* @example With dynamic cost:
* ```typescript
* @Post('bulk-import')
* @UseCredits({
* operation: CreditOperationType.BULK_IMPORT,
* dynamicCost: (req) => Math.ceil(req.body.items.length / 10) * 0.2,
* })
* async bulkImport(@Body() dto: BulkImportDto) {
* return this.importService.import(dto);
* }
* ```
*/
export function UseCredits(
operationOrConfig: CreditOperationType | CreditOperationConfig
): MethodDecorator {
const config: CreditOperationConfig =
typeof operationOrConfig === 'string' ? { operation: operationOrConfig } : operationOrConfig;
return applyDecorators(
SetMetadata(CREDIT_OPERATION_KEY, config),
UseInterceptors(CreditInterceptor)
);
}

View file

@ -15,6 +15,14 @@ export { OptionalAuthGuard } from './guards/optional-auth.guard';
// Decorators
export { CurrentUser, JwtPayload } from './decorators/current-user.decorator';
export { Public, IS_PUBLIC_KEY } from './decorators/public.decorator';
export {
UseCredits,
CreditOperationConfig,
CREDIT_OPERATION_KEY,
} from './decorators/use-credits.decorator';
// Interceptors
export { CreditInterceptor } from './interceptors/credit.interceptor';
// Services
export {
@ -28,3 +36,18 @@ export {
InsufficientCreditsException,
InsufficientCreditsDetails,
} from './exceptions/insufficient-credits.exception';
// Re-export credit operations for convenience
export {
CreditOperationType,
CREDIT_COSTS,
CreditCategory,
getCreditCost,
getOperationMetadata,
getOperationsForApp,
formatCreditCost,
getPricingTable,
isFreeOperation,
isMicroCreditOperation,
isAiOperation,
} from '@manacore/credit-operations';

View file

@ -0,0 +1,195 @@
import {
Injectable,
NestInterceptor,
ExecutionContext,
CallHandler,
Logger,
Inject,
Optional,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { Observable, tap, catchError, throwError } from 'rxjs';
import { CreditClientService } from '../services/credit-client.service';
import {
InsufficientCreditsException,
InsufficientCreditsDetails,
} from '../exceptions/insufficient-credits.exception';
import { CREDIT_OPERATION_KEY, CreditOperationConfig } from '../decorators/use-credits.decorator';
import { CREDIT_COSTS, getOperationMetadata } from '@manacore/credit-operations';
import { MANA_CORE_OPTIONS } from '../mana-core.module';
import { ManaCoreModuleOptions } from '../interfaces/mana-core-options.interface';
/**
* Interceptor that handles credit validation and consumption for decorated endpoints.
*
* This interceptor:
* 1. Checks if the user has sufficient credits before executing the handler
* 2. Consumes credits after successful execution (or before, depending on config)
* 3. Throws InsufficientCreditsException if the user doesn't have enough credits
*/
@Injectable()
export class CreditInterceptor implements NestInterceptor {
private readonly logger = new Logger(CreditInterceptor.name);
constructor(
private readonly reflector: Reflector,
private readonly creditClient: CreditClientService,
@Optional()
@Inject(MANA_CORE_OPTIONS)
private readonly options?: ManaCoreModuleOptions
) {}
async intercept(context: ExecutionContext, next: CallHandler): Promise<Observable<any>> {
const config = this.reflector.get<CreditOperationConfig>(
CREDIT_OPERATION_KEY,
context.getHandler()
);
// If no config, just proceed (shouldn't happen if decorator is used correctly)
if (!config) {
return next.handle();
}
const request = context.switchToHttp().getRequest();
const user = request.user;
// Check if user is authenticated
if (!user?.sub) {
this.logger.warn('No authenticated user found for credit operation');
return next.handle();
}
const userId = user.sub;
const operationName = config.operation;
// Calculate cost
const cost = this.calculateCost(config, request);
const consumeMode = config.consumeMode || 'after';
// Skip in development if configured
if (config.skipInDev && this.isDevelopment()) {
this.logger.debug(`Skipping credit check in development for ${operationName}`);
return next.handle();
}
// Validate credits before execution
const validation = await this.creditClient.validateCredits(userId, operationName, cost);
if (!validation.hasCredits) {
const details: InsufficientCreditsDetails = {
requiredCredits: cost,
availableCredits: validation.availableCredits,
creditType: 'user',
operation: operationName,
};
throw new InsufficientCreditsException(details);
}
// If consume mode is 'before', consume now
if (consumeMode === 'before') {
const description = this.generateDescription(config, request);
const consumed = await this.creditClient.consumeCredits(
userId,
operationName,
cost,
description,
this.buildMetadata(config, request)
);
if (!consumed) {
this.logger.error(`Failed to consume credits for ${operationName}`);
// Still allow the operation to proceed - fail open
}
return next.handle();
}
// If consume mode is 'after', consume on success
return next.handle().pipe(
tap(async () => {
const description = this.generateDescription(config, request);
const consumed = await this.creditClient.consumeCredits(
userId,
operationName,
cost,
description,
this.buildMetadata(config, request)
);
if (!consumed) {
this.logger.error(`Failed to consume credits after success for ${operationName}`);
} else if (this.options?.debug) {
this.logger.log(`Consumed ${cost} credits for ${operationName} (user: ${userId})`);
}
}),
catchError((error) => {
// Don't consume credits if the operation failed
this.logger.debug(`Operation ${operationName} failed, credits not consumed`);
return throwError(() => error);
})
);
}
/**
* Calculate the credit cost for the operation.
*/
private calculateCost(config: CreditOperationConfig, request: any): number {
// Dynamic cost takes priority
if (config.dynamicCost) {
return config.dynamicCost(request);
}
// Custom cost override
if (config.customCost !== undefined) {
return config.customCost;
}
// Default cost from CREDIT_COSTS
return CREDIT_COSTS[config.operation] || 0;
}
/**
* Generate a description for the credit transaction.
*/
private generateDescription(config: CreditOperationConfig, request: any): string {
// Custom description function
if (config.descriptionFn) {
return config.descriptionFn(request);
}
// Default description from operation metadata
const metadata = getOperationMetadata(config.operation);
return metadata?.name || config.operation;
}
/**
* Build metadata for the credit transaction.
*/
private buildMetadata(config: CreditOperationConfig, request: any): Record<string, any> {
const metadata: Record<string, any> = {
operation: config.operation,
path: request.path,
method: request.method,
};
// Add app info from operation metadata
const opMeta = getOperationMetadata(config.operation);
if (opMeta) {
metadata.app = opMeta.app;
metadata.category = opMeta.category;
}
return metadata;
}
/**
* Check if running in development mode.
*/
private isDevelopment(): boolean {
return (
this.options?.debug ||
process.env.NODE_ENV === 'development' ||
process.env.NODE_ENV === 'dev'
);
}
}

View file

@ -0,0 +1 @@
export { CreditInterceptor } from './credit.interceptor';

View file

@ -0,0 +1,51 @@
{
"name": "@manacore/shared-credit-ui",
"version": "1.0.0",
"private": true,
"description": "Credit system UI components for web (Svelte) and mobile (React Native)",
"exports": {
".": {
"types": "./src/web/index.ts",
"svelte": "./src/web/index.ts",
"default": "./src/web/index.ts"
},
"./web": {
"types": "./src/web/index.ts",
"svelte": "./src/web/index.ts",
"default": "./src/web/index.ts"
},
"./mobile": {
"types": "./src/mobile/index.ts",
"default": "./src/mobile/index.ts"
}
},
"scripts": {
"type-check": "svelte-check --tsconfig ./tsconfig.json"
},
"dependencies": {
"@manacore/credit-operations": "workspace:*"
},
"peerDependencies": {
"svelte": "^5.0.0",
"react": "^18.0.0",
"react-native": "^0.74.0 || ^0.75.0 || ^0.76.0 || ^0.77.0 || ^0.78.0 || ^0.79.0 || ^0.80.0 || ^0.81.0"
},
"peerDependenciesMeta": {
"svelte": {
"optional": true
},
"react": {
"optional": true
},
"react-native": {
"optional": true
}
},
"devDependencies": {
"@types/react": "^18.0.0",
"@types/react-native": "^0.73.0",
"svelte": "^5.0.0",
"svelte-check": "^4.0.0",
"typescript": "^5.0.0"
}
}

View file

@ -0,0 +1,217 @@
import React from 'react';
import { View, Text, TouchableOpacity, ActivityIndicator, StyleSheet } from 'react-native';
import { formatCreditCost } from '@manacore/credit-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;

View file

@ -0,0 +1,253 @@
import React, { useEffect } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Animated } from 'react-native';
import { formatCreditCost } from '@manacore/credit-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;

View file

@ -0,0 +1,19 @@
/**
* Credit UI components for mobile (React Native)
*/
export { CreditBalance } from './CreditBalance';
export { CreditToast } from './CreditToast';
// Re-export useful functions from credit-operations
export {
formatCreditCost,
getCreditCost,
getOperationMetadata,
getPricingTable,
isFreeOperation,
isMicroCreditOperation,
isAiOperation,
CreditOperationType,
CreditCategory,
} from '@manacore/credit-operations';

View file

@ -0,0 +1,339 @@
<script lang="ts">
import { formatCreditCost } from '@manacore/credit-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>

View file

@ -0,0 +1,324 @@
<script lang="ts">
import {
getPricingTable,
CreditCategory,
type CreditOperationType,
} from '@manacore/credit-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>

View file

@ -0,0 +1,257 @@
<script lang="ts">
import { formatCreditCost } from '@manacore/credit-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>

View file

@ -0,0 +1,20 @@
/**
* Credit UI components for web (Svelte 5)
*/
export { default as CreditBalance } from './CreditBalance.svelte';
export { default as CreditToast } from './CreditToast.svelte';
export { default as CreditPricingTable } from './CreditPricingTable.svelte';
// Re-export useful functions from credit-operations
export {
formatCreditCost,
getCreditCost,
getOperationMetadata,
getPricingTable,
isFreeOperation,
isMicroCreditOperation,
isAiOperation,
CreditOperationType,
CreditCategory,
} from '@manacore/credit-operations';

View file

@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext", "DOM"],
"declaration": true,
"strict": true,
"noImplicitAny": true,
"strictNullChecks": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noImplicitReturns": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"jsx": "react-native",
"types": ["svelte", "react", "react-native"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}