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
129 changes: 129 additions & 0 deletions src/components/common/QuorumSettingsPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import React, { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';

const MIN_QUORUM_PCT = 1;
const MAX_QUORUM_PCT = 50;
/** Slider position used before the key detail API supplies a stored value. */
const DEFAULT_QUORUM_PCT = 10;

export interface QuorumSettingsPanelProps {
/** Current proposal quorum in basis points (100–5000 = 1%–50%). */
quorumBps?: number;
/** Called with the new quorum threshold in basis points. */
onSubmit: (quorumBps: number) => void;
isSubmitting?: boolean;
}

const clampPct = (pct: number): number =>
Math.min(MAX_QUORUM_PCT, Math.max(MIN_QUORUM_PCT, pct));

const bpsToPct = (bps: number | undefined): number =>
bps != null ? clampPct(bps / 100) : DEFAULT_QUORUM_PCT;

/**
* Quorum Settings panel for the creator dashboard governance tab (#828).
*
* Shows the current proposal quorum (derived from `quorumBps` fetched from
* the key detail API), lets the creator pick a new minimum participation
* percentage on a 1%–50% slider, and submits via the `set_quorum_bps`
* contract function. The selected value is shown as both a percentage and
* in basis points below the slider.
*/
const QuorumSettingsPanel: React.FC<QuorumSettingsPanelProps> = ({
quorumBps,
onSubmit,
isSubmitting = false,
}) => {
const [quorumPct, setQuorumPct] = useState(() => bpsToPct(quorumBps));

// Keep the slider aligned with the stored value after a successful save
// refetches it (acceptance: updated quorum reflected without reload).
useEffect(() => {
setQuorumPct(bpsToPct(quorumBps));
}, [quorumBps]);

const quorumBpsValue = quorumPct * 100;
const isUnchanged =
quorumBps != null && quorumBps === quorumBpsValue;
const fillPercent =
((quorumPct - MIN_QUORUM_PCT) / (MAX_QUORUM_PCT - MIN_QUORUM_PCT)) *
100;

const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (isSubmitting) return;
onSubmit(quorumBpsValue);
};

return (
<form
onSubmit={handleSubmit}
className="space-y-4"
noValidate
data-testid="quorum-settings-panel"
>
<div className="space-y-3">
<label
htmlFor="quorum-threshold"
className="text-xs font-bold uppercase tracking-[0.18em] text-white/50"
>
Quorum threshold (%)
</label>
<input
id="quorum-threshold"
data-testid="quorum-slider"
type="range"
min={MIN_QUORUM_PCT}
max={MAX_QUORUM_PCT}
step={1}
className="quorum-slider w-full"
value={quorumPct}
onChange={e => setQuorumPct(Number(e.target.value))}
disabled={isSubmitting}
aria-describedby="quorum-hint"
aria-valuetext={`${quorumPct} percent, ${quorumBpsValue} basis points`}
style={{
background: `linear-gradient(to right, rgba(251, 191, 36, 0.85) 0%, rgba(251, 191, 36, 0.85) ${fillPercent}%, rgba(255, 255, 255, 0.12) ${fillPercent}%, rgba(255, 255, 255, 0.12) 100%)`,
}}
/>
<div className="flex items-end justify-between gap-4">
<div>
<p
className="font-grotesque text-3xl font-black tracking-tight"
data-testid="quorum-value-percent"
>
{quorumPct}%
</p>
<p
className="text-xs text-white/50"
data-testid="quorum-value-bps"
>
{quorumBpsValue.toLocaleString()} bps
</p>
</div>
<p className="pb-1 text-right text-xs text-white/40">
Min 1% — Max 50%
</p>
</div>
<p
id="quorum-hint"
className="text-xs text-white/40"
data-testid="quorum-hint"
>
Minimum percentage of holders that must vote for a proposal to
pass
</p>
</div>

<Button
type="submit"
data-testid="quorum-submit"
disabled={isSubmitting || isUnchanged}
>
{isSubmitting ? 'Submitting…' : 'Save quorum'}
</Button>
</form>
);
};

export default QuorumSettingsPanel;
2 changes: 1 addition & 1 deletion src/components/common/SlippageToleranceSelector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -136,4 +136,4 @@ const SlippageToleranceSelector: React.FC<SlippageToleranceSelectorProps> = ({
);
};

export default SlippageToleranceSelector;
export default SlippageToleranceSelector;
Original file line number Diff line number Diff line change
Expand Up @@ -93,4 +93,4 @@ describe('SlippageToleranceSelector', () => {
fireEvent.click(screen.getByTestId('slippage-preset-0.5'));
expect(input).toHaveValue('');
});
});
});
28 changes: 26 additions & 2 deletions src/hooks/useCreatorContractActions.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { queryKeys } from '@/lib/queryKeys';
import { cacheManager } from '@/utils/cache.utils';
import showToast from '@/utils/toast.util';
import { getSignatureErrorMessage } from '@/utils/errorHandling.utils';
import type { CreatorMetadataChange } from '@/utils/creatorMetadata.utils';

/**
* Creator-facing contract calls issued from the dashboard settings tab
* (`update_metadata` — #818, `configure_auction` / `cancel_auction` — #816).
* Creator-facing contract calls issued from the dashboard tabs
* (`update_metadata` — #818, `configure_auction` / `cancel_auction` — #816,
* `set_launch_penalty`, `set_max_buy_quantity`, `set_quorum_bps` — #828).
*
* The on-chain wiring is not in the client yet, so each mutation simulates
* signing latency and resolves. On success the creator detail query is
Expand Down Expand Up @@ -122,3 +124,25 @@ export function useSetMaxBuyQuantityMutation(creatorId: string) {
},
});
}

export function useSetQuorumBpsMutation(creatorId: string) {
const queryClient = useQueryClient();

return useMutation({
mutationKey: ['contract', 'set_quorum_bps', creatorId],
mutationFn: (quorumBps: number) =>
submitContractCall('set_quorum_bps', { creatorId, quorumBps }),
onError: error => {
showToast.error(getSignatureErrorMessage(error));
},
onSuccess: () => {
// Drop the 30s course cache entry so the refetch below returns the
// freshly committed quorum and the slider reflects it immediately.
cacheManager.invalidate(`course_${creatorId}`);
queryClient.invalidateQueries({
queryKey: queryKeys.creators.detail(creatorId),
});
showToast.success('Quorum threshold updated');
},
});
}
42 changes: 42 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,48 @@
height: 1rem;
}

/* Quorum threshold slider on the creator dashboard governance tab (#828).
* The filled track is painted inline via a linear-gradient background;
* these rules only shape the track and thumb. */
.quorum-slider {
-webkit-appearance: none;
appearance: none;
height: 0.375rem;
border-radius: 9999px;
outline: none;
}

.quorum-slider:disabled {
opacity: 0.5;
cursor: not-allowed;
}

.quorum-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 1.125rem;
height: 1.125rem;
border-radius: 9999px;
background: var(--chart-4);
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
cursor: pointer;
}

.quorum-slider::-moz-range-thumb {
width: 1.125rem;
height: 1.125rem;
border-radius: 9999px;
background: var(--chart-4);
border: 2px solid rgba(255, 255, 255, 0.9);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.35);
cursor: pointer;
}

.quorum-slider:focus-visible {
box-shadow: 0 0 0 3px var(--ring);
}

@media (prefers-reduced-motion: reduce) {
.skeleton-shimmer {
animation: none;
Expand Down
29 changes: 29 additions & 0 deletions src/pages/CreatorDashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import CreatorMetadataForm from '@/components/common/CreatorMetadataForm';
import AuctionSetupPanel from '@/components/common/AuctionSetupPanel';
import LaunchPenaltyPanel from '@/components/common/LaunchPenaltyPanel';
import MaxBuyQuantityPanel from '@/components/common/MaxBuyQuantityPanel';
import QuorumSettingsPanel from '@/components/common/QuorumSettingsPanel';
import {
useCancelAuctionMutation,
useConfigureAuctionMutation,
useUpdateMetadataMutation,
useSetLaunchPenaltyMutation,
useSetMaxBuyQuantityMutation,
useSetQuorumBpsMutation,
} from '@/hooks/useCreatorContractActions';
import { formatDisplayKeyPrice, resolveCreatorKeyPriceStroops } from '@/utils/keyPriceDisplay.utils';
import { formatNumber } from '@/utils/numberFormat.utils';

const TABS = [
{ label: 'Overview', value: 'overview' },
{ label: 'Settings', value: 'settings' },
{ label: 'Governance', value: 'governance' },
];

const CARD_CLASS =
Expand All @@ -40,6 +43,7 @@ export default function CreatorDashboardPage() {
const cancelAuction = useCancelAuctionMutation(id);
const setLaunchPenalty = useSetLaunchPenaltyMutation(id);
const setMaxBuyQuantity = useSetMaxBuyQuantityMutation(id);
const setQuorumBps = useSetQuorumBpsMutation(id);

const setTab = (value: string) => {
setSearchParams(
Expand Down Expand Up @@ -199,6 +203,31 @@ export default function CreatorDashboardPage() {
</section>
</div>
)}

{activeTab === 'governance' && (
<div
className="space-y-8"
id="profile-panel-governance"
role="tabpanel"
aria-labelledby="profile-tab-governance"
data-testid="dashboard-governance-panel"
>
<section className={CARD_CLASS} data-testid="quorum-settings-section">
<h2 className="mb-1 font-grotesque text-xl font-black tracking-tight">
Quorum Settings
</h2>
<p className="mb-6 text-sm text-white/50">
Set the minimum percentage of holders that must participate
in a vote for a proposal to pass.
</p>
<QuorumSettingsPanel
quorumBps={creator.quorumBps}
isSubmitting={setQuorumBps.isPending}
onSubmit={quorumBps => setQuorumBps.mutate(quorumBps)}
/>
</section>
</div>
)}
</div>
</main>
);
Expand Down
5 changes: 5 additions & 0 deletions src/services/course.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export interface Course {
* Applied to sells within the first 7 days after key creation.
*/
launchPenaltyBps?: number;
/**
* Proposal quorum threshold in basis points (100–5000 = 1%–50%).
* Minimum holder participation required for a governance proposal to pass.
*/
quorumBps?: number;
/** Ledger sequence at which this key was created; anchors the 7-day launch window. */
createdAtLedger?: number;
/** Network ledger sequence as of this response, used to evaluate the launch window. */
Expand Down
Loading