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
5 changes: 5 additions & 0 deletions src/components/common/CreatorPublicProfileHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import VerifiedBadge from '@/components/common/VerifiedBadge';
import IssuerVerificationBadge from '@/components/common/IssuerVerificationBadge';
import CreatorInitialsAvatar from '@/components/common/CreatorInitialsAvatar';
import CreatorProfileSocialLinks from '@/components/common/CreatorProfileSocialLinks';
import CreatorBio from '@/components/common/CreatorBio';
Expand Down Expand Up @@ -81,6 +82,10 @@ const CreatorPublicProfileHeader: React.FC<CreatorPublicProfileHeaderProps> = ({
{displayName}
</h1>
{isVerified && <VerifiedBadge verified={true} />}
<IssuerVerificationBadge
wallet={creatorId}
showExpiryTooltip
/>
</div>

{displayHandle && (
Expand Down
78 changes: 78 additions & 0 deletions src/components/common/EarlyInvestorBonusChip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import React from 'react';
import { Sparkles } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { EarlyIncentive } from '@/services/earlyIncentive.service';

export interface EarlyInvestorBonusChipProps {
incentive?: EarlyIncentive | null;
isLoading?: boolean;
className?: string;
}

/**
* Early investor bonus chip (#1232).
*
* Surfaces the remaining early funding incentive slots on the invest panel
* to drive urgency. Renders the greyed "Early spots filled" state once every
* slot is taken, and nothing at all when the key has no incentive configured.
*/
export const EarlyInvestorBonusChip: React.FC<EarlyInvestorBonusChipProps> = ({
incentive,
isLoading = false,
className,
}) => {
if (isLoading) return null;
if (!incentive || !incentive.configured) return null;

const filled = incentive.slotsRemaining <= 0;

return (
<span
className={cn(
'inline-flex items-center gap-1.5 rounded-lg border px-2.5 py-1 text-xs font-semibold',
filled
? 'border-white/10 bg-white/[0.04] text-white/45'
: 'border-amber-500/25 bg-amber-500/10 text-amber-300',
className
)}
data-testid="early-investor-bonus-chip"
data-slots-remaining={incentive.slotsRemaining}
data-bonus-bps={incentive.bonusBps}
title={
filled
? 'Early investor bonus slots are fully taken'
: `Early investor bonus: +${incentive.bonusBps} bps`
}
>
<Sparkles
className={cn('size-3.5 shrink-0', filled ? 'text-white/35' : 'text-amber-400')}
aria-hidden="true"
/>
{filled ? (
<span data-testid="early-investor-bonus-filled">Early spots filled</span>
) : (
<>
<span data-testid="early-investor-bonus-bps">
+{incentive.bonusBps} bps bonus
</span>
<span aria-hidden="true" className="text-white/25">
·
</span>
<span
className="font-mono tabular-nums"
data-testid="early-investor-bonus-slots"
>
{incentive.slotsRemaining}{' '}
{incentive.slotsRemaining === 1 ? 'slot' : 'slots'} left
</span>
<span aria-hidden="true" className="text-white/25">
·
</span>
<span className="uppercase tracking-wider">Limited spots</span>
</>
)}
</span>
);
};

export default EarlyInvestorBonusChip;
89 changes: 89 additions & 0 deletions src/components/common/IssuerVerificationBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import { ShieldCheck } from 'lucide-react';
import { cn } from '@/lib/utils';
import { Tooltip } from '@/components/ui/tooltip';
import type {
IssuerVerification,
IssuerVerificationTier,
} from '@/services/issuerVerification.service';
import { useIssuerVerification } from '@/hooks/useIssuerVerification';

const TIER_LABELS: Record<IssuerVerificationTier, string> = {
basic: 'Basic',
enhanced: 'Enhanced',
institutional: 'Institutional',
};

const TIER_COLOURS: Record<IssuerVerificationTier, string> = {
basic: 'border-white/15 bg-white/[0.06] text-white/70',
enhanced: 'border-sky-400/30 bg-sky-400/10 text-sky-300',
institutional: 'border-amber-400/35 bg-amber-400/10 text-amber-300',
};

export interface IssuerVerificationBadgeProps {
/** Issuer wallet address used for GET /issuers/:wallet/verification. */
wallet?: string | null;
/** Pre-fetched verification; skips the network call when provided. */
verification?: IssuerVerification | null;
/** Show the expiry date in a tooltip (issuer profile header). */
showExpiryTooltip?: boolean;
className?: string;
}

/**
* Issuer verification tier badge (#1233).
*
* Renders Basic (grey) / Enhanced (blue) / Institutional (gold) chips for
* verified issuers. Nothing is shown for unverified issuers or when the
* verification lookup fails.
*/
export const IssuerVerificationBadge: React.FC<IssuerVerificationBadgeProps> = ({
wallet,
verification,
showExpiryTooltip = false,
className,
}) => {
const { data: fetched, isLoading } = useIssuerVerification(
wallet ?? '',
!verification && !!wallet
);
const data = verification ?? fetched;

if (verification === undefined && isLoading) return null;
if (!data || !data.verified) return null;

const label = TIER_LABELS[data.tier] ?? TIER_LABELS.basic;

const badge = (
<span
className={cn(
'inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em]',
TIER_COLOURS[data.tier] ?? TIER_COLOURS.basic,
className
)}
data-testid="issuer-verification-badge"
data-tier={data.tier}
>
<ShieldCheck className="size-3" aria-hidden="true" />
{label}
</span>
);

if (showExpiryTooltip) {
return (
<Tooltip
content={
data.expiresAt
? `Verification expires ${new Date(data.expiresAt).toLocaleDateString()}`
: 'No verification expiry recorded'
}
>
{badge}
</Tooltip>
);
}

return badge;
};

export default IssuerVerificationBadge;
5 changes: 5 additions & 0 deletions src/components/common/KeyCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
resolveCreatorKeyPriceStroops,
} from '@/utils/keyPriceDisplay.utils';
import AddToBatchButton from '@/components/common/AddToBatchButton';
import IssuerVerificationBadge from '@/components/common/IssuerVerificationBadge';
import { cn } from '@/lib/utils';

export interface KeyCardProps {
Expand Down Expand Up @@ -84,6 +85,10 @@ export const KeyCard: React.FC<KeyCardProps> = ({ creator, className }) => {
{handle}
</p>
)}
<IssuerVerificationBadge
wallet={creator.instructorId}
className="mt-1.5"
/>
</div>
</div>

Expand Down
171 changes: 171 additions & 0 deletions src/components/common/PaymentScheduleTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import React from 'react';
import { cn } from '@/lib/utils';
import type {
PaymentInstalment,
PaymentSchedule,
PaymentInstalmentStatus,
} from '@/services/paymentSchedule.service';

const STATUS_STYLES: Record<PaymentInstalmentStatus, string> = {
paid: 'border-emerald-400/30 bg-emerald-400/10 text-emerald-300',
pending: 'border-white/15 bg-white/[0.06] text-white/70',
overdue: 'border-rose-400/35 bg-rose-400/10 text-rose-300',
};

const STATUS_LABELS: Record<PaymentInstalmentStatus, string> = {
paid: 'Paid',
pending: 'Pending',
overdue: 'Overdue',
};

function formatDate(value: string): string {
const parsed = new Date(value);
return Number.isNaN(parsed.getTime())
? value
: parsed.toLocaleDateString(undefined, {
year: 'numeric',
month: 'short',
day: 'numeric',
});
}

function formatAmount(value: number): string {
return `${value.toLocaleString(undefined, {
maximumFractionDigits: 2,
})} XLM`;
}

function formatCountdown(dueDate: string): string | null {
const diffMs = new Date(dueDate).getTime() - Date.now();
if (!Number.isFinite(diffMs) || diffMs <= 0) return null;
const days = Math.ceil(diffMs / 86_400_000);
if (days > 1) return `in ${days} days`;
const hours = Math.max(1, Math.ceil(diffMs / 3_600_000));
return `in ${hours}h`;
}

export interface PaymentScheduleTimelineProps {
schedule?: PaymentSchedule | null;
isLoading?: boolean;
className?: string;
}

/**
* Instalment payment schedule timeline (#1235).
*
* Lists every scheduled instalment with due date, amount and status chip,
* a cumulative repayment progress bar, and a countdown on the next due
* instalment. Renders nothing when the key has no payment schedule.
*/
export const PaymentScheduleTimeline: React.FC<PaymentScheduleTimelineProps> = ({
schedule,
isLoading = false,
className,
}) => {
if (isLoading || !schedule || schedule.instalments.length === 0) return null;

const now = Date.now();
const upcoming = schedule.instalments.find(
item => item.status !== 'paid' && new Date(item.dueDate).getTime() >= now
);
const nextDueNumber = upcoming?.number ?? null;
const progressPercent =
schedule.totalAmount > 0
? Math.min(100, Math.round((schedule.paidAmount / schedule.totalAmount) * 100))
: 0;

return (
<section
className={cn(
'rounded-4xl border border-white/10 bg-white/2 p-6 shadow-2xl backdrop-blur-md md:p-8',
className
)}
aria-labelledby="payment-schedule-heading"
data-testid="payment-schedule-timeline"
>
<h2
id="payment-schedule-heading"
className="font-grotesque text-xl font-black tracking-tight text-white"
>
Payment schedule
</h2>

<div className="mt-4" data-testid="payment-schedule-progress">
<div className="flex items-center justify-between text-xs text-white/55">
<span>Repaid</span>
<span className="font-mono tabular-nums">
{formatAmount(schedule.paidAmount)} / {formatAmount(schedule.totalAmount)}{' '}
({progressPercent}%)
</span>
</div>
<div
className="mt-2 h-2 overflow-hidden rounded-full bg-white/10"
role="progressbar"
aria-valuenow={progressPercent}
aria-valuemin={0}
aria-valuemax={100}
aria-label="Cumulative repayment progress"
>
<div
className="h-full rounded-full bg-emerald-400 transition-all"
style={{ width: `${progressPercent}%` }}
/>
</div>
</div>

<ol className="mt-6 space-y-3">
{schedule.instalments.map((item: PaymentInstalment) => {
const isNextDue = item.number === nextDueNumber;
const countdown = isNextDue ? formatCountdown(item.dueDate) : null;

return (
<li
key={item.number}
className={cn(
'flex flex-col gap-2 rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3 sm:flex-row sm:items-center sm:justify-between',
isNextDue && 'border-emerald-400/40 bg-emerald-400/[0.06]'
)}
data-testid="payment-schedule-item"
data-instalment-number={item.number}
data-status={item.status}
>
<div className="min-w-0">
<p className="text-xs font-semibold uppercase tracking-wider text-white/55">
Instalment {item.number}
{isNextDue && (
<span
className="ml-2 rounded-full border border-emerald-400/30 bg-emerald-400/10 px-2 py-0.5 text-[10px] font-bold text-emerald-300"
data-testid="payment-schedule-next-due"
>
Next due{countdown ? ` · ${countdown}` : ''}
</span>
)}
</p>
<p className="mt-0.5 text-sm text-white/80">
<time dateTime={item.dueDate}>{formatDate(item.dueDate)}</time>
<span aria-hidden="true" className="mx-2 text-white/25">
·
</span>
<span className="font-mono tabular-nums">
{formatAmount(item.amount)}
</span>
</p>
</div>
<span
className={cn(
'inline-flex w-fit items-center rounded-full border px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.12em]',
STATUS_STYLES[item.status] ?? STATUS_STYLES.pending
)}
data-testid="payment-schedule-status"
>
{STATUS_LABELS[item.status] ?? STATUS_LABELS.pending}
</span>
</li>
);
})}
</ol>
</section>
);
};

export default PaymentScheduleTimeline;
Loading
Loading