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
76 changes: 52 additions & 24 deletions src/components/common/CreatorCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,16 @@ import { useAccount } from 'wagmi';
import { Button } from '@/components/ui/button';
import type { Course } from '@/services/course.service';
import { cn } from '@/lib/utils';
import { ShoppingCart, Wallet, Link as LinkIcon } from 'lucide-react';
import { ShoppingCart, Link as LinkIcon } from 'lucide-react';
import toast from 'react-hot-toast';
import showToast from '@/utils/toast.util';
import TransactionRetryNotice from '@/components/common/TransactionRetryNotice';
import CardMetaRow from '@/components/common/CardMetaRow';
import VerifiedBadge from '@/components/common/VerifiedBadge';
import CompactEmptyWalletState from '@/components/common/CompactEmptyWalletState';
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
import WalletConnectCalloutBanner from '@/components/common/WalletConnectCalloutBanner';
import CreatorSocialLinksList from '@/components/common/CreatorSocialLinksList';
import TransactionStatusIcon from '@/components/common/TransactionStatusIcon';

interface CreatorCardProps {
creator: Course;
Expand All @@ -19,7 +22,7 @@ interface CreatorCardProps {
const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
const { isConnected } = useAccount();
const [transactionState, setTransactionState] = useState<
'idle' | 'submitting' | 'failed'
'idle' | 'submitting' | 'failed' | 'success'
>('idle');
const hasFailedOnceRef = useRef(false);

Expand All @@ -37,18 +40,21 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
}

hasFailedOnceRef.current = false;
setTransactionState('idle');
setTransactionState('success');
showToast.transactionSuccess(
'Purchase Successful!',
`You successfully bought a key for ${creator.title}`
);

window.setTimeout(() => {
setTransactionState('idle');
}, 1800);
}, 1500);
};

const handleBuy = () => {
if (!isConnected) {
toast.error('Please connect your wallet to purchase keys', {
icon: <Wallet className="size-5 text-amber-500" />,
duration: 4000,
});
return;
Expand All @@ -65,10 +71,10 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
)}
>
<div className="relative mb-4 aspect-square overflow-hidden rounded-xl">
<img
src={creator.thumbnail || '/icons/avatar.png'}
alt={creator.title}
className="size-full object-cover transition-transform duration-500 md:group-hover:scale-[1.03]"
<CreatorInitialsAvatar
name={creator.title}
imageSrc={creator.thumbnail}
imageClassName="transition-transform duration-500 md:group-hover:scale-[1.03]"
/>
<div className="absolute inset-0 bg-gradient-to-t from-slate-950/80 via-transparent to-transparent opacity-0 transition-opacity duration-300 md:group-hover:opacity-100" />
</div>
Expand All @@ -94,9 +100,21 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
Handle
</span>
}
value={creator.socialHandle ? `@${creator.socialHandle}` : 'No public handle'}
valueTitle={creator.socialHandle ? `@${creator.socialHandle}` : undefined}
valueClassName={creator.socialHandle ? 'text-white/75' : 'italic text-white/35'}
value={
creator.socialHandle
? `@${creator.socialHandle}`
: 'No public handle'
}
valueTitle={
creator.socialHandle
? `@${creator.socialHandle}`
: undefined
}
valueClassName={
creator.socialHandle
? 'text-white/75'
: 'italic text-white/35'
}
/>
<CardMetaRow
label="Key Price"
Expand All @@ -105,6 +123,10 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
valueClassName="font-grotesque text-base font-black text-amber-400"
/>
</div>
<CreatorSocialLinksList
handle={creator.socialHandle}
className="mt-4"
/>
</div>

<div className="flex items-center justify-end gap-4">
Expand All @@ -115,24 +137,30 @@ const CreatorCard: React.FC<CreatorCardProps> = ({ creator, className }) => {
disabled={transactionState === 'submitting'}
className={cn(
'rounded-xl font-bold cursor-pointer ',
!isConnected &&
'border-white/10 hover:bg-white/5'
!isConnected && 'border-white/10 hover:bg-white/5'
)}
>
{transactionState === 'success' && (
<TransactionStatusIcon status="success" className="mr-2" />
)}
{transactionState === 'submitting' && (
<TransactionStatusIcon status="pending" className="mr-2" />
)}
{transactionState === 'failed' && (
<TransactionStatusIcon status="failed" className="mr-2" />
)}
<ShoppingCart className="mr-2 size-4" />
{transactionState === 'submitting' ? 'Processing...' : 'Buy Key'}
{transactionState === 'submitting'
? 'Processing...'
: transactionState === 'success'
? 'Completed'
: transactionState === 'failed'
? 'Retry Purchase'
: 'Buy Key'}
</Button>
</div>

{!isConnected && (
<div className="mt-4 space-y-3">
<div className="flex items-center gap-2 text-[10px] font-medium uppercase tracking-widest text-amber-500/70">
<Wallet className="size-3" />
Wallet Required
</div>
<CompactEmptyWalletState />
</div>
)}
{!isConnected && <WalletConnectCalloutBanner className="mt-4" />}

{transactionState === 'failed' && (
<TransactionRetryNotice
Expand Down
54 changes: 54 additions & 0 deletions src/components/common/CreatorInitialsAvatar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { cn } from '@/lib/utils';

interface CreatorInitialsAvatarProps {
name: string;
imageSrc?: string;
className?: string;
imageClassName?: string;
}

const getInitials = (name: string) => {
const cleanName = name.trim();
if (!cleanName) {
return 'NA';
}

const parts = cleanName.split(/\s+/).filter(Boolean);
if (parts.length === 1) {
return parts[0].slice(0, 2).toUpperCase();
}

return `${parts[0][0] ?? ''}${parts[1][0] ?? ''}`.toUpperCase();
};

const CreatorInitialsAvatar: React.FC<CreatorInitialsAvatarProps> = ({
name,
imageSrc,
className,
imageClassName,
}) => {
const initials = getInitials(name);

if (!imageSrc) {
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',
className
)}
>
<span aria-label={`${name} initials avatar`}>{initials}</span>
</div>
);
}

return (
<img
src={imageSrc}
alt={name}
className={cn('size-full object-cover', imageClassName, className)}
/>
);
};

export default CreatorInitialsAvatar;
88 changes: 88 additions & 0 deletions src/components/common/CreatorSocialLinksList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import type { ComponentType } from 'react';
import {
Globe,
Instagram,
Link as LinkIcon,
Twitter,
Youtube,
} from 'lucide-react';
import { cn } from '@/lib/utils';

interface CreatorSocialLinksListProps {
handle?: string;
className?: string;
}

interface SocialLinkItem {
id: 'x' | 'instagram' | 'youtube' | 'website';
label: string;
url: string;
Icon: ComponentType<{ className?: string }>;
}

const CreatorSocialLinksList: React.FC<CreatorSocialLinksListProps> = ({
handle,
className,
}) => {
if (!handle) {
return (
<div
className={cn(
'rounded-xl border border-dashed border-white/10 bg-white/[0.03] px-3 py-2 text-xs italic text-white/40',
className
)}
>
No social links yet
</div>
);
}

const normalized = handle.replace(/^@/, '').trim();
const links: SocialLinkItem[] = [
{
id: 'x',
label: 'X',
url: `https://x.com/${normalized}`,
Icon: Twitter,
},
{
id: 'instagram',
label: 'Instagram',
url: `https://instagram.com/${normalized}`,
Icon: Instagram,
},
{
id: 'youtube',
label: 'YouTube',
url: `https://youtube.com/@${normalized}`,
Icon: Youtube,
},
{
id: 'website',
label: 'Website',
url: `https://${normalized}.link`,
Icon: Globe,
},
];

return (
<ul className={cn('grid grid-cols-2 gap-2 sm:grid-cols-4', className)}>
{links.map(({ id, label, url, Icon }) => (
<li key={id}>
<a
href={url}
target="_blank"
rel="noreferrer"
className="group inline-flex w-full items-center justify-center gap-1.5 rounded-lg border border-white/10 bg-white/[0.03] px-2.5 py-2 text-[11px] font-semibold text-white/80 transition-colors hover:border-amber-400/40 hover:bg-amber-400/10 hover:text-amber-100"
>
<Icon className="size-3.5" />
<span>{label}</span>
<LinkIcon className="size-3 opacity-0 transition-opacity group-hover:opacity-80" />
</a>
</li>
))}
</ul>
);
};

export default CreatorSocialLinksList;
65 changes: 65 additions & 0 deletions src/components/common/TransactionStatusIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { CheckCircle2, Clock3, XCircle } from 'lucide-react';
import { cn } from '@/lib/utils';

type TransactionStatus = 'success' | 'pending' | 'failed';

interface TransactionStatusIconProps {
status: TransactionStatus;
className?: string;
}

const statusStyles: Record<TransactionStatus, string> = {
success: 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-300/35',
pending: 'bg-amber-500/15 text-amber-300 ring-1 ring-amber-300/35',
failed: 'bg-red-500/15 text-red-300 ring-1 ring-red-300/35',
};

const TransactionStatusIcon: React.FC<TransactionStatusIconProps> = ({
status,
className,
}) => {
if (status === 'success') {
return (
<span
aria-label="Transaction success"
className={cn(
'inline-flex rounded-full p-1.5',
statusStyles.success,
className
)}
>
<CheckCircle2 className="size-4" />
</span>
);
}

if (status === 'pending') {
return (
<span
aria-label="Transaction pending"
className={cn(
'inline-flex rounded-full p-1.5',
statusStyles.pending,
className
)}
>
<Clock3 className="size-4 animate-pulse" />
</span>
);
}

return (
<span
aria-label="Transaction failed"
className={cn(
'inline-flex rounded-full p-1.5',
statusStyles.failed,
className
)}
>
<XCircle className="size-4" />
</span>
);
};

export default TransactionStatusIcon;
42 changes: 42 additions & 0 deletions src/components/common/WalletConnectCalloutBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { Wallet } from 'lucide-react';
import { cn } from '@/lib/utils';
import ConnectWalletButton from '@/components/common/ConnectWalletButton';

interface WalletConnectCalloutBannerProps {
title?: string;
description?: string;
className?: string;
}

const WalletConnectCalloutBanner: React.FC<WalletConnectCalloutBannerProps> = ({
title = 'Wallet connection required',
description = 'Connect your wallet to continue with creator key purchases and on-chain actions.',
className,
}) => {
return (
<div
className={cn(
'rounded-2xl border border-amber-300/30 bg-gradient-to-r from-amber-400/10 via-amber-200/5 to-emerald-300/10 p-4',
className
)}
>
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="min-w-0">
<div className="mb-1 inline-flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.18em] text-amber-300/85">
<Wallet className="size-3.5" />
Wallet required
</div>
<p className="font-jakarta text-sm font-bold text-amber-100">
{title}
</p>
<p className="mt-1 text-xs text-amber-100/75">{description}</p>
</div>
<div className="shrink-0">
<ConnectWalletButton />
</div>
</div>
</div>
);
};

export default WalletConnectCalloutBanner;
Loading