Skip to content
Merged
41 changes: 41 additions & 0 deletions docs/ClaimActionHelperText-DisabledReason.md
Original file line number Diff line number Diff line change
@@ -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
```
1 change: 1 addition & 0 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
<div className="relative mb-4 aspect-square overflow-hidden rounded-xl">
<CreatorInitialsAvatar
name={creator.title}
creatorId={creator.id}
imageSrc={creator.thumbnail}
imageClassName="transition-transform duration-500 md:group-hover:scale-[1.03]"
/>
Expand Down
10 changes: 9 additions & 1 deletion src/components/common/CreatorInitialsAvatar.tsx
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -24,20 +26,26 @@ const getInitials = (name: string) => {

const CreatorInitialsAvatar: React.FC<CreatorInitialsAvatarProps> = ({
name,
creatorId,
imageSrc,
className,
imageClassName,
}) => {
const [hasError, setHasError] = useState(false);
const initials = getInitials(name);
const fallbackColors = getFallbackAvatarColors(creatorId);

if (!imageSrc || hasError) {
return (
<div
className={cn(
'flex size-full items-center justify-center bg-gradient-to-br from-slate-800 via-slate-700 to-amber-700 text-3xl font-black tracking-wide text-white/95',
'flex size-full items-center justify-center text-3xl font-black tracking-wide',
className
)}
style={{
background: fallbackColors.background,
color: fallbackColors.textColor,
}}
>
<span aria-label={`${name} initials avatar`}>{initials}</span>
</div>
Expand Down
4 changes: 3 additions & 1 deletion src/components/common/CreatorProfileHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -17,6 +18,7 @@ interface CreatorProfileHeaderProps {
const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
name,
handle,
creatorId,
avatarUrl,
isVerified,
className,
Expand Down Expand Up @@ -44,7 +46,7 @@ const CreatorProfileHeader: React.FC<CreatorProfileHeaderProps> = ({
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:gap-6">
<div className="size-24 overflow-hidden rounded-2xl border-4 border-white/10 shadow-xl md:size-32">
<CreatorInitialsAvatar name={name} imageSrc={avatarUrl} />
<CreatorInitialsAvatar name={name} creatorId={creatorId} imageSrc={avatarUrl} />
</div>
<div className="space-y-1">
<div className="flex items-center gap-2">
Expand Down
63 changes: 13 additions & 50 deletions src/components/common/CreatorSkeleton.tsx
Original file line number Diff line number Diff line change
@@ -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<CreatorSkeletonProps> = ({ className }) => {
return (
<div
Expand All @@ -13,63 +15,24 @@ const CreatorSkeleton: React.FC<CreatorSkeletonProps> = ({ className }) => {
className
)}
>
<motion.div
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{ duration: 1.5, repeat: Infinity, ease: 'easeInOut' }}
className="mb-4 aspect-square w-full rounded-xl bg-white/10"
<div
className={cn(
'mb-4 aspect-square w-full rounded-xl',
skeletonBlockClass
)}
/>

<div className="mb-4 space-y-2">
<motion.div
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
className="h-6 w-3/4 rounded-md bg-white/10"
/>
<motion.div
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
className="h-4 w-1/2 rounded-md bg-white/10"
/>
<div className={cn('h-6 w-3/4', skeletonBlockClass)} />
<div className={cn('h-4 w-1/2', skeletonBlockClass)} />
</div>

<div className="flex items-center justify-between">
<div className="space-y-1">
<motion.div
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
className="h-3 w-12 rounded bg-white/10"
/>
<motion.div
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
className="h-6 w-16 rounded bg-white/10"
/>
<div className={cn('h-3 w-12', skeletonBlockClass)} />
<div className={cn('h-6 w-16', skeletonBlockClass)} />
</div>
<motion.div
animate={{ opacity: [0.4, 0.7, 0.4] }}
transition={{
duration: 1.5,
repeat: Infinity,
ease: 'easeInOut',
}}
className="h-9 w-24 rounded-xl bg-white/10"
/>
<div className={cn('h-9 w-24 rounded-xl', skeletonBlockClass)} />
</div>
</div>
);
Expand Down
30 changes: 30 additions & 0 deletions src/components/common/__tests__/CreatorInitialsAvatar.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatorInitialsAvatar name="Alex Rivers" creatorId="creator-123" />);

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,
});
});
});
19 changes: 19 additions & 0 deletions src/components/common/__tests__/CreatorSkeleton.test.tsx
Original file line number Diff line number Diff line change
@@ -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(<CreatorSkeleton />);
const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer');

expect(shimmerBlocks).toHaveLength(6);
});

it('renders the requested number of cards in grid skeleton', () => {
const { container } = render(<CreatorGridSkeleton count={3} />);
const shimmerBlocks = container.querySelectorAll('.skeleton-shimmer');

expect(shimmerBlocks).toHaveLength(18);
});
});
21 changes: 21 additions & 0 deletions src/components/ui/dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import * as React from 'react';

Check failure on line 1 in src/components/ui/dialog.test.tsx

View workflow job for this annotation

GitHub Actions / verify

'React' is declared but its value is never read.

Check failure on line 1 in src/components/ui/dialog.test.tsx

View workflow job for this annotation

GitHub Actions / verify

'React' is declared but its value is never read.
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 open>
<DialogContent>Dialog body</DialogContent>
</Dialog>
);

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');
});
});
2 changes: 1 addition & 1 deletion src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ function DialogContent({
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg',
'bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 pb-[calc(1.5rem+env(safe-area-inset-bottom))] shadow-lg duration-200 outline-none sm:max-w-lg sm:pb-6',
className
)}
{...props}
Expand Down
33 changes: 33 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,37 @@
margin-top: var(--spacing-section-major);
margin-bottom: var(--spacing-section-major);
}

.skeleton-shimmer {
position: relative;
overflow: hidden;
background-image: linear-gradient(
110deg,
rgba(255, 255, 255, 0.07) 30%,
rgba(255, 255, 255, 0.2) 45%,
rgba(255, 255, 255, 0.07) 60%
);
background-size: 220% 100%;
animation: skeleton-shimmer 1.6s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
background-image: linear-gradient(
180deg,
rgba(255, 255, 255, 0.16),
rgba(255, 255, 255, 0.08)
);
}
}
}

@keyframes skeleton-shimmer {
0% {
background-position: 100% 0;
}
100% {
background-position: -100% 0;
}
}
1 change: 1 addition & 0 deletions src/pages/LandingPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,7 @@ function LandingPage() {
<CreatorProfileHeader
name="Alex Rivers"
handle="arivers"
creatorId="arivers"
isVerified={true}
avatarUrl="https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?w=400&h=400&fit=crop"
/>
Expand Down
40 changes: 40 additions & 0 deletions src/utils/__tests__/claimActionDisabledReason.test.ts
Original file line number Diff line number Diff line change
@@ -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);
}
});
});
19 changes: 19 additions & 0 deletions src/utils/avatarColor.util.ts
Original file line number Diff line number Diff line change
@@ -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)',
};
};
Loading
Loading