diff --git a/src/components/common/LaunchPenaltyPanel.tsx b/src/components/common/LaunchPenaltyPanel.tsx new file mode 100644 index 00000000..85d1753a --- /dev/null +++ b/src/components/common/LaunchPenaltyPanel.tsx @@ -0,0 +1,131 @@ +import React, { useEffect, useState } from 'react'; +import { Button } from '@/components/ui/button'; + +const MIN_PENALTY_PCT = 0; +const MAX_PENALTY_PCT = 20; + +export interface LaunchPenaltyPanelProps { + /** Current launch penalty in basis points (0–2000). */ + launchPenaltyBps?: number; + /** Called with the new penalty value in basis points. */ + onSubmit: (penaltyBps: number) => 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'; + +/** + * Launch penalty configuration panel for the creator dashboard settings tab. + * + * Displays the current early-sell penalty percentage (derived from + * `launchPenaltyBps`) and lets the creator update it via the + * `set_launch_penalty` contract call. Input is constrained to 0–20%. + */ +const LaunchPenaltyPanel: React.FC = ({ + launchPenaltyBps, + onSubmit, + isSubmitting = false, +}) => { + // Convert bps → display percentage string (e.g. 500 bps → "5") + const bpsToDisplay = (bps: number | undefined): string => + bps != null ? String(bps / 100) : ''; + + const [penaltyInput, setPenaltyInput] = useState( + bpsToDisplay(launchPenaltyBps) + ); + const [showError, setShowError] = useState(false); + + // Keep input aligned with upstream value after a successful save refetches it. + useEffect(() => { + setPenaltyInput(bpsToDisplay(launchPenaltyBps)); + setShowError(false); + }, [launchPenaltyBps]); + + const parsed = parseFloat(penaltyInput); + const isValueValid = + penaltyInput.trim() !== '' && + !isNaN(parsed) && + parsed >= MIN_PENALTY_PCT && + parsed <= MAX_PENALTY_PCT; + + const errorMessage = (() => { + if (penaltyInput.trim() === '') return 'Penalty percentage is required'; + if (isNaN(parsed)) return 'Enter a valid number'; + if (parsed < MIN_PENALTY_PCT) return 'Minimum is 0%'; + if (parsed > MAX_PENALTY_PCT) return 'Maximum is 20%'; + return null; + })(); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (isSubmitting) return; + if (!isValueValid) { + setShowError(true); + return; + } + // Convert percentage → basis points, rounding to the nearest integer. + const bps = Math.round(parsed * 100); + onSubmit(bps); + }; + + return ( +
+
+ + setPenaltyInput(e.target.value)} + disabled={isSubmitting} + placeholder="0" + aria-describedby="launch-penalty-hint" + aria-invalid={showError && !isValueValid ? 'true' : undefined} + /> +

+ Applied to sells within the first 7 days after key creation +

+ {showError && errorMessage && ( +

+ {errorMessage} +

+ )} +
+ + +
+ ); +}; + +export default LaunchPenaltyPanel; diff --git a/src/hooks/useCreatorContractActions.ts b/src/hooks/useCreatorContractActions.ts index 93a3e9de..dfcd7ae1 100644 --- a/src/hooks/useCreatorContractActions.ts +++ b/src/hooks/useCreatorContractActions.ts @@ -84,3 +84,22 @@ export function useCancelAuctionMutation(creatorId: string) { }, }); } + +export function useSetLaunchPenaltyMutation(creatorId: string) { + const queryClient = useQueryClient(); + + return useMutation({ + mutationKey: ['contract', 'set_launch_penalty', creatorId], + mutationFn: (penaltyBps: number) => + submitContractCall('set_launch_penalty', { creatorId, penaltyBps }), + onError: error => { + showToast.error(getSignatureErrorMessage(error)); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: queryKeys.creators.detail(creatorId), + }); + showToast.success('Launch penalty updated'); + }, + }); +} diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index c8ef23c0..a2041cf5 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -4,10 +4,12 @@ import { CreatorDashboardSkeleton } from '@/components/common/CreatorSkeleton'; import { ProfileTabPillGroup } from '@/components/common/ProfileTabPill'; import CreatorMetadataForm from '@/components/common/CreatorMetadataForm'; import AuctionSetupPanel from '@/components/common/AuctionSetupPanel'; +import LaunchPenaltyPanel from '@/components/common/LaunchPenaltyPanel'; import { useCancelAuctionMutation, useConfigureAuctionMutation, useUpdateMetadataMutation, + useSetLaunchPenaltyMutation, } from '@/hooks/useCreatorContractActions'; import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils'; import { formatNumber } from '@/utils/numberFormat.utils'; @@ -34,6 +36,7 @@ export default function CreatorDashboardPage() { const metadataMutation = useUpdateMetadataMutation(id); const configureAuction = useConfigureAuctionMutation(id); const cancelAuction = useCancelAuctionMutation(id); + const setLaunchPenalty = useSetLaunchPenaltyMutation(id); const setTab = (value: string) => { setSearchParams( @@ -162,6 +165,21 @@ export default function CreatorDashboardPage() { onCancel={() => cancelAuction.mutate()} /> + +
+

+ Launch Penalty +

+

+ Charge early sellers a percentage fee during the first 7 days + after key creation. +

+ setLaunchPenalty.mutate(penaltyBps)} + /> +
)} diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 88ad8119..3da0cdda 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -46,10 +46,11 @@ export interface Course { auctionSupply?: number; /** Keys sold through the auction so far. */ auctionSold?: number; - coCreatorAddress?: string; - coCreatorSplitBps?: number; - totalPaidToCoCreator?: number; - totalPaidToCreator?: number; + /** + * Early-sell penalty in basis points (0–2000 = 0%–20%). + * Applied to sells within the first 7 days after key creation. + */ + launchPenaltyBps?: number; } export type CourseSortOption =