diff --git a/src/components/common/AuctionSetupPanel.tsx b/src/components/common/AuctionSetupPanel.tsx new file mode 100644 index 00000000..4e72107f --- /dev/null +++ b/src/components/common/AuctionSetupPanel.tsx @@ -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 = ({ + 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 ( +
+
+

+ Current state +

+

+ {describeAuctionState(config)} +

+
+ +
+
+
+ + setPriceInput(e.target.value)} + disabled={isSubmitting} + placeholder="0.00" + aria-invalid={showErrors && priceError ? 'true' : undefined} + /> + {showErrors && priceError && ( +

+ {priceError} +

+ )} +
+ +
+ + setSupplyInput(e.target.value)} + disabled={isSubmitting} + placeholder="0" + aria-invalid={showErrors && supplyError ? 'true' : undefined} + /> + {showErrors && supplyError && ( +

+ {supplyError} +

+ )} +
+
+ +
+ + + {canCancelAuction(config) && ( + + )} +
+
+
+ ); +}; + +export default AuctionSetupPanel; diff --git a/src/components/common/CreatorMetadataForm.tsx b/src/components/common/CreatorMetadataForm.tsx new file mode 100644 index 00000000..d8cb485c --- /dev/null +++ b/src/components/common/CreatorMetadataForm.tsx @@ -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 ( + max - 20 ? 'text-amber-400' : 'text-white/40' + )} + > + {length} / {max} + + ); +} + +/** + * 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 = ({ + initialName, + initialBio, + initialAvatarUri, + onSubmit, + isSubmitting = false, +}) => { + const initial = useMemo( + () => ({ name: initialName, bio: initialBio, avatarUri: initialAvatarUri }), + [initialName, initialBio, initialAvatarUri] + ); + + const [draft, setDraft] = useState(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 ( +
+
+
+ + +
+ update('name', e.target.value)} + disabled={isSubmitting} + aria-invalid={nameError ? 'true' : undefined} + aria-describedby={nameError ? 'metadata-name-error' : undefined} + /> + {nameError && ( + + )} +
+ +
+
+ + +
+