diff --git a/docs/ClaimActionHelperText-DisabledReason.md b/docs/ClaimActionHelperText-DisabledReason.md new file mode 100644 index 00000000..f9179bb1 --- /dev/null +++ b/docs/ClaimActionHelperText-DisabledReason.md @@ -0,0 +1,41 @@ +# Claim Action Disabled Reason Helper Text + +This change introduces a centralized helper for claim action disabled reasons: + +- Utility: `getClaimActionDisabledReasonText` +- File: `src/utils/claimActionDisabledReason.ts` + +## Why + +The helper standardizes claim disabled-reason text so messaging stays consistent and always includes: + +1. **Clear cause** (why claim is disabled) +2. **Next step** (what the user should do) + +This mirrors the tone used in trade action helper messaging. + +## Supported reason keys + +- `wallet_not_connected` +- `no_claimable_rewards` +- `claim_in_progress` +- `network_mismatch` +- `insufficient_gas` +- `unknown` + +## Usage + +```ts +import { getClaimActionDisabledReasonText } from '@/utils/claimActionDisabledReason'; + +const disabledReason = getClaimActionDisabledReasonText('wallet_not_connected'); +// "Claim is unavailable because your wallet is not connected. Connect your wallet to continue." +``` + +## Local validation + +Run: + +```bash +pnpm test src/utils/__tests__/claimActionDisabledReason.test.ts +``` diff --git a/src/components/common/CreatorCard.tsx b/src/components/common/CreatorCard.tsx index 69d2c451..ae4a5110 100644 --- a/src/components/common/CreatorCard.tsx +++ b/src/components/common/CreatorCard.tsx @@ -108,6 +108,7 @@ const CreatorCard: React.FC = ({ creator, className }) => {
diff --git a/src/components/common/CreatorInitialsAvatar.tsx b/src/components/common/CreatorInitialsAvatar.tsx index ecb2a164..a48ee74f 100644 --- a/src/components/common/CreatorInitialsAvatar.tsx +++ b/src/components/common/CreatorInitialsAvatar.tsx @@ -1,8 +1,10 @@ import { useState } from 'react'; import { cn } from '@/lib/utils'; +import { getFallbackAvatarColors } from '@/utils/avatarColor.util'; interface CreatorInitialsAvatarProps { name: string; + creatorId?: string | number | null; imageSrc?: string; className?: string; imageClassName?: string; @@ -24,20 +26,26 @@ const getInitials = (name: string) => { const CreatorInitialsAvatar: React.FC = ({ name, + creatorId, imageSrc, className, imageClassName, }) => { const [hasError, setHasError] = useState(false); const initials = getInitials(name); + const fallbackColors = getFallbackAvatarColors(creatorId); if (!imageSrc || hasError) { return (
{initials}
diff --git a/src/components/common/CreatorProfileHeader.tsx b/src/components/common/CreatorProfileHeader.tsx index 21d93255..1ef14f24 100644 --- a/src/components/common/CreatorProfileHeader.tsx +++ b/src/components/common/CreatorProfileHeader.tsx @@ -9,6 +9,7 @@ import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; interface CreatorProfileHeaderProps { name: string; handle: string; + creatorId?: string | number | null; avatarUrl?: string; isVerified?: boolean; className?: string; @@ -17,6 +18,7 @@ interface CreatorProfileHeaderProps { const CreatorProfileHeader: React.FC = ({ name, handle, + creatorId, avatarUrl, isVerified, className, @@ -44,7 +46,7 @@ const CreatorProfileHeader: React.FC = ({ >
- +
diff --git a/src/components/common/CreatorSkeleton.tsx b/src/components/common/CreatorSkeleton.tsx index b548bff5..1a0c3f8e 100644 --- a/src/components/common/CreatorSkeleton.tsx +++ b/src/components/common/CreatorSkeleton.tsx @@ -1,10 +1,12 @@ -import { motion } from 'framer-motion'; import { cn } from '@/lib/utils'; interface CreatorSkeletonProps { className?: string; } +const skeletonBlockClass = + 'rounded-md bg-white/12 skeleton-shimmer motion-reduce:bg-white/18 motion-reduce:ring-1 motion-reduce:ring-white/15'; + const CreatorSkeleton: React.FC = ({ className }) => { return (
= ({ className }) => { className )} > -
- - +
+
- - +
+
- +
); diff --git a/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx b/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx new file mode 100644 index 00000000..5157bd88 --- /dev/null +++ b/src/components/common/__tests__/CreatorInitialsAvatar.test.tsx @@ -0,0 +1,30 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar'; +import { getFallbackAvatarColors } from '@/utils/avatarColor.util'; + +describe('CreatorInitialsAvatar', () => { + it('uses deterministic fallback colors for the same creator id', () => { + const first = getFallbackAvatarColors('creator-123'); + const second = getFallbackAvatarColors('creator-123'); + const third = getFallbackAvatarColors('creator-456'); + + expect(first).toEqual(second); + expect(first.background).not.toBe(third.background); + expect(first.textColor).toBe('rgba(255, 255, 255, 0.95)'); + }); + + it('renders initials fallback with hashed background when image is missing', () => { + render(); + + const initials = screen.getByLabelText('Alex Rivers initials avatar'); + const avatar = initials.parentElement; + const colors = getFallbackAvatarColors('creator-123'); + + expect(initials).toHaveTextContent('AR'); + expect(avatar).toHaveStyle({ + background: colors.background, + color: colors.textColor, + }); + }); +}); diff --git a/src/components/common/__tests__/CreatorSkeleton.test.tsx b/src/components/common/__tests__/CreatorSkeleton.test.tsx new file mode 100644 index 00000000..398ec684 --- /dev/null +++ b/src/components/common/__tests__/CreatorSkeleton.test.tsx @@ -0,0 +1,19 @@ +import { render } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import CreatorSkeleton, { CreatorGridSkeleton } from '../CreatorSkeleton'; + +describe('CreatorSkeleton', () => { + it('renders shimmer blocks for loading affordance', () => { + const { container } = render(); + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + + expect(shimmerBlocks).toHaveLength(6); + }); + + it('renders the requested number of cards in grid skeleton', () => { + const { container } = render(); + const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer'); + + expect(shimmerBlocks).toHaveLength(18); + }); +}); diff --git a/src/components/ui/dialog.test.tsx b/src/components/ui/dialog.test.tsx new file mode 100644 index 00000000..f5fd96e6 --- /dev/null +++ b/src/components/ui/dialog.test.tsx @@ -0,0 +1,20 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; + +import { Dialog, DialogContent } from './dialog'; + +describe('DialogContent', () => { + it('adds mobile safe-area bottom inset padding and restores desktop spacing', () => { + render( + + Dialog body + + ); + + const content = screen.getByRole('dialog'); + expect(content.className).toContain( + 'pb-[calc(1.5rem+env(safe-area-inset-bottom))]' + ); + expect(content.className).toContain('sm:pb-6'); + }); +}); \ No newline at end of file diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index b1244d92..5ee4722e 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -59,7 +59,7 @@ function DialogContent({ diff --git a/src/utils/__tests__/claimActionDisabledReason.test.ts b/src/utils/__tests__/claimActionDisabledReason.test.ts new file mode 100644 index 00000000..802cabc9 --- /dev/null +++ b/src/utils/__tests__/claimActionDisabledReason.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { + getClaimActionDisabledReasonText, + type ClaimActionDisabledReasonKey, +} from '@/utils/claimActionDisabledReason'; + +describe('getClaimActionDisabledReasonText', () => { + it('returns standardized helper text for each disabled reason', () => { + const cases: Array<[ClaimActionDisabledReasonKey, string]> = [ + [ + 'wallet_not_connected', + 'Claim is unavailable because your wallet is not connected. Connect your wallet to continue.', + ], + [ + 'no_claimable_rewards', + 'Claim is unavailable because there are no rewards ready yet. Check back after a new payout accrues.', + ], + [ + 'claim_in_progress', + 'Claim is unavailable because a claim transaction is already in progress. Wait for confirmation before trying again.', + ], + [ + 'network_mismatch', + 'Claim is unavailable because your wallet is on the wrong network. Switch to the supported network to continue.', + ], + [ + 'insufficient_gas', + 'Claim is unavailable because your wallet balance is too low for network fees. Add funds for gas, then retry.', + ], + [ + 'unknown', + 'Claim is temporarily unavailable due to a validation issue. Refresh and try again, or contact support if it persists.', + ], + ]; + + for (const [reason, expectedText] of cases) { + expect(getClaimActionDisabledReasonText(reason)).toBe(expectedText); + } + }); +}); diff --git a/src/utils/avatarColor.util.ts b/src/utils/avatarColor.util.ts new file mode 100644 index 00000000..44d8472a --- /dev/null +++ b/src/utils/avatarColor.util.ts @@ -0,0 +1,19 @@ +const stringHash = (value: string) => { + let hash = 0; + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0; + } + return hash; +}; + +export const getFallbackAvatarColors = (creatorId?: string | number | null) => { + const normalizedId = String(creatorId ?? '').trim(); + const hash = stringHash(normalizedId || 'fallback-avatar'); + const baseHue = hash % 360; + const accentHue = (baseHue + 28) % 360; + + return { + background: `linear-gradient(135deg, hsl(${baseHue} 65% 28%), hsl(${accentHue} 72% 36%))`, + textColor: 'rgba(255, 255, 255, 0.95)', + }; +}; diff --git a/src/utils/claimActionDisabledReason.ts b/src/utils/claimActionDisabledReason.ts new file mode 100644 index 00000000..625b302e --- /dev/null +++ b/src/utils/claimActionDisabledReason.ts @@ -0,0 +1,26 @@ +export type ClaimActionDisabledReasonKey = + | 'wallet_not_connected' + | 'no_claimable_rewards' + | 'claim_in_progress' + | 'network_mismatch' + | 'insufficient_gas' + | 'unknown'; + +const CLAIM_ACTION_DISABLED_REASON_TEXT: Record = { + wallet_not_connected: + 'Claim is unavailable because your wallet is not connected. Connect your wallet to continue.', + no_claimable_rewards: + 'Claim is unavailable because there are no rewards ready yet. Check back after a new payout accrues.', + claim_in_progress: + 'Claim is unavailable because a claim transaction is already in progress. Wait for confirmation before trying again.', + network_mismatch: + 'Claim is unavailable because your wallet is on the wrong network. Switch to the supported network to continue.', + insufficient_gas: + 'Claim is unavailable because your wallet balance is too low for network fees. Add funds for gas, then retry.', + unknown: + 'Claim is temporarily unavailable due to a validation issue. Refresh and try again, or contact support if it persists.', +}; + +export const getClaimActionDisabledReasonText = ( + reason: ClaimActionDisabledReasonKey +): string => CLAIM_ACTION_DISABLED_REASON_TEXT[reason];