@sudobility/consumables_pages 0.0.3 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CLAUDE.md CHANGED
@@ -26,4 +26,41 @@ All components are props-driven with labels/formatters for i18n support.
26
26
  bun run build # Build ESM
27
27
  bun run dev # Watch mode
28
28
  bun run typecheck # TypeScript check
29
+ bun test # Run tests
29
30
  ```
31
+
32
+ ## Related Projects
33
+
34
+ - **consumables_client** (`@sudobility/consumables_client`) — Peer dependency that provides hooks (`useBalance`, `usePurchaseCredits`, etc.) and data types. This package does NOT call those hooks internally; the consuming app calls them and passes data as props.
35
+ - **@sudobility/types** — Shared type definitions used across the consumables ecosystem.
36
+
37
+ Dependency direction: `consumables_pages` depends on `consumables_client` (peer dep for types only, not runtime calls)
38
+
39
+ ## Coding Patterns
40
+
41
+ - **Props-driven components (no internal state)**: Components receive all data and callbacks via props. They do not call hooks, manage state, or fetch data. This makes them pure presentational components.
42
+ - **Customizable labels and formatters for i18n**: All user-facing text is passed in via `labels` props, and numeric/date formatting uses `formatter` props. Never hardcode user-visible strings inside components.
43
+ - **Responsive design with Tailwind sm/lg breakpoints**: Components use Tailwind's `sm:` and `lg:` prefixes for responsive layouts (e.g., cards on mobile, tables on desktop). Follow this pattern for any new components.
44
+ - **No direct CSS**: All styling uses Tailwind utility classes. Do not introduce CSS files, CSS modules, or styled-components.
45
+
46
+ ## Gotchas
47
+
48
+ - **Consumer app must provide Tailwind CSS**: This package emits Tailwind class names but does NOT bundle Tailwind itself. If the consuming app does not have Tailwind configured, components will render unstyled. This is by design.
49
+ - **Components are purely presentational**: No hooks are called inside components. The parent app is responsible for calling `useBalance()`, `usePurchaseCredits()`, etc. from `consumables_client` and passing the results as props. Breaking this pattern creates tight coupling.
50
+ - **No internal state**: Components derive everything from props. If you need loading states or error states, they must be passed in as props, not managed internally with `useState`.
51
+ - **Peer dependency on consumables_client**: The package depends on `consumables_client` for TypeScript types (e.g., `CreditPackage`, `CreditBalance`), but it never imports runtime code from it. The peer dependency ensures type compatibility.
52
+
53
+ ## Testing
54
+
55
+ - Run tests: `bun test` (uses vitest)
56
+ - Tests use **React Testing Library** with **jsdom** environment.
57
+ - Test that components render correctly given various prop combinations (empty lists, loading states, different label overrides).
58
+ - Test responsive behavior by verifying correct CSS classes are applied.
59
+ - Do not test hook behavior here -- hooks belong to `consumables_client`.
60
+
61
+ ## Publishing
62
+
63
+ - Package: `@sudobility/consumables_pages` (public on npm)
64
+ - Build before publish: `bun run build` produces ESM output in `dist/`
65
+ - Bump version in `package.json`, then `npm publish --access public`
66
+ - Ensure `consumables_client` peer dependency version range is correct in `package.json` before publishing
@@ -0,0 +1,2 @@
1
+ import type { CreditBalanceBadgeProps } from "./types";
2
+ export declare function CreditBalanceBadge({ balance, isLoading, onClick, className, }: CreditBalanceBadgeProps): import("react/jsx-runtime").JSX.Element | null;
@@ -0,0 +1,12 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function CreditBalanceBadge({ balance, isLoading, onClick, className, }) {
3
+ if (isLoading) {
4
+ return (_jsx("span", { className: `inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-gray-400 ${className || ""}`, children: _jsx("span", { className: "animate-pulse", children: "..." }) }));
5
+ }
6
+ if (balance === null)
7
+ return null;
8
+ const Wrapper = onClick ? "button" : "span";
9
+ return (_jsxs(Wrapper, { onClick: onClick, className: `inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium ${balance > 0
10
+ ? "bg-blue-100 text-blue-700"
11
+ : "bg-red-100 text-red-700"} ${onClick ? "cursor-pointer hover:opacity-80" : ""} ${className || ""}`, children: [_jsx("svg", { className: "w-3.5 h-3.5", fill: "currentColor", viewBox: "0 0 20 20", children: _jsx("path", { fillRule: "evenodd", d: "M10 18a8 8 0 100-16 8 8 0 000 16zm1-13a1 1 0 10-2 0v.092a4.535 4.535 0 00-1.676.662C6.602 6.234 6 7.009 6 8c0 .99.602 1.765 1.324 2.246.48.32 1.054.545 1.676.662v1.941c-.391-.127-.68-.317-.843-.504a1 1 0 10-1.51 1.31c.562.649 1.413 1.076 2.353 1.253V15a1 1 0 102 0v-.092a4.535 4.535 0 001.676-.662C13.398 13.766 14 12.991 14 12c0-.99-.602-1.765-1.324-2.246A4.535 4.535 0 0011 9.092V7.151c.391.127.68.317.843.504a1 1 0 101.511-1.31c-.563-.649-1.413-1.076-2.354-1.253V5z", clipRule: "evenodd" }) }), balance] }));
12
+ }
@@ -0,0 +1,2 @@
1
+ import type { CreditStorePageProps } from "./types";
2
+ export declare function CreditStorePage({ isAuthenticated, balance, packages, isLoading, isPurchasing, error, onPurchase, onLoginClick, labels, formatters, className, }: CreditStorePageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function CreditStorePage({ isAuthenticated, balance, packages, isLoading, isPurchasing, error, onPurchase, onLoginClick, labels, formatters, className, }) {
3
+ return (_jsxs("div", { className: className, children: [_jsx("h1", { className: "text-2xl font-bold mb-6", children: labels.title }), isAuthenticated && balance !== null && (_jsxs("div", { className: "mb-8 p-4 bg-blue-50 rounded-lg border border-blue-200", children: [_jsx("p", { className: "text-sm text-blue-600 font-medium", children: labels.currentBalanceLabel }), _jsx("p", { className: "text-3xl font-bold text-blue-900", children: formatters.formatCredits(balance) })] })), error && (_jsxs("div", { className: "mb-6 p-4 bg-red-50 rounded-lg border border-red-200", children: [_jsx("p", { className: "text-sm font-medium text-red-800", children: labels.errorTitle }), _jsx("p", { className: "text-sm text-red-600", children: error })] })), !isAuthenticated && (_jsxs("div", { className: "mb-6 p-4 bg-yellow-50 rounded-lg border border-yellow-200", children: [_jsx("p", { className: "text-sm text-yellow-800", children: labels.loginRequired }), _jsx("button", { onClick: onLoginClick, className: "mt-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-lg hover:bg-blue-700", children: "Log in" })] })), isLoading && (_jsx("div", { className: "flex justify-center py-12", children: _jsx("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" }) })), !isLoading && packages.length === 0 && (_jsx("p", { className: "text-gray-500 text-center py-8", children: labels.noProducts })), !isLoading && packages.length > 0 && (_jsx("div", { className: "grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4", children: packages.map((pkg) => (_jsx("div", { className: "p-6 bg-white rounded-xl border border-gray-200 shadow-sm hover:shadow-md transition-shadow", children: _jsxs("div", { className: "text-center", children: [_jsx("p", { className: "text-3xl font-bold text-gray-900", children: formatters.formatCredits(pkg.credits) }), formatters.getPackageDescription && (_jsx("p", { className: "text-sm text-gray-500 mt-1", children: formatters.getPackageDescription(pkg.packageId) })), _jsx("p", { className: "text-xl font-semibold text-blue-600 mt-3", children: pkg.priceString }), _jsx("button", { onClick: () => onPurchase(pkg.packageId), disabled: isPurchasing || !isAuthenticated, className: "mt-4 w-full px-4 py-2.5 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors", children: isPurchasing
4
+ ? labels.purchasingButton
5
+ : labels.purchaseButton })] }) }, pkg.packageId))) }))] }));
6
+ }
@@ -0,0 +1,2 @@
1
+ import type { PurchaseHistoryPageProps } from "./types";
2
+ export declare function PurchaseHistoryPage({ purchases, isLoading, error, onLoadMore, hasMore, labels, formatters, className, }: PurchaseHistoryPageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,6 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ export function PurchaseHistoryPage({ purchases, isLoading, error, onLoadMore, hasMore, labels, formatters, className, }) {
3
+ return (_jsxs("div", { className: className, children: [_jsx("h1", { className: "text-2xl font-bold mb-6", children: labels.title }), error && (_jsx("div", { className: "mb-4 p-3 bg-red-50 rounded-lg border border-red-200", children: _jsx("p", { className: "text-sm text-red-600", children: error }) })), isLoading && (_jsx("div", { className: "flex justify-center py-12", children: _jsx("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" }) })), !isLoading && purchases.length === 0 && (_jsx("p", { className: "text-gray-500 text-center py-8", children: labels.noRecords })), !isLoading && purchases.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "hidden sm:block overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-gray-200", children: [_jsx("th", { className: "text-left py-3 px-4 font-medium text-gray-500", children: labels.columnDate }), _jsx("th", { className: "text-right py-3 px-4 font-medium text-gray-500", children: labels.columnCredits }), _jsx("th", { className: "text-left py-3 px-4 font-medium text-gray-500", children: labels.columnSource }), _jsx("th", { className: "text-right py-3 px-4 font-medium text-gray-500", children: labels.columnAmount })] }) }), _jsx("tbody", { children: purchases.map((purchase) => (_jsxs("tr", { className: "border-b border-gray-100 hover:bg-gray-50", children: [_jsx("td", { className: "py-3 px-4 text-gray-700", children: formatters.formatDate(purchase.created_at) }), _jsxs("td", { className: "py-3 px-4 text-right font-medium text-green-600", children: ["+", purchase.credits] }), _jsx("td", { className: "py-3 px-4 text-gray-600", children: formatters.formatSource(purchase.source) }), _jsx("td", { className: "py-3 px-4 text-right text-gray-600", children: purchase.price_cents != null && purchase.currency
4
+ ? formatters.formatAmount(purchase.price_cents, purchase.currency)
5
+ : "-" })] }, purchase.id))) })] }) }), _jsx("div", { className: "sm:hidden space-y-3", children: purchases.map((purchase) => (_jsx("div", { className: "p-4 bg-white rounded-lg border border-gray-200", children: _jsxs("div", { className: "flex justify-between items-start", children: [_jsxs("div", { children: [_jsx("p", { className: "text-sm text-gray-500", children: formatters.formatDate(purchase.created_at) }), _jsx("p", { className: "text-sm text-gray-600", children: formatters.formatSource(purchase.source) })] }), _jsxs("div", { className: "text-right", children: [_jsxs("p", { className: "font-medium text-green-600", children: ["+", purchase.credits] }), purchase.price_cents != null && purchase.currency && (_jsx("p", { className: "text-sm text-gray-500", children: formatters.formatAmount(purchase.price_cents, purchase.currency) }))] })] }) }, purchase.id))) }), hasMore && onLoadMore && (_jsx("div", { className: "mt-4 text-center", children: _jsx("button", { onClick: onLoadMore, className: "px-4 py-2 text-sm text-blue-600 hover:text-blue-800 font-medium", children: labels.loadMore }) }))] }))] }));
6
+ }
@@ -0,0 +1,2 @@
1
+ import type { UsageHistoryPageProps } from "./types";
2
+ export declare function UsageHistoryPage({ usages, isLoading, error, onLoadMore, hasMore, labels, formatters, className, }: UsageHistoryPageProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,4 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ export function UsageHistoryPage({ usages, isLoading, error, onLoadMore, hasMore, labels, formatters, className, }) {
3
+ return (_jsxs("div", { className: className, children: [_jsx("h1", { className: "text-2xl font-bold mb-6", children: labels.title }), error && (_jsx("div", { className: "mb-4 p-3 bg-red-50 rounded-lg border border-red-200", children: _jsx("p", { className: "text-sm text-red-600", children: error }) })), isLoading && (_jsx("div", { className: "flex justify-center py-12", children: _jsx("div", { className: "animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600" }) })), !isLoading && usages.length === 0 && (_jsx("p", { className: "text-gray-500 text-center py-8", children: labels.noRecords })), !isLoading && usages.length > 0 && (_jsxs(_Fragment, { children: [_jsx("div", { className: "hidden sm:block overflow-x-auto", children: _jsxs("table", { className: "w-full text-sm", children: [_jsx("thead", { children: _jsxs("tr", { className: "border-b border-gray-200", children: [_jsx("th", { className: "text-left py-3 px-4 font-medium text-gray-500", children: labels.columnDate }), _jsx("th", { className: "text-left py-3 px-4 font-medium text-gray-500", children: labels.columnFilename })] }) }), _jsx("tbody", { children: usages.map((usage) => (_jsxs("tr", { className: "border-b border-gray-100 hover:bg-gray-50", children: [_jsx("td", { className: "py-3 px-4 text-gray-700", children: formatters.formatDate(usage.created_at) }), _jsx("td", { className: "py-3 px-4 text-gray-600", children: usage.filename || "-" })] }, usage.id))) })] }) }), _jsx("div", { className: "sm:hidden space-y-3", children: usages.map((usage) => (_jsxs("div", { className: "p-4 bg-white rounded-lg border border-gray-200", children: [_jsx("p", { className: "text-sm text-gray-500", children: formatters.formatDate(usage.created_at) }), _jsx("p", { className: "text-sm text-gray-700 font-medium", children: usage.filename || "-" })] }, usage.id))) }), hasMore && onLoadMore && (_jsx("div", { className: "mt-4 text-center", children: _jsx("button", { onClick: onLoadMore, className: "px-4 py-2 text-sm text-blue-600 hover:text-blue-800 font-medium", children: labels.loadMore }) }))] }))] }));
4
+ }
@@ -0,0 +1,5 @@
1
+ export { CreditStorePage } from "./CreditStorePage";
2
+ export { PurchaseHistoryPage } from "./PurchaseHistoryPage";
3
+ export { UsageHistoryPage } from "./UsageHistoryPage";
4
+ export { CreditBalanceBadge } from "./CreditBalanceBadge";
5
+ export type { CreditStorePageProps, CreditStorePageLabels, CreditStorePageFormatters, PurchaseHistoryPageProps, PurchaseHistoryPageLabels, PurchaseHistoryPageFormatters, UsageHistoryPageProps, UsageHistoryPageLabels, UsageHistoryPageFormatters, CreditBalanceBadgeProps, } from "./types";
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export { CreditStorePage } from "./CreditStorePage";
2
+ export { PurchaseHistoryPage } from "./PurchaseHistoryPage";
3
+ export { UsageHistoryPage } from "./UsageHistoryPage";
4
+ export { CreditBalanceBadge } from "./CreditBalanceBadge";
@@ -0,0 +1,80 @@
1
+ import type { CreditPackage } from "@sudobility/consumables_client";
2
+ import type { ConsumablePurchaseRecord, ConsumableUsageRecord } from "@sudobility/types";
3
+ export interface CreditStorePageLabels {
4
+ title: string;
5
+ currentBalanceLabel: string;
6
+ creditsUnit: string;
7
+ purchaseButton: string;
8
+ purchasingButton: string;
9
+ noProducts: string;
10
+ errorTitle: string;
11
+ loginRequired: string;
12
+ }
13
+ export interface CreditStorePageFormatters {
14
+ formatCredits: (count: number) => string;
15
+ getPackageDescription?: (packageId: string) => string;
16
+ }
17
+ export interface CreditStorePageProps {
18
+ isAuthenticated: boolean;
19
+ balance: number | null;
20
+ packages: CreditPackage[];
21
+ isLoading: boolean;
22
+ isPurchasing: boolean;
23
+ error: string | null;
24
+ onPurchase: (packageId: string) => Promise<void>;
25
+ onLoginClick: () => void;
26
+ labels: CreditStorePageLabels;
27
+ formatters: CreditStorePageFormatters;
28
+ className?: string;
29
+ }
30
+ export interface PurchaseHistoryPageLabels {
31
+ title: string;
32
+ columnDate: string;
33
+ columnCredits: string;
34
+ columnSource: string;
35
+ columnProduct: string;
36
+ columnAmount: string;
37
+ noRecords: string;
38
+ loadMore: string;
39
+ }
40
+ export interface PurchaseHistoryPageFormatters {
41
+ formatDate: (dateStr: string) => string;
42
+ formatAmount: (cents: number, currency: string) => string;
43
+ formatSource: (source: string) => string;
44
+ }
45
+ export interface PurchaseHistoryPageProps {
46
+ purchases: ConsumablePurchaseRecord[];
47
+ isLoading: boolean;
48
+ error: string | null;
49
+ onLoadMore?: () => void;
50
+ hasMore?: boolean;
51
+ labels: PurchaseHistoryPageLabels;
52
+ formatters: PurchaseHistoryPageFormatters;
53
+ className?: string;
54
+ }
55
+ export interface UsageHistoryPageLabels {
56
+ title: string;
57
+ columnDate: string;
58
+ columnFilename: string;
59
+ noRecords: string;
60
+ loadMore: string;
61
+ }
62
+ export interface UsageHistoryPageFormatters {
63
+ formatDate: (dateStr: string) => string;
64
+ }
65
+ export interface UsageHistoryPageProps {
66
+ usages: ConsumableUsageRecord[];
67
+ isLoading: boolean;
68
+ error: string | null;
69
+ onLoadMore?: () => void;
70
+ hasMore?: boolean;
71
+ labels: UsageHistoryPageLabels;
72
+ formatters: UsageHistoryPageFormatters;
73
+ className?: string;
74
+ }
75
+ export interface CreditBalanceBadgeProps {
76
+ balance: number | null;
77
+ isLoading: boolean;
78
+ onClick?: () => void;
79
+ className?: string;
80
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sudobility/consumables_pages",
3
- "version": "0.0.3",
3
+ "version": "0.0.6",
4
4
  "description": "Web UI components for consumable credits (credit store, purchase history, usage history)",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -17,7 +17,7 @@
17
17
  "CLAUDE.md"
18
18
  ],
19
19
  "scripts": {
20
- "build": "tsc",
20
+ "build": "tsc -p tsconfig.build.json",
21
21
  "dev": "tsc --watch",
22
22
  "clean": "rm -rf dist",
23
23
  "typecheck": "bunx tsc --noEmit",
@@ -29,13 +29,13 @@
29
29
  "prepublishOnly": "bun run build"
30
30
  },
31
31
  "peerDependencies": {
32
- "@sudobility/consumables_client": "^0.0.3",
33
- "@sudobility/types": "^1.9.51",
32
+ "@sudobility/consumables_client": "^0.0.4",
33
+ "@sudobility/types": "^1.9.52",
34
34
  "react": "^18.0.0 || ^19.0.0",
35
35
  "react-dom": "^18.0.0 || ^19.0.0"
36
36
  },
37
37
  "devDependencies": {
38
- "@sudobility/consumables_client": "^0.0.3",
38
+ "@sudobility/consumables_client": "^0.0.4",
39
39
  "@testing-library/jest-dom": "^6.9.1",
40
40
  "@testing-library/react": "^16.3.2",
41
41
  "@types/bun": "^1.2.8",