Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});
});
20 changes: 20 additions & 0 deletions src/components/ui/dialog.test.tsx
Original file line number Diff line number Diff line change
@@ -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 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