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
166 changes: 166 additions & 0 deletions src/components/common/AuctionSetupPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import React, { useEffect, useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import {
canCancelAuction,
describeAuctionState,
getAuctionStatus,
validateAuctionInputs,
type AuctionConfig,
} from '@/utils/auctionConfig.utils';

export interface AuctionSetupPanelProps extends AuctionConfig {
/** Submits `configure_auction`. */
onConfigure: (config: { price: number; supply: number }) => void;
/** Submits `cancel_auction`. */
onCancel: () => void;
isSubmitting?: boolean;
}

const fieldClass =
'w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50';

/**
* Auction configuration panel for the creator dashboard settings tab (#816).
*
* Lets a creator set a fixed auction price (XLM) and supply allocation before
* their key goes live, shows the current auction state, and offers a Cancel
* Auction action while no auction keys have been sold.
*/
const AuctionSetupPanel: React.FC<AuctionSetupPanelProps> = ({
auctionPrice,
auctionSupply,
auctionSold,
onConfigure,
onCancel,
isSubmitting = false,
}) => {
const config: AuctionConfig = { auctionPrice, auctionSupply, auctionSold };
const status = getAuctionStatus(config);

const [priceInput, setPriceInput] = useState(
auctionPrice != null ? String(auctionPrice) : ''
);
const [supplyInput, setSupplyInput] = useState(
auctionSupply != null ? String(auctionSupply) : ''
);
const [showErrors, setShowErrors] = useState(false);

// Keep inputs aligned with upstream config after a save refetches it.
useEffect(() => {
setPriceInput(auctionPrice != null ? String(auctionPrice) : '');
setSupplyInput(auctionSupply != null ? String(auctionSupply) : '');
setShowErrors(false);
}, [auctionPrice, auctionSupply]);

const { priceError, supplyError, isValid } = validateAuctionInputs(
priceInput,
supplyInput
);

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
if (!isValid) {
setShowErrors(true);
return;
}
onConfigure({ price: Number(priceInput.trim()), supply: Number(supplyInput.trim()) });
};

return (
<div className="space-y-5" data-testid="auction-setup-panel">
<div className="rounded-xl border border-white/10 bg-white/[0.03] px-4 py-3">
<p className="text-xs font-bold uppercase tracking-[0.18em] text-white/50">
Current state
</p>
<p className="mt-1 font-jakarta text-sm font-bold text-white" data-testid="auction-state">
{describeAuctionState(config)}
</p>
</div>

<form onSubmit={handleSubmit} className="space-y-4" noValidate>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label
htmlFor="auction-price"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Auction price (XLM)
</label>
<input
id="auction-price"
data-testid="auction-price-input"
inputMode="decimal"
className={fieldClass}
value={priceInput}
onChange={e => setPriceInput(e.target.value)}
disabled={isSubmitting}
placeholder="0.00"
aria-invalid={showErrors && priceError ? 'true' : undefined}
/>
{showErrors && priceError && (
<p role="alert" data-testid="auction-price-error" className="text-xs text-red-400">
{priceError}
</p>
)}
</div>

<div className="space-y-1.5">
<label
htmlFor="auction-supply"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Auction supply (keys)
</label>
<input
id="auction-supply"
data-testid="auction-supply-input"
inputMode="numeric"
className={fieldClass}
value={supplyInput}
onChange={e => setSupplyInput(e.target.value)}
disabled={isSubmitting}
placeholder="0"
aria-invalid={showErrors && supplyError ? 'true' : undefined}
/>
{showErrors && supplyError && (
<p role="alert" data-testid="auction-supply-error" className="text-xs text-red-400">
{supplyError}
</p>
)}
</div>
</div>

<div className="flex flex-wrap gap-3">
<Button
type="submit"
data-testid="auction-submit"
disabled={isSubmitting || (showErrors && !isValid)}
>
{isSubmitting
? 'Submitting…'
: status === 'not_configured'
? 'Configure auction'
: 'Update auction'}
</Button>

{canCancelAuction(config) && (
<Button
type="button"
variant="destructive"
data-testid="auction-cancel"
onClick={onCancel}
disabled={isSubmitting}
className={cn(isSubmitting && 'pointer-events-none')}
>
Cancel auction
</Button>
)}
</div>
</form>
</div>
);
};

export default AuctionSetupPanel;
183 changes: 183 additions & 0 deletions src/components/common/CreatorMetadataForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React, { useEffect, useMemo, useState } from 'react';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
CREATOR_BIO_MAX_LENGTH,
CREATOR_NAME_MAX_LENGTH,
diffCreatorMetadata,
validateCreatorMetadata,
type CreatorMetadataChange,
type CreatorMetadataDraft,
} from '@/utils/creatorMetadata.utils';

export interface CreatorMetadataFormProps {
initialName: string;
initialBio: string;
initialAvatarUri: string;
/** Receives only the fields that changed. Never called with an empty change. */
onSubmit: (change: CreatorMetadataChange) => void;
isSubmitting?: boolean;
}

const fieldClass =
'w-full rounded-md border border-white/10 bg-white/[0.04] px-3 py-2 text-sm text-white placeholder:text-white/30 outline-none transition-colors focus:border-amber-400/40 focus:ring-[3px] focus:ring-amber-400/20 disabled:opacity-50';

function CharacterCounter({
length,
max,
testId,
}: {
length: number;
max: number;
testId: string;
}) {
const over = length > max;
return (
<span
data-testid={testId}
aria-live="polite"
className={cn(
'text-xs font-medium tabular-nums',
over ? 'text-red-400' : length > max - 20 ? 'text-amber-400' : 'text-white/40'
)}
>
{length} / {max}
</span>
);
}

/**
* Edit Profile form for the creator dashboard settings tab (#818).
*
* Pre-fills from the current metadata, shows live character counters, blocks
* submission when the name (64) or bio (256) limits are exceeded, and submits
* only the fields that actually changed via `onSubmit`.
*/
const CreatorMetadataForm: React.FC<CreatorMetadataFormProps> = ({
initialName,
initialBio,
initialAvatarUri,
onSubmit,
isSubmitting = false,
}) => {
const initial = useMemo<CreatorMetadataDraft>(
() => ({ name: initialName, bio: initialBio, avatarUri: initialAvatarUri }),
[initialName, initialBio, initialAvatarUri]
);

const [draft, setDraft] = useState<CreatorMetadataDraft>(initial);

// Re-sync when the upstream metadata changes (e.g. after a successful save
// invalidates and refetches the creator detail query).
useEffect(() => {
setDraft(initial);
}, [initial]);

const { nameError, bioError, isValid } = validateCreatorMetadata(draft);
const change = diffCreatorMetadata(initial, draft);
const hasChange = Object.keys(change).length > 0;
const canSubmit = isValid && hasChange && !isSubmitting;

const update = (field: keyof CreatorMetadataDraft, value: string) => {
setDraft(prev => ({ ...prev, [field]: value }));
};

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
onSubmit(change);
};

return (
<form
onSubmit={handleSubmit}
className="space-y-5"
data-testid="creator-metadata-form"
noValidate
>
<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label
htmlFor="creator-metadata-name"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Display name
</label>
<CharacterCounter
length={draft.name.length}
max={CREATOR_NAME_MAX_LENGTH}
testId="metadata-name-counter"
/>
</div>
<input
id="creator-metadata-name"
className={fieldClass}
value={draft.name}
onChange={e => update('name', e.target.value)}
disabled={isSubmitting}
aria-invalid={nameError ? 'true' : undefined}
aria-describedby={nameError ? 'metadata-name-error' : undefined}
/>
{nameError && (
<p id="metadata-name-error" role="alert" data-testid="metadata-name-error" className="text-xs text-red-400">
{nameError}
</p>
)}
</div>

<div className="space-y-1.5">
<div className="flex items-center justify-between gap-2">
<label
htmlFor="creator-metadata-bio"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Bio
</label>
<CharacterCounter
length={draft.bio.length}
max={CREATOR_BIO_MAX_LENGTH}
testId="metadata-bio-counter"
/>
</div>
<Textarea
id="creator-metadata-bio"
className={cn(fieldClass, 'min-h-24')}
value={draft.bio}
onChange={e => update('bio', e.target.value)}
disabled={isSubmitting}
aria-invalid={bioError ? 'true' : undefined}
aria-describedby={bioError ? 'metadata-bio-error' : undefined}
/>
{bioError && (
<p id="metadata-bio-error" role="alert" data-testid="metadata-bio-error" className="text-xs text-red-400">
{bioError}
</p>
)}
</div>

<div className="space-y-1.5">
<label
htmlFor="creator-metadata-avatar"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Avatar URI
</label>
<input
id="creator-metadata-avatar"
className={fieldClass}
value={draft.avatarUri}
onChange={e => update('avatarUri', e.target.value)}
disabled={isSubmitting}
placeholder="https://…"
/>
</div>

<Button type="submit" data-testid="metadata-submit" disabled={!canSubmit}>
{isSubmitting ? 'Saving…' : 'Save changes'}
</Button>
</form>
);
};

export default CreatorMetadataForm;
Loading
Loading