Initial support for available buzz per domain

This commit is contained in:
Luis Rojas
2025-09-29 17:24:37 -04:00
parent 268e300de5
commit a723354056
19 changed files with 496 additions and 289 deletions
@@ -0,0 +1,337 @@
# Multi-Domain Buzz Currency Restriction Implementation Plan
## Executive Summary
This plan addresses the migration to support multiple sub-domains where each supports one primary Buzz currency (Yellow or Green) while allowing Blue Buzz everywhere. The implementation enforces currency restrictions at both transaction and UI levels.
## Current Architecture Analysis
### Current Buzz Implementation
- **Transaction Service**: `src/server/services/buzz.service.ts` handles all Buzz transactions
- **Account Types**: Yellow, Green, Blue, Red (disabled), with various config options
- **Front-end**: Uses `useBuzz.ts`, `buzz.utils.ts` for balance queries and transactions
- **Feature Flags**: `isGreen` flag exists and controls domain-specific behavior
### Affected Features
1. **Model Training** - Uses `BuzzTransactionButton` with configurable account types
2. **Generation** - Supports multi-account transactions via `createMultiAccountBuzzTransaction`
3. **Tipping** - Uses `SendTipModal` with currency type selection
4. **Model Early Access** - Uses `BuzzTransactionButton` for purchases
5. **Bounty Creation** - Uses `BuzzTransactionButton` with `buzzSpendTypes`
6. **Cosmetic Purchases** - Uses `createMultiAccountBuzzTransaction`
## Implementation Plan
### Phase 1: Back-end Transaction Logic Changes
#### 1.1 Update Transaction Validation (`src/utils/buzz.ts`)
```typescript
// Update getBuzzTransactionSupportedAccountTypes to accept base array approach
export const getBuzzTransactionSupportedAccountTypes = ({
nsfwLevel,
isNsfw,
baseTypes, // New parameter for domain-filtered account types
}: {
nsfwLevel?: NsfwLevel;
isNsfw?: boolean;
baseTypes?: BuzzSpendType[]; // Defaults to all spend types if not provided
}): BuzzSpendType[] => {
// Function filters baseTypes based on content restrictions
// This keeps the service layer clean and domain-agnostic
}
// Services remain clean - no domain knowledge needed
// Controllers/routers will handle filtering account types based on domain
// No changes to createBuzzTransaction or createMultiAccountBuzzTransaction needed
// They already accept fromAccountType and fromAccountTypes parameters
```
#### 1.2 Update Controllers and Routers (Not Services)
Services remain unchanged - they already accept account types as parameters.
Controllers and routers will filter allowed account types based on feature flags.
**Controller Helper Functions:**
```typescript
// src/server/utils/buzz-helpers.ts
export function getAllowedAccountTypes(
features: FeatureAccess,
baseTypes: BuzzSpendType[] = ['blue'] // Default includes blue (universal currency)
): BuzzSpendType[] {
const domainTypes: BuzzSpendType[] = [];
if (features.isGreen) {
domainTypes.push('green');
} else {
domainTypes.push('yellow');
}
return [...domainTypes, ...baseTypes];
}
```
#### 1.3 TRPC Context
No changes needed - `isGreen` feature flag already exists in context under `features.isGreen`.
### Phase 2: Front-end Integration with Feature Flags
#### 2.1 Create Domain-Aware Buzz Hook
**New Hook (`src/components/Buzz/useAvailableBuzz.ts`):**
```typescript
export function useAvailableBuzz(includeBlue = false): BuzzSpendType[] {
const features = useFeatureFlags();
return useMemo(() => {
const allowedTypes: BuzzSpendType[] = [];
if (features.isGreen) {
allowedTypes.push('green');
} else {
allowedTypes.push('yellow');
}
if (includeBlue) {
allowedTypes.push('blue');
}
return allowedTypes;
}, [features.isGreen, includeBlue]);
}
```
**Update BuzzTransactionButton:**
```typescript
export function BuzzTransactionButton({
buzzAmount,
accountTypes: propAccountTypes,
includeBlue = false,
// ... other props
}: Props) {
const domainAllowedTypes = useAvailableBuzz(includeBlue);
// Use provided account types filtered by domain, or domain defaults
const allowedAccountTypes = propAccountTypes
? propAccountTypes.filter(type => domainAllowedTypes.includes(type))
: domainAllowedTypes;
// ... rest of component using allowedAccountTypes
}
```
**SendTipModal (`src/components/Modals/SendTipModal.tsx`):**
```typescript
// Use the new hook for consistency
const supportedCurrencyTypes = useAvailableBuzz(true); // Include blue for tipping
```
#### 2.2 Update Buzz Utilities
**useBuzz.ts:**
```typescript
export function useQueryBuzz(buzzTypes?: BuzzSpendType[]) {
const defaultTypes = useAvailableBuzz(true); // Include blue by default
const accountTypes = buzzTypes ?? defaultTypes;
// ... rest of implementation
}
```
**buzz.utils.ts:**
```typescript
export const useBuzzTransaction = (opts?: {
// ... existing options
includeBlue?: boolean;
}) => {
const defaultAccountTypes = useAvailableBuzz(opts?.includeBlue ?? true);
const {
accountTypes = defaultAccountTypes,
// ... other opts
} = opts ?? {};
// ... rest of implementation
};
```
#### 2.3 Update Generation Forms
**GenerationForm2.tsx:**
- Pass domain context to generation requests
- Filter available account types for cost calculation
**TrainingSubmit.tsx:**
- Update account type selection based on domain
- Show appropriate currency restrictions in UI
### Phase 3: API Changes
#### 3.1 Router Updates
**IMPORTANT**: Services should remain domain-agnostic. Calculate allowed account types at router level and pass them down to services.
Update all relevant routers to filter account types based on domain:
- `buzz.router.ts`
- `orchestrator.router.ts`
- `bounty.router.ts`
- `model-version.router.ts`
- `cosmetic-shop.router.ts`
Example pattern:
```typescript
// In router file
import { getAllowedAccountTypes } from '~/server/utils/buzz-helpers';
// For services using createMultiAccountBuzzTransaction
purchaseItem: protectedProcedure
.input(purchaseItemInput)
.mutation(async ({ ctx, input }) => {
// Calculate domain-allowed account types at router level
const allowedAccountTypes = getAllowedAccountTypes(ctx.features);
return purchaseItemService({
...input,
userId: ctx.user.id,
allowedAccountTypes, // Pass to service, not features
});
})
// For direct transaction validation
createTransaction: protectedProcedure
.input(createBuzzTransactionInput)
.mutation(async ({ ctx, input }) => {
// Validate fromAccountType against domain restrictions
if (input.fromAccountType) {
const allowedTypes = getAllowedAccountTypes(ctx.features);
if (!allowedTypes.includes(input.fromAccountType as BuzzSpendType)) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: `${input.fromAccountType} Buzz is not allowed on this domain`
});
}
}
return createBuzzTransaction({
...input,
fromAccountId: ctx.user.id,
});
})
```
#### 3.2 Schema Updates
No schema changes needed - existing `fromAccountType` and `fromAccountTypes` fields are sufficient.
Validation occurs at the router level using feature flags from context.
### Phase 4: Testing Strategy
@dev: you can skip the tests. I'll test manually.
#### 4.1 Testing Approach
Focus on integration and E2E testing rather than unit tests.
**Router Integration Tests:**
```typescript
// Test router validation logic
describe('Buzz router domain restrictions', () => {
it('should reject invalid account types for domain', async () => {
// Test API calls with wrong currency types
});
});
```
**Component Integration Tests:**
```typescript
// Test hook behavior
describe('useAvailableBuzz', () => {
it('returns correct currencies for each domain type', () => {
// Test hook with different feature flag values
});
});
```
#### 4.2 Integration Tests
**Feature Flow Tests:**
- Test complete generation flow on Green vs non-Green domains
- Test tipping with different currency types across domains
- Test model training submission with currency restrictions
- Test early access purchases with domain-specific currencies
- Test bounty creation with appropriate currency filtering
- Test cosmetic purchases across different domains
#### 4.3 End-to-End Tests
**Multi-Domain Scenarios:**
- User switches between Green and non-Green subdomains
- Transaction attempts with wrong currency types are blocked
- UI correctly shows/hides currency options based on domain
- Error messages are appropriate for blocked transactions
### Phase 5: Implementation Order
#### 5.1 Backend Implementation (Week 1)
1. Create `buzz-helpers.ts` utility functions for domain-based account type filtering
2. Update router validation logic to filter account types based on feature flags
3. Test router integration with domain restrictions
4. No service or context changes needed
#### 5.2 Frontend Implementation (Week 2)
1. Create `useAvailableBuzz` hook for domain-aware currency filtering
2. Update `BuzzTransactionButton` to use new hook with `includeBlue` parameter
3. Update `useBuzz` and `buzz.utils` to use new hook for defaults
4. Update transaction modals and forms to use new hook
5. Write component integration tests
#### 5.3 Integration & Testing (Week 3)
1. Integration testing across all affected features
2. End-to-end testing on different domain configurations
3. Performance testing for additional validation overhead
4. User acceptance testing with domain switching scenarios
#### 5.4 Deployment & Monitoring (Week 4)
1. Feature flag rollout strategy
2. Monitoring transaction success rates
3. Error logging for blocked transactions
4. User feedback collection and iteration
## Acceptance Criteria Verification
**Green Domain Restrictions**: Only Green and Blue Buzz usable when `isGreen = true`
**Non-Green Domain Restrictions**: Only Yellow and Blue Buzz usable when `isGreen = false`
**Blue Buzz Universal**: Blue Buzz works everywhere regardless of flag
**Frontend Display**: UI shows correct primary Buzz color for current subdomain
**Automated Testing**: Comprehensive test coverage for all behaviors and features
## Risk Mitigation
- **Feature Flag Rollout**: Gradual release with ability to disable restrictions
- **Backward Compatibility**: Existing transactions continue working during transition
- **User Communication**: Clear error messages when transactions are blocked
- **Fallback Mechanisms**: Blue Buzz as universal fallback option
- **Monitoring**: Transaction success rate monitoring and alerting
## Additional Considerations
### Performance Impact
- Minimal overhead from additional validation logic
- Feature flag lookups are cached and performant
- Database queries remain unchanged
### User Experience
- Clear visual indicators of allowed currencies per domain
- Graceful error handling for restricted transactions
- Consistent currency color coding across domains
### Rollback Strategy
- Feature flags allow instant disable of restrictions
- Database schema changes are additive only
- Rollback testing included in deployment plan
## Success Metrics
- **Functional**: 100% of restricted transactions properly blocked
- **User Experience**: Error rate < 1% for valid transactions
- **Performance**: < 5ms additional latency for transaction validation
- **Coverage**: 90%+ test coverage for all new validation logic
@@ -36,6 +36,7 @@ import {
import { BrowsingModeMenu } from '~/components/BrowsingMode/BrowsingMode';
import { Burger } from '~/components/Burger/Burger';
import { useBuyBuzz } from '~/components/Buzz/buzz.utils';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
import {
type CivitaiAccount,
useAccountContext,
@@ -444,6 +445,7 @@ function BuzzMenuItem() {
const isMobile = useIsMobile({ breakpoint: 'md' });
const onBuyBuzz = useBuyBuzz();
const { handleClose } = useUserMenuContext();
const [mainBuzzColor] = useAvailableBuzz();
if (!features.buzz) return null;
if (!currentUser) return null;
@@ -459,14 +461,14 @@ function BuzzMenuItem() {
textSize={isMobile ? 'sm' : 'md'}
withAbbreviation={!isMobile}
withTooltip={!isMobile}
accountTypes={['blue', 'green']}
accountTypes={['blue']}
/>
<UserBuzz
iconSize={16}
textSize={isMobile ? 'sm' : 'md'}
withAbbreviation={!isMobile}
withTooltip={!isMobile}
accountTypes={['yellow']}
accountTypes={[mainBuzzColor]}
/>
</div>
<Button
+2 -2
View File
@@ -74,7 +74,6 @@ import { getMinMaxDates, useMutateBounty } from './bounty.utils';
import classes from './BountyCreateForm.module.scss';
import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import { buzzSpendTypes } from '~/shared/constants/buzz.constants';
import { activeBaseModels } from '~/shared/constants/base-model.constants';
import { getSanitizedStringSchema } from '~/server/schema/utils.schema';
@@ -121,6 +120,7 @@ const formSchema = createBountyInputSchema
export function BountyCreateForm() {
const router = useRouter();
const availableBuzzTypes = useAvailableBuzz(['blue']);
const features = useFeatureFlags();
const { files: imageFiles, uploadToCF, removeImage } = useCFImageUpload();
@@ -612,7 +612,7 @@ export function BountyCreateForm() {
disabled={hasPoiInNsfw}
label="Save"
buzzAmount={unitAmount}
accountTypes={buzzSpendTypes}
accountTypes={availableBuzzTypes}
/>
) : (
<Button loading={creatingBounty} type="submit" disabled={hasPoiInNsfw}>
@@ -11,6 +11,7 @@ import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
import { useBuzzCurrencyConfig } from '~/components/Currency/useCurrencyConfig';
import { getBuzzTypeDistribution } from '~/utils/buzz';
import { useQueryBuzz } from '~/components/Buzz/useBuzz';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
type Props = ButtonProps &
Partial<React.ButtonHTMLAttributes<HTMLButtonElement>> & {
@@ -44,18 +45,20 @@ export function BuzzTransactionButton({
priceReplacement,
...buttonProps
}: Props) {
const allowedAccountTypes = useAvailableBuzz(accountTypes);
// Use provided account types filtered by domain, or domain defaults
const features = useFeatureFlags();
const colorScheme = useComputedColorScheme('dark');
const {
data: { accounts },
} = useQueryBuzz(accountTypes);
} = useQueryBuzz(allowedAccountTypes);
const baseType = accounts[0]?.type;
const { conditionalPerformTransaction, hasRequiredAmount, isLoadingBalance } = useBuzzTransaction(
{
message,
purchaseSuccessMessage,
performTransactionOnPurchase,
accountTypes,
accountTypes: allowedAccountTypes,
}
);
@@ -63,6 +66,7 @@ export function BuzzTransactionButton({
accounts,
buzzAmount,
});
const mainBuzzColor = Object.entries(buzzTypeDistribution.amt).reduce(
(max, [key, amount]) =>
amount > (buzzTypeDistribution.amt[max as BuzzSpendType] || 0) ? key : max,
+15 -155
View File
@@ -2,12 +2,13 @@ import type { UnstyledButtonProps } from '@mantine/core';
import { Group, Popover, Stack, Text, UnstyledButton, Button } from '@mantine/core';
import { useInterval, useLocalStorage } from '@mantine/hooks';
import { showNotification } from '@mantine/notifications';
import { IconBolt, IconCheck, IconSend, IconX, IconChevronDown } from '@tabler/icons-react';
import { IconBolt, IconCheck, IconSend, IconX } from '@tabler/icons-react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
import { useQueryBuzz } from '~/components/Buzz/useBuzz';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
import { useContainerSmallerThan } from '~/components/ContainerProvider/useContainerSmallerThan';
import { CurrencyBadge } from '~/components/Currency/CurrencyBadge';
import { CurrencyIcon } from '~/components/Currency/CurrencyIcon';
@@ -22,7 +23,7 @@ import { useBuzzTransaction } from './buzz.utils';
import classes from './InteractiveTipBuzzButton.module.scss';
import clsx from 'clsx';
import { LegacyActionIcon } from '~/components/LegacyActionIcon/LegacyActionIcon';
import { buzzConstants } from '~/shared/constants/buzz.constants';
import { buzzConstants, type BuzzSpendType } from '~/shared/constants/buzz.constants';
import { useBuzzCurrencyConfig } from '~/components/Currency/useCurrencyConfig';
type Props = UnstyledButtonProps &
@@ -31,7 +32,6 @@ type Props = UnstyledButtonProps &
entityId: number;
entityType: string;
hideLoginPopover?: boolean;
initialCurrencyType?: 'green' | 'yellow'; // | 'red' - temporarily disabled
};
const CLICK_AMOUNT = 10;
@@ -43,18 +43,14 @@ const CONFIRMATION_TIMEOUT = 5000;
*/
type BuzzTippingStore = {
tips: Record<string, number>;
selectedCurrencyType: 'green' | 'yellow' | null; // | 'red' - temporarily disabled
setSelectedCurrencyType: (currencyType: 'green' | 'yellow') => void; // | 'red' - temporarily disabled
onTip: ({
entityType,
entityId,
amount,
currencyType,
}: {
entityType: string;
entityId: number;
amount: number;
currencyType?: 'green' | 'yellow'; // | 'red' - temporarily disabled
}) => void;
};
@@ -65,12 +61,6 @@ const useStore = create<BuzzTippingStore>()(
devtools(
immer((set) => ({
tips: {},
selectedCurrencyType: null,
setSelectedCurrencyType: (currencyType: 'green' | 'yellow') => { // | 'red' - temporarily disabled
set((state) => {
state.selectedCurrencyType = currencyType;
});
},
onTip: ({ entityType, entityId, amount }) => {
const key = getTippingKey({ entityType, entityId });
set((state) => {
@@ -93,11 +83,6 @@ export const useBuzzTippingStore = ({
return useStore(useCallback((state) => state.tips[key] ?? 0, [key]));
};
export const useGlobalCurrencySelection = () => {
const selectedCurrencyType = useStore((state) => state.selectedCurrencyType);
const setSelectedCurrencyType = useStore((state) => state.setSelectedCurrencyType);
return { selectedCurrencyType, setSelectedCurrencyType };
};
const steps: [number, number][] = [
// [20000, 2500],
@@ -116,50 +101,15 @@ export function InteractiveTipBuzzButton({
entityType,
children,
hideLoginPopover = false,
initialCurrencyType,
...buttonProps
}: Props) {
const mobile = useContainerSmallerThan('sm');
const currentUser = useCurrentUser();
const features = useFeatureFlags();
// Get all currency balances to determine default and available options
const { data: balance } = useQueryBuzz();
// Get global currency selection from store
const globalSelectedCurrency = useStore((state) => state.selectedCurrencyType);
const setGlobalSelectedCurrency = useStore((state) => state.setSelectedCurrencyType);
// Determine default currency type based on highest balance
const defaultCurrencyType = useMemo(() => {
if (initialCurrencyType) return initialCurrencyType;
if (globalSelectedCurrency) return globalSelectedCurrency;
if (!balance?.accounts) return 'yellow';
const greenAccount = balance.accounts.find((acc) => acc.type === 'green');
const yellowAccount = balance.accounts.find((acc) => acc.type === 'yellow');
// const redAccount = balance.accounts.find((acc) => acc.type === 'red'); // temporarily disabled
const balances = {
green: greenAccount?.balance || 0,
yellow: yellowAccount?.balance || 0,
// red: redAccount?.balance || 0, // temporarily disabled
};
// Return the currency type with the highest balance
return Object.entries(balances).reduce((a, b) =>
balances[a[0] as keyof typeof balances] > balances[b[0] as keyof typeof balances] ? a : b
)[0] as 'green' | 'yellow'; // | 'red' - temporarily disabled
}, [initialCurrencyType, globalSelectedCurrency, balance?.accounts]);
const selectedCurrencyType = globalSelectedCurrency || defaultCurrencyType;
// Initialize global currency if not set
useEffect(() => {
if (!globalSelectedCurrency && !initialCurrencyType) {
setGlobalSelectedCurrency(defaultCurrencyType);
}
}, [globalSelectedCurrency, defaultCurrencyType, initialCurrencyType, setGlobalSelectedCurrency]);
// Get the single domain-based currency type (either green or yellow)
const availableBuzzTypes = useAvailableBuzz([]);
const selectedCurrencyType = availableBuzzTypes[0] as BuzzSpendType; // Use the primary domain currency
const {
data: { total },
@@ -167,22 +117,11 @@ export function InteractiveTipBuzzButton({
const currencyBalance = total;
const buzzConfig = useBuzzCurrencyConfig(selectedCurrencyType);
// Pre-compute currency configs to avoid hook rules violations
const greenConfig = useBuzzCurrencyConfig('green');
const yellowConfig = useBuzzCurrencyConfig('yellow');
// const redConfig = useBuzzCurrencyConfig('red'); // temporarily disabled
const currencyConfigs = {
green: greenConfig,
yellow: yellowConfig,
// red: redConfig, // temporarily disabled
};
const [buzzCounter, setBuzzCounter] = useState(0);
const startTimerTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const confirmTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [status, setStatus] = useState<'pending' | 'confirming' | 'confirmed'>('pending');
const [showCountDown, setShowCountDown] = useState(false);
const [showCurrencySelector, setShowCurrencySelector] = useState(false);
const interval = useInterval(() => {
setBuzzCounter((prevCounter) => {
@@ -269,7 +208,7 @@ export function InteractiveTipBuzzButton({
onSuccess: (_, { amount }) => {
setStatus('confirmed');
if (entityType && entityId) {
onTip({ entityType, entityId, amount, currencyType: selectedCurrencyType });
onTip({ entityType, entityId, amount });
}
},
onSettled: () => {
@@ -318,23 +257,6 @@ export function InteractiveTipBuzzButton({
}, CONFIRMATION_TIMEOUT);
};
const stopCountdown = () => {
if (confirmTimeoutRef.current) {
clearTimeout(confirmTimeoutRef.current);
confirmTimeoutRef.current = null;
}
setShowCountDown(false);
};
const resumeCountdown = () => {
if (status === 'confirming' && !confirmTimeoutRef.current) {
setShowCountDown(true);
confirmTimeoutRef.current = setTimeout(() => {
setTimeout(() => reset(), 100);
setStatus('pending');
}, CONFIRMATION_TIMEOUT);
}
};
const clickStart = (e: React.MouseEvent | React.TouchEvent) => {
if (isTouchDevice()) {
@@ -463,77 +385,15 @@ export function InteractiveTipBuzzButton({
</LegacyActionIcon>
)}
<Stack gap={2} align="center">
{/* Compact Currency Selector */}
{/* Currency Balance Display */}
<Group gap={4} mb={2}>
<Button
size="xs"
variant="subtle"
color={buzzConfig.color}
onClick={() => {
const newShowState = !showCurrencySelector;
setShowCurrencySelector(newShowState);
if (newShowState && status === 'confirming') {
// Stop countdown when opening currency selector
stopCountdown();
} else if (!newShowState && status === 'confirming') {
// Resume countdown when closing currency selector
resumeCountdown();
}
}}
leftSection={<CurrencyIcon currency="BUZZ" size={12} type={selectedCurrencyType} />}
rightSection={<IconChevronDown size={10} />}
style={{
fontSize: '10px',
padding: '2px 6px',
height: 'auto',
minHeight: 'auto',
}}
>
{numberWithCommas(currencyBalance || 0)}
</Button>
</Group>
{/* Currency Options (when expanded) */}
{showCurrencySelector && (
<Group gap={2} mb={2}>
{(['green', 'yellow'] as const).map((type) => { // 'red' temporarily disabled
const config = currencyConfigs[type];
const typeBalance =
balance?.accounts?.find((acc) => acc.type === type)?.balance || 0;
if (type === selectedCurrencyType) return null; // Don't show current selection
return (
<Button
key={type}
size="xs"
variant="outline"
color={config.color}
style={{
borderColor: config.color,
fontSize: '10px',
padding: '2px 6px',
height: 'auto',
minHeight: 'auto',
}}
onClick={() => {
setGlobalSelectedCurrency(type);
setShowCurrencySelector(false);
// Resume countdown after currency selection if we're in confirming state
if (status === 'confirming') {
resumeCountdown();
}
}}
leftSection={<CurrencyIcon currency="BUZZ" size={10} type={type} />}
>
{numberWithCommas(typeBalance)}
</Button>
);
})}
<Group gap={4}>
<CurrencyIcon currency="BUZZ" size={12} type={selectedCurrencyType} />
<Text size="xs" c={buzzConfig.color} fw={500}>
{numberWithCommas(currencyBalance || 0)}
</Text>
</Group>
)}
</Group>
<Text c={buzzConfig.color} fw={500} size="xs" opacity={0.8}>
Tipping
@@ -551,7 +411,7 @@ export function InteractiveTipBuzzButton({
sendTip(amount);
}
}}
onFocus={stopCountdown}
onFocus={() => setShowCountDown(false)}
className={classes.tipAmount}
dangerouslySetInnerHTML={{ __html: buzzCounter.toString() }}
/>
+3 -1
View File
@@ -2,6 +2,7 @@ import { useRouter } from 'next/router';
import type React from 'react';
import { useState } from 'react';
import { useQueryBuzz } from '~/components/Buzz/useBuzz';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
import { dialogStore } from '~/components/Dialog/dialogStore';
import type { BuyBuzzModalProps } from '~/components/Modals/BuyBuzzModal';
import { env } from '~/env/client';
@@ -108,11 +109,12 @@ export const useBuzzTransaction = (opts?: {
performTransactionOnPurchase?: boolean;
accountTypes?: BuzzSpendType[];
}) => {
const defaultAccountTypes = useAvailableBuzz();
const {
message,
purchaseSuccessMessage,
performTransactionOnPurchase,
accountTypes = ['green', 'yellow'], // 'red'
accountTypes = defaultAccountTypes,
} = opts ?? {};
const features = useFeatureFlags();
+29
View File
@@ -0,0 +1,29 @@
import { useMemo } from 'react';
import { useFeatureFlags } from '~/providers/FeatureFlagsProvider';
import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
/**
* Hook that returns available buzz types based on current domain feature flags.
* Mirrors the backend getAllowedAccountTypes logic for frontend consistency.
*
* @param baseTypes - Base array of account types to include (defaults to ['blue'])
* @returns Array of BuzzSpendType that are allowed on the current domain
*/
export function useAvailableBuzz(baseTypes: BuzzSpendType[] = []): BuzzSpendType[] {
const features = useFeatureFlags();
return useMemo(() => {
const domainTypes: BuzzSpendType[] = baseTypes.filter(
// Remove default yellow/green if provided.
(type) => !['yellow', 'green'].includes(type)
);
if (features.isGreen) {
domainTypes.push('green');
} else {
domainTypes.push('yellow');
}
return domainTypes;
}, [features.isGreen, baseTypes]);
}
+6 -3
View File
@@ -10,8 +10,10 @@ import { trpc } from '~/utils/trpc';
import { isDefined } from '~/utils/type-guards';
import type { GetTransactionsReportSchema } from '~/server/schema/buzz.schema';
import { getBuzzTypeDistribution } from '~/utils/buzz';
import { useAvailableBuzz } from './useAvailableBuzz';
export function useQueryBuzz(buzzTypes: BuzzSpendType[] = ['green', 'yellow']) {
export function useQueryBuzz(buzzTypes?: BuzzSpendType[]) {
const defaultTypes = useAvailableBuzz(buzzTypes);
const currentUser = useCurrentUser();
const { data: initialData, isLoading } = trpc.buzz.getBuzzAccount.useQuery(undefined, {
enabled: !!currentUser,
@@ -20,7 +22,8 @@ export function useQueryBuzz(buzzTypes: BuzzSpendType[] = ['green', 'yellow']) {
if (!initialData) return { accounts: [], total: 0, nsfwTotal: 0 };
let total = 0;
let nsfwTotal = 0;
const accounts = buzzTypes
const accountTypes = buzzTypes ?? defaultTypes;
const accounts = accountTypes
.map((type) => {
const config = BuzzTypes.getConfig(type);
if (!config || config.type !== 'spend') return null;
@@ -32,7 +35,7 @@ export function useQueryBuzz(buzzTypes: BuzzSpendType[] = ['green', 'yellow']) {
.filter(isDefined);
return { accounts, total, nsfwTotal };
}, [initialData, buzzTypes]);
}, [initialData, buzzTypes, defaultTypes]);
return { data, isLoading };
}
@@ -17,6 +17,7 @@ import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
import { buzzSpendTypes } from '~/shared/constants/buzz.constants';
import { useMainBuzzAccountType, useQueryBuzz } from '~/components/Buzz/useBuzz';
import { getBuzzTypeDistribution } from '~/utils/buzz';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
const getEmojiByValue = (value: number) => {
if (value === 0) return '😢';
@@ -37,7 +38,8 @@ export function GenerationCostPopover({
}: Omit<PopoverProps, 'children'> & Props) {
const totalCost = workflowCost.total ?? 0;
const disabled = totalCost > 0 ? popoverProps.disabled : true;
const mainBuzzAccountType = useMainBuzzAccountType(buzzSpendTypes, totalCost);
const availableBuzzTypes = useAvailableBuzz(['blue']);
const mainBuzzAccountType = useMainBuzzAccountType(availableBuzzTypes, totalCost);
return (
<Popover shadow="md" {...popoverProps} withinPortal>
+21 -109
View File
@@ -31,9 +31,10 @@ import { UserBuzz } from '../User/UserBuzz';
import { useDialogContext } from '~/components/Dialog/DialogProvider';
import { useIsMobile } from '~/hooks/useIsMobile';
import classes from './SendTipModal.module.scss';
import { buzzConstants } from '~/shared/constants/buzz.constants';
import { buzzConstants, type BuzzSpendType } from '~/shared/constants/buzz.constants';
import { useBuzzCurrencyConfig } from '~/components/Currency/useCurrencyConfig';
import { useQueryBuzz } from '~/components/Buzz/useBuzz';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
const schema = z
.object({
@@ -46,7 +47,6 @@ const schema = z
.max(buzzConstants.maxTipAmount)
.optional(),
description: z.string().trim().max(100, 'Cannot be longer than 100 characters').optional(),
currencyType: z.enum(['green', 'yellow', 'red']),
})
.refine((data) => data.amount !== '-1' || data.customAmount, {
error: 'Please enter a valid amount',
@@ -60,10 +60,6 @@ const presets = [
{ label: 'lg', amount: '1000' },
];
// Define supported currency types for tipping
const supportedCurrencyTypes = ['green', 'yellow'] as const; // 'red'
type SupportedCurrencyType = (typeof supportedCurrencyTypes)[number];
export function SendTipModal({
toUserId,
entityType,
@@ -73,53 +69,24 @@ export function SendTipModal({
entityType?: string;
entityId?: number;
}) {
// Use domain-aware buzz types, including blue for tipping
const selectedCurrencyType = useAvailableBuzz(['blue'])[0] as BuzzSpendType;
const dialog = useDialogContext();
const queryUtils = trpc.useUtils();
const [loading, setLoading] = useState(false);
const isMobile = useIsMobile();
const colorScheme = useComputedColorScheme('light');
const { data: balance } = useQueryBuzz();
const defaultCurrencyType = useMemo(() => {
if (!balance?.accounts) return 'yellow';
const greenAccount = balance.accounts.find((acc) => acc.type === 'green');
const yellowAccount = balance.accounts.find((acc) => acc.type === 'yellow');
const redAccount = balance.accounts.find((acc) => acc.type === 'red');
const balances = {
green: greenAccount?.balance || 0,
yellow: yellowAccount?.balance || 0,
red: redAccount?.balance || 0,
};
// Return the currency type with the highest balance
return Object.entries(balances).reduce((a, b) =>
balances[a[0] as keyof typeof balances] > balances[b[0] as keyof typeof balances] ? a : b
)[0] as 'green' | 'yellow' | 'red';
}, [balance?.accounts]);
const { data: balance } = useQueryBuzz([selectedCurrencyType]);
const form = useForm({
schema,
defaultValues: {
amount: presets[0].amount,
currencyType: defaultCurrencyType, // Start with green, will be updated by useEffect
},
});
const { trackAction } = useTrackEvent();
const [currencyType] = form.watch(['currencyType']);
const buzzConfig = useBuzzCurrencyConfig(currencyType);
// Pre-compute currency configs to avoid hook rules violations
const greenConfig = useBuzzCurrencyConfig('green');
const yellowConfig = useBuzzCurrencyConfig('yellow');
const redConfig = useBuzzCurrencyConfig('red');
const currencyConfigs: Record<SupportedCurrencyType, ReturnType<typeof useBuzzCurrencyConfig>> = {
green: greenConfig,
yellow: yellowConfig,
// red: redConfig,
};
const buzzConfig = useBuzzCurrencyConfig(selectedCurrencyType);
const { conditionalPerformTransaction } = useBuzzTransaction({
message: (requiredBalance: number) =>
@@ -136,7 +103,7 @@ export function SendTipModal({
</Stack>
),
performTransactionOnPurchase: true,
accountTypes: [currencyType],
accountTypes: [selectedCurrencyType],
});
const tipUserMutation = trpc.buzz.tipUser.useMutation({
@@ -155,7 +122,7 @@ export function SendTipModal({
const handleClose = () => dialog.onClose();
const handleSubmit = (data: z.infer<typeof schema>) => {
const { customAmount, description, currencyType } = data;
const { customAmount, description } = data;
const amount = Number(data.amount);
const amountToSend = Number(amount) === -1 ? customAmount ?? 0 : Number(amount);
const performTransaction = () => {
@@ -171,8 +138,8 @@ export function SendTipModal({
entityId,
entityType,
// Ensures we don't transfer between different account types
fromAccountType: currencyType,
toAccountType: currencyType,
fromAccountType: selectedCurrencyType,
toAccountType: selectedCurrencyType,
});
};
@@ -183,13 +150,6 @@ export function SendTipModal({
const [amount, description, customAmount] = form.watch(['amount', 'description', 'customAmount']);
const amountToSend = Number(amount) === -1 ? customAmount : Number(amount);
useEffect(() => {
// Only set default currency once when balance is first loaded
if (balance?.accounts) {
form.setValue('currencyType', defaultCurrencyType);
}
}, [defaultCurrencyType]);
return (
<Modal
{...dialog}
@@ -235,61 +195,6 @@ export function SendTipModal({
<Form form={form} onSubmit={handleSubmit} style={{ position: 'static' }}>
<Stack gap="lg">
{/* Currency Type Selection */}
<Card
padding="md"
radius="md"
withBorder
style={{
borderColor: buzzConfig.color || 'rgba(0,0,0,0.3)',
backgroundColor: colorScheme === 'dark' ? 'rgba(0,0,0,0.05)' : 'rgba(0,0,0,0.02)',
}}
>
<Stack gap="sm">
<Text size="sm" fw={600} c={buzzConfig.color}>
Choose Currency Type
</Text>
<InputChipGroup name="currencyType">
<Group gap="sm">
{supportedCurrencyTypes.map((type) => {
const config = currencyConfigs[type];
let typeBalance = 0;
if (balance?.accounts) {
typeBalance =
balance.accounts.find((acc) => acc.type === type)?.balance || 0;
}
return (
<Chip
key={type}
value={type}
variant="filled"
size="md"
classNames={{
root: classes.chip,
label: classes.label,
}}
style={{
'--chip-color': config.colorRgb,
}}
>
<Group gap="xs" wrap="nowrap">
<CurrencyIcon currency={Currency.BUZZ} size={16} type={type} />
<div>
<Text size="xs" opacity={0.8}>
{numberWithCommas(typeBalance)}
</Text>
</div>
</Group>
</Chip>
);
})}
</Group>
</InputChipGroup>
</Stack>
</Card>
{/* Amount Selection */}
<Card padding="md" radius="md" withBorder>
<Stack gap="md">
@@ -313,7 +218,11 @@ export function SendTipModal({
}}
>
<Group gap={4}>
<CurrencyIcon currency={Currency.BUZZ} size={14} type={currencyType} />
<CurrencyIcon
currency={Currency.BUZZ}
size={14}
type={selectedCurrencyType}
/>
<Text size="sm" fw={600}>
{numberWithCommas(Number(preset.amount))}
</Text>
@@ -351,7 +260,11 @@ export function SendTipModal({
max={buzzConstants.maxTipAmount}
disabled={sending}
leftSection={
<CurrencyIcon currency={Currency.BUZZ} size={16} type={currencyType} />
<CurrencyIcon
currency={Currency.BUZZ}
size={16}
type={selectedCurrencyType}
/>
}
allowDecimal={false}
allowNegative={false}
@@ -392,13 +305,12 @@ export function SendTipModal({
Cancel
</Button>
<BuzzTransactionButton
key={currencyType}
label="Send Tip"
className={classes.submitButton}
buzzAmount={amountToSend ?? 0}
disabled={(amountToSend ?? 0) === 0}
loading={sending}
accountTypes={currencyType ? [currencyType] : ['green']}
accountTypes={[selectedCurrencyType]}
type="submit"
size="sm"
style={{
@@ -1,6 +1,7 @@
import type { ButtonProps } from '@mantine/core';
import { Button, Text } from '@mantine/core';
import { BuzzTransactionButton } from '~/components/Buzz/BuzzTransactionButton';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
import { useGenerationStatus } from '~/components/ImageGeneration/GenerationForm/generation.utils';
import { useGenerationContext } from '~/components/ImageGeneration/GenerationProvider';
import { LoginRedirect } from '~/components/LoginRedirect/LoginRedirect';
@@ -20,6 +21,7 @@ export function GenerateButton({
const currentUser = useCurrentUser();
const status = useGenerationStatus();
const canGenerate = useGenerationContext((state) => state.canGenerate);
const availableBuzzTypes = useAvailableBuzz(['blue']);
const { size = 'lg' } = buttonProps;
@@ -45,7 +47,7 @@ export function GenerateButton({
buzzAmount={cost}
onPerformTransaction={onClick}
error={error}
accountTypes={buzzSpendTypes}
accountTypes={availableBuzzTypes}
showPurchaseModal
showTypePct
/>
@@ -75,20 +75,20 @@ import {
} from '~/utils/training';
import { trpc } from '~/utils/trpc';
import { isDefined } from '~/utils/type-guards';
import { buzzSpendTypes } from '~/shared/constants/buzz.constants';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
const maxRuns = 5;
const prefersCaptions: TrainingBaseModelType[] = ['flux', 'sd35', 'hunyuan', 'wan', 'chroma'];
export const TrainingFormSubmit = ({ model }: { model: NonNullable<TrainingModelData> }) => {
const features = useFeatureFlags();
const thisModelVersion = model.modelVersions[0];
const thisTrainingDetails = thisModelVersion.trainingDetails as TrainingDetailsObj | undefined;
const thisFile = thisModelVersion.files[0];
const thisMetadata = thisFile?.metadata as FileMetadata | null;
const thisNumImages = thisMetadata?.numImages;
const thisMediaType = thisTrainingDetails?.mediaType ?? 'image';
const availableBuzzTypes = useAvailableBuzz(['blue']);
const { addRun, removeRun, updateRun } = trainingStore;
const { runs } = useTrainingImageStore(
@@ -129,7 +129,7 @@ export const TrainingFormSubmit = ({ model }: { model: NonNullable<TrainingModel
</Text>
</Stack>
),
accountTypes: buzzSpendTypes,
accountTypes: availableBuzzTypes,
});
const thisStep = 3;
@@ -565,7 +565,7 @@ export const TrainingFormSubmit = ({ model }: { model: NonNullable<TrainingModel
<Stack
className={clsx(
'dark:bg-dark-7 sticky top-0 z-10 mb-[-5px] bg-white pb-[5px]',
'sticky top-0 z-10 mb-[-5px] bg-white pb-[5px] dark:bg-dark-7',
!multiMode && 'hidden'
)}
>
@@ -772,7 +772,7 @@ export const TrainingFormSubmit = ({ model }: { model: NonNullable<TrainingModel
w="fit-content"
px="md"
py="xs"
className="bg-gray-0 dark:bg-dark-6 self-end"
className="self-end bg-gray-0 dark:bg-dark-6"
>
<Group gap="sm">
<Badge>
@@ -851,7 +851,7 @@ export const TrainingFormSubmit = ({ model }: { model: NonNullable<TrainingModel
}
label={`Submit${runs.length > 1 ? ` (${runs.length} runs)` : ''}`}
buzzAmount={totalBuzzCost}
accountTypes={buzzSpendTypes}
accountTypes={availableBuzzTypes}
onPerformTransaction={handleSubmit}
error={hasIssue ? 'Error computing cost' : undefined}
showTypePct
+4 -1
View File
@@ -14,6 +14,7 @@ import clsx from 'clsx';
import { BuzzBoltSvg } from '~/components/User/BuzzBoltSvg';
import { Currency } from '~/shared/utils/prisma/enums';
import { getCurrencyConfig } from '~/shared/constants/currency.constants';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
type Props = TextProps & {
iconSize?: number;
@@ -31,9 +32,11 @@ export function UserBuzz({
withTooltip,
withAbbreviation = true,
accountId,
accountTypes = buzzSpendTypes,
accountTypes,
...textProps
}: Props) {
const availableTypes = useAvailableBuzz(['blue']);
accountTypes ??= availableTypes;
const {
data: { accounts, total },
isLoading,
+3 -1
View File
@@ -37,6 +37,7 @@ import { getAccountTypeLabel } from '~/utils/buzz';
import { trpc } from '~/utils/trpc';
import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
import { buzzSpendTypes } from '~/shared/constants/buzz.constants';
import { useAvailableBuzz } from '~/components/Buzz/useAvailableBuzz';
export const getServerSideProps = createServerSideProps({
useSession: true,
@@ -60,9 +61,10 @@ export default function UserBuzzDashboard() {
const isMember = currentUser?.isMember;
const { isFreeTier, meta } = useActiveSubscription();
const features = useFeatureFlags();
const [mainBuzztype] = useAvailableBuzz();
// Account type selection state
const [selectedAccountType, setSelectedAccountType] = React.useState<BuzzSpendType>('yellow');
const [selectedAccountType, setSelectedAccountType] = React.useState<BuzzSpendType>(mainBuzztype);
const selectedBuzzConfig = useBuzzCurrencyConfig(selectedAccountType);
+1 -1
View File
@@ -112,7 +112,7 @@ export async function createBuzzTipTransactionHandler({
try {
const { id: fromAccountId } = ctx.user;
if (input.fromAccountType !== input.toAccountType) {
throw throwBadRequestError('You cannot send Buzz between different account types');
throw throwBadRequestError('You cannot tip Buzz between different account types');
}
if (fromAccountId === input.toAccountId)
@@ -1,4 +1,5 @@
import { getByIdSchema } from '~/server/schema/base.schema';
import { getAllowedAccountTypes } from '~/server/utils/buzz-helpers';
import {
getAllCosmeticShopSections,
getPaginatedCosmeticShopItemInput,
@@ -92,9 +93,13 @@ export const cosmeticShopRouter = router({
purchaseShopItem: verifiedProcedure
.input(purchaseCosmeticShopItemInput)
.mutation(({ input, ctx }) => {
// Calculate domain-allowed account types at router level
const allowedAccountTypes = getAllowedAccountTypes(ctx.features);
return purchaseCosmeticShopItem({
...input,
userId: ctx.user.id,
allowedAccountTypes,
});
}),
getPreviewImages: protectedProcedure.input(getPreviewImagesInput).query(({ input, ctx }) => {
@@ -35,6 +35,7 @@ import {
MetricTimeframe,
} from '~/shared/utils/prisma/enums';
import { getBuzzTransactionSupportedAccountTypes } from '~/utils/buzz';
import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
export const getShopItemById = async ({ id }: GetByIdInput) => {
return dbRead.cosmeticShopItem.findUniqueOrThrow({
@@ -434,8 +435,10 @@ export const getShopSectionsWithItems = async ({
export const purchaseCosmeticShopItem = async ({
userId,
shopItemId,
allowedAccountTypes,
}: PurchaseCosmeticShopItemInput & {
userId: number;
allowedAccountTypes?: BuzzSpendType[];
}) => {
const shopItem = await dbRead.cosmeticShopItem.findUnique({
where: { id: shopItemId },
@@ -523,6 +526,7 @@ export const purchaseCosmeticShopItem = async ({
// Can use a combination of all these accounts:
fromAccountTypes: getBuzzTransactionSupportedAccountTypes({
isNsfw: false,
baseTypes: allowedAccountTypes,
}),
toAccountId: 0, // bank
amount: shopItem.unitAmount,
+17
View File
@@ -1,4 +1,6 @@
import { buzzBulkBonusMultipliers } from '~/server/common/constants';
import type { BuzzSpendType } from '~/shared/constants/buzz.constants';
import type { FeatureAccess } from '~/server/services/feature-flags.service';
export const getBuzzBulkMultiplier = ({
buzzAmount: _buzzAmount,
@@ -33,3 +35,18 @@ export const getBuzzBulkMultiplier = ({
totalBuzz: mainBuzzAdded + blueBuzzAdded + buzzAmount,
};
};
export function getAllowedAccountTypes(
features: FeatureAccess,
baseTypes: BuzzSpendType[] = ['blue']
): BuzzSpendType[] {
const domainTypes: BuzzSpendType[] = [];
if (features.isGreen) {
domainTypes.push('green');
} else {
domainTypes.push('yellow');
}
return [...domainTypes, ...baseTypes];
}
+27 -4
View File
@@ -75,26 +75,49 @@ export const parseBuzzTransactionDetails = (
/**
* Gets the supported Buzz account types for a transaction based on NSFW level and content rating.
* Returns 'user' and either 'green' for safe content or 'fakered' for NSFW content.
* Takes a base array of account types that are assumed available and filters based on content.
*
* @param data - Configuration object
* @param data.nsfwLevel - The NSFW level enum value (optional)
* @param data.isNsfw - Boolean flag indicating if content is NSFW (optional)
* @returns Array of supported BuzzAccountType values for the transaction
* @param data.baseTypes - Base array of account types to filter from (defaults to all spend types)
* @returns Array of supported BuzzSpendType values for the transaction
*/
export const getBuzzTransactionSupportedAccountTypes = ({
nsfwLevel,
isNsfw,
baseTypes,
}: {
nsfwLevel?: NsfwLevel;
isNsfw?: boolean;
baseTypes?: BuzzSpendType[];
}): BuzzSpendType[] => {
const availableTypes = baseTypes ?? ['yellow', 'green', 'red', 'blue'];
const accountTypes: BuzzSpendType[] = [];
// For safe content, allow green if available in base types
if ((typeof isNsfw !== 'undefined' && !isNsfw) || (nsfwLevel ?? 0) <= NsfwLevel.R) {
accountTypes.push('green');
if (availableTypes.includes('green')) {
accountTypes.push('green');
}
}
accountTypes.push('yellow', 'red');
// Always include yellow if available (universal primary currency)
if (availableTypes.includes('yellow')) {
accountTypes.push('yellow');
}
// For NSFW content, include red if available
if ((typeof isNsfw !== 'undefined' && isNsfw) || (nsfwLevel ?? 0) > NsfwLevel.R) {
if (availableTypes.includes('red')) {
accountTypes.push('red');
}
}
// Always include blue if available (universal currency)
if (availableTypes.includes('blue')) {
accountTypes.push('blue');
}
return accountTypes;
};