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
131 changes: 131 additions & 0 deletions src/components/common/LaunchPenaltyPanel.tsx
Original file line number Diff line number Diff line change
@@ -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<LaunchPenaltyPanelProps> = ({
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 (
<form
onSubmit={handleSubmit}
className="space-y-4"
noValidate
data-testid="launch-penalty-panel"
>
<div className="space-y-1.5">
<label
htmlFor="launch-penalty"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Launch penalty (%)
</label>
<input
id="launch-penalty"
data-testid="launch-penalty-input"
type="number"
inputMode="decimal"
min={MIN_PENALTY_PCT}
max={MAX_PENALTY_PCT}
step="0.01"
className={fieldClass}
value={penaltyInput}
onChange={e => setPenaltyInput(e.target.value)}
disabled={isSubmitting}
placeholder="0"
aria-describedby="launch-penalty-hint"
aria-invalid={showError && !isValueValid ? 'true' : undefined}
/>
<p
id="launch-penalty-hint"
className="text-xs text-white/40"
data-testid="launch-penalty-hint"
>
Applied to sells within the first 7 days after key creation
</p>
{showError && errorMessage && (
<p
role="alert"
data-testid="launch-penalty-error"
className="text-xs text-red-400"
>
{errorMessage}
</p>
)}
</div>

<Button
type="submit"
data-testid="launch-penalty-submit"
disabled={isSubmitting || (showError && !isValueValid)}
>
{isSubmitting ? 'Submitting…' : 'Save penalty'}
</Button>
</form>
);
};

export default LaunchPenaltyPanel;
19 changes: 19 additions & 0 deletions src/hooks/useCreatorContractActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
},
});
}
18 changes: 18 additions & 0 deletions src/pages/CreatorDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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(
Expand Down Expand Up @@ -162,6 +165,21 @@ export default function CreatorDashboardPage() {
onCancel={() => cancelAuction.mutate()}
/>
</section>

<section className={CARD_CLASS} data-testid="launch-penalty-section">
<h2 className="mb-1 font-grotesque text-xl font-black tracking-tight">
Launch Penalty
</h2>
<p className="mb-6 text-sm text-white/50">
Charge early sellers a percentage fee during the first 7 days
after key creation.
</p>
<LaunchPenaltyPanel
launchPenaltyBps={creator.launchPenaltyBps}
isSubmitting={setLaunchPenalty.isPending}
onSubmit={penaltyBps => setLaunchPenalty.mutate(penaltyBps)}
/>
</section>
</div>
)}
</div>
Expand Down
9 changes: 5 additions & 4 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
Loading