Skip to content

Commit f05ec60

Browse files
committed
Implement new tier system with bytes-based limits and user-friendly UI
- Add tierConversion utility with MB/GB/TB conversion and validation - Create TierEditor component with number input + unit dropdown - Update AllowedUsersTier to use price_sats and monthly_limit_bytes - Migrate DEFAULT_TIERS to new format with proper byte values - Update API layer to send/receive new backend format - Fix all TypeScript errors in tier management components - Add validation for 1MB-1TB range and unlimited tiers - Maintain backward compatibility during transition Backend format: {name, price_sats, monthly_limit_bytes, unlimited} Frontend UI: User-friendly number + unit selection with real-time validation
1 parent 63ee1bf commit f05ec60

File tree

8 files changed

+557
-179
lines changed

8 files changed

+557
-179
lines changed

src/api/allowedUsers.api.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,10 @@ export const getAllowedUsersSettings = async (): Promise<AllowedUsersSettings> =
3737
// Check if tiers exist in response, otherwise use defaults
3838
if (allowedUsersData.tiers && Array.isArray(allowedUsersData.tiers)) {
3939
transformedTiers = allowedUsersData.tiers.map((tier: any) => ({
40-
data_limit: tier.datalimit || tier.data_limit || '',
41-
price: tier.price || '0'
40+
name: tier.name || 'Unnamed Tier',
41+
price_sats: tier.price_sats || 0,
42+
monthly_limit_bytes: tier.monthly_limit_bytes || 0,
43+
unlimited: tier.unlimited || false
4244
}));
4345
} else {
4446
// Use default tiers for the mode if none provided
@@ -48,10 +50,10 @@ export const getAllowedUsersSettings = async (): Promise<AllowedUsersSettings> =
4850

4951
// For free mode, reconstruct full UI options with active tier marked
5052
if (allowedUsersData.mode === 'free' && transformedTiers.length === 1) {
51-
const activeTierDataLimit = transformedTiers[0].data_limit;
53+
const activeTierBytes = transformedTiers[0].monthly_limit_bytes;
5254
transformedTiers = DEFAULT_TIERS.free.map(defaultTier => ({
5355
...defaultTier,
54-
active: defaultTier.data_limit === activeTierDataLimit
56+
active: defaultTier.monthly_limit_bytes === activeTierBytes
5557
}));
5658
}
5759

@@ -95,8 +97,10 @@ export const updateAllowedUsersSettings = async (settings: AllowedUsersSettings)
9597
"scope": settings.write_access.scope
9698
},
9799
"tiers": tiersToSend.map(tier => ({
98-
"datalimit": tier.data_limit || "1 GB per month", // Backend expects 'datalimit' not 'data_limit', fallback for empty values
99-
"price": tier.price || "0"
100+
"name": tier.name,
101+
"price_sats": tier.price_sats,
102+
"monthly_limit_bytes": tier.monthly_limit_bytes,
103+
"unlimited": tier.unlimited
100104
}))
101105
}
102106
}

src/components/allowed-users/components/NPubManagement/NPubManagement.tsx

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,16 @@ export const NPubManagement: React.FC<NPubManagementProps> = ({
7272

7373
setUnifiedUsers(Array.from(allNpubs.values()));
7474
}, [readNpubs.npubs, writeNpubs.npubs]);
75-
const tierOptions = settings.tiers.map(tier => ({
76-
label: `${tier.data_limit} (${tier.price === '0' ? 'Free' : `${tier.price} sats`})`,
77-
value: tier.data_limit
78-
}));
75+
const tierOptions = settings.tiers.map(tier => {
76+
const displayFormat = tier.unlimited
77+
? 'unlimited'
78+
: `${(tier.monthly_limit_bytes / 1073741824).toFixed(tier.monthly_limit_bytes % 1073741824 === 0 ? 0 : 1)} GB per month`;
79+
80+
return {
81+
label: `${tier.name} - ${displayFormat} (${tier.price_sats === 0 ? 'Free' : `${tier.price_sats} sats`})`,
82+
value: tier.name
83+
};
84+
});
7985

8086
const handleAddNpub = async () => {
8187
try {
@@ -140,7 +146,7 @@ export const NPubManagement: React.FC<NPubManagementProps> = ({
140146
}
141147

142148
const lines = bulkText.split('\n').filter(line => line.trim());
143-
const defaultTier = settings.tiers[0]?.data_limit || 'basic';
149+
const defaultTier = settings.tiers[0]?.name || 'basic';
144150

145151
try {
146152
for (const line of lines) {
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
import React, { useState, useEffect } from 'react';
2+
import { Input, Select, Checkbox, Space, Typography, Alert } from 'antd';
3+
import {
4+
TierDisplayFormat,
5+
DataUnit,
6+
validateTierFormat,
7+
displayToFriendlyString,
8+
bytesToDisplayFormat,
9+
TIER_VALIDATION
10+
} from '@app/utils/tierConversion.utils';
11+
import { AllowedUsersTier } from '@app/types/allowedUsers.types';
12+
13+
const { Text } = Typography;
14+
const { Option } = Select;
15+
16+
interface TierEditorProps {
17+
tier: AllowedUsersTier;
18+
onTierChange: (updatedTier: AllowedUsersTier) => void;
19+
disabled?: boolean;
20+
showName?: boolean;
21+
showPrice?: boolean;
22+
}
23+
24+
export const TierEditor: React.FC<TierEditorProps> = ({
25+
tier,
26+
onTierChange,
27+
disabled = false,
28+
showName = true,
29+
showPrice = true
30+
}) => {
31+
// Convert backend format to display format for editing
32+
const [displayFormat, setDisplayFormat] = useState<TierDisplayFormat>(() => {
33+
if (tier.unlimited) {
34+
return { value: 0, unit: 'MB', unlimited: true };
35+
}
36+
return { ...bytesToDisplayFormat(tier.monthly_limit_bytes), unlimited: false };
37+
});
38+
39+
const [name, setName] = useState(tier.name);
40+
const [priceSats, setPriceSats] = useState(tier.price_sats);
41+
42+
// Validation
43+
const validation = validateTierFormat(displayFormat);
44+
const isValid = validation.isValid;
45+
46+
// Update parent when any field changes
47+
useEffect(() => {
48+
if (isValid) {
49+
const updatedTier: AllowedUsersTier = {
50+
name,
51+
price_sats: priceSats,
52+
monthly_limit_bytes: displayFormat.unlimited ? 0 :
53+
Math.round(displayFormat.value * getUnitMultiplier(displayFormat.unit)),
54+
unlimited: displayFormat.unlimited,
55+
active: tier.active // Preserve active state
56+
};
57+
onTierChange(updatedTier);
58+
}
59+
}, [displayFormat, name, priceSats, isValid, onTierChange, tier.active]);
60+
61+
const getUnitMultiplier = (unit: DataUnit): number => {
62+
switch (unit) {
63+
case 'MB': return 1048576; // 1024 * 1024
64+
case 'GB': return 1073741824; // 1024 * 1024 * 1024
65+
case 'TB': return 1099511627776; // 1024 * 1024 * 1024 * 1024
66+
default: return 1048576;
67+
}
68+
};
69+
70+
const handleValueChange = (value: string) => {
71+
const numericValue = parseFloat(value) || 0;
72+
setDisplayFormat(prev => ({ ...prev, value: numericValue }));
73+
};
74+
75+
const handleUnitChange = (unit: DataUnit) => {
76+
setDisplayFormat(prev => ({ ...prev, unit }));
77+
};
78+
79+
const handleUnlimitedChange = (unlimited: boolean) => {
80+
setDisplayFormat(prev => ({ ...prev, unlimited }));
81+
};
82+
83+
const handleNameChange = (value: string) => {
84+
setName(value);
85+
};
86+
87+
const handlePriceChange = (value: string) => {
88+
const numericValue = parseInt(value) || 0;
89+
setPriceSats(numericValue);
90+
};
91+
92+
return (
93+
<Space direction="vertical" style={{ width: '100%' }}>
94+
{/* Tier Name */}
95+
{showName && (
96+
<div>
97+
<Text strong>Tier Name</Text>
98+
<Input
99+
value={name}
100+
onChange={(e) => handleNameChange(e.target.value)}
101+
placeholder="Enter tier name"
102+
disabled={disabled}
103+
style={{ marginTop: 4 }}
104+
/>
105+
</div>
106+
)}
107+
108+
{/* Price */}
109+
{showPrice && (
110+
<div>
111+
<Text strong>Price (sats)</Text>
112+
<Input
113+
type="number"
114+
value={priceSats}
115+
onChange={(e) => handlePriceChange(e.target.value)}
116+
placeholder="Price in satoshis"
117+
disabled={disabled}
118+
min={0}
119+
style={{ marginTop: 4 }}
120+
/>
121+
</div>
122+
)}
123+
124+
{/* Data Limit */}
125+
<div>
126+
<Text strong>Monthly Data Limit</Text>
127+
128+
{/* Unlimited Checkbox */}
129+
<div style={{ marginTop: 8, marginBottom: 8 }}>
130+
<Checkbox
131+
checked={displayFormat.unlimited}
132+
onChange={(e) => handleUnlimitedChange(e.target.checked)}
133+
disabled={disabled}
134+
>
135+
Unlimited
136+
</Checkbox>
137+
</div>
138+
139+
{/* Value and Unit Inputs */}
140+
{!displayFormat.unlimited && (
141+
<Space.Compact style={{ width: '100%' }}>
142+
<Input
143+
type="number"
144+
value={displayFormat.value || ''}
145+
onChange={(e) => handleValueChange(e.target.value)}
146+
placeholder="Amount"
147+
disabled={disabled}
148+
min={TIER_VALIDATION.MIN_VALUE}
149+
style={{ flex: 1 }}
150+
/>
151+
<Select
152+
value={displayFormat.unit}
153+
onChange={handleUnitChange}
154+
disabled={disabled}
155+
style={{ width: 80 }}
156+
>
157+
<Option value="MB">MB</Option>
158+
<Option value="GB">GB</Option>
159+
<Option value="TB">TB</Option>
160+
</Select>
161+
</Space.Compact>
162+
)}
163+
164+
{/* Preview */}
165+
<div style={{ marginTop: 8 }}>
166+
<Text type="secondary">
167+
Preview: {displayToFriendlyString(displayFormat)}
168+
</Text>
169+
</div>
170+
171+
{/* Validation Error */}
172+
{!isValid && validation.error && (
173+
<Alert
174+
message={validation.error}
175+
type="error"
176+
style={{ marginTop: 8 }}
177+
showIcon
178+
/>
179+
)}
180+
</div>
181+
182+
{/* Helpful Information */}
183+
<div style={{ marginTop: 8 }}>
184+
<Text type="secondary" style={{ fontSize: '12px' }}>
185+
Valid range: 1 MB to 1 TB
186+
</Text>
187+
</div>
188+
</Space>
189+
);
190+
};

0 commit comments

Comments
 (0)