mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
Initial support for available buzz per domain
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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() }}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user