diff --git a/src/components/common/QuorumSettingsPanel.tsx b/src/components/common/QuorumSettingsPanel.tsx new file mode 100644 index 0000000..6d90aab --- /dev/null +++ b/src/components/common/QuorumSettingsPanel.tsx @@ -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 = ({ + 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 ( +
+
+ + 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%)`, + }} + /> +
+
+

+ {quorumPct}% +

+

+ {quorumBpsValue.toLocaleString()} bps +

+
+

+ Min 1% — Max 50% +

+
+

+ Minimum percentage of holders that must vote for a proposal to + pass +

+
+ + +
+ ); +}; + +export default QuorumSettingsPanel; \ No newline at end of file diff --git a/src/components/common/SlippageToleranceSelector.tsx b/src/components/common/SlippageToleranceSelector.tsx index 333d98c..bb662e4 100644 --- a/src/components/common/SlippageToleranceSelector.tsx +++ b/src/components/common/SlippageToleranceSelector.tsx @@ -136,4 +136,4 @@ const SlippageToleranceSelector: React.FC = ({ ); }; -export default SlippageToleranceSelector; +export default SlippageToleranceSelector; \ No newline at end of file diff --git a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx index 4fa468e..a546fd8 100644 --- a/src/components/common/__tests__/SlippageToleranceSelector.test.tsx +++ b/src/components/common/__tests__/SlippageToleranceSelector.test.tsx @@ -93,4 +93,4 @@ describe('SlippageToleranceSelector', () => { fireEvent.click(screen.getByTestId('slippage-preset-0.5')); expect(input).toHaveValue(''); }); -}); +}); \ No newline at end of file diff --git a/src/hooks/useCreatorContractActions.ts b/src/hooks/useCreatorContractActions.ts index db5906f..7108547 100644 --- a/src/hooks/useCreatorContractActions.ts +++ b/src/hooks/useCreatorContractActions.ts @@ -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 @@ -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'); + }, + }); +} diff --git a/src/index.css b/src/index.css index 1946f6f..965803b 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/CreatorDashboardPage.tsx b/src/pages/CreatorDashboardPage.tsx index a9b6739..19a1255 100644 --- a/src/pages/CreatorDashboardPage.tsx +++ b/src/pages/CreatorDashboardPage.tsx @@ -6,12 +6,14 @@ 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'; @@ -19,6 +21,7 @@ import { formatNumber } from '@/utils/numberFormat.utils'; const TABS = [ { label: 'Overview', value: 'overview' }, { label: 'Settings', value: 'settings' }, + { label: 'Governance', value: 'governance' }, ]; const CARD_CLASS = @@ -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( @@ -199,6 +203,31 @@ export default function CreatorDashboardPage() { )} + + {activeTab === 'governance' && ( +
+
+

+ Quorum Settings +

+

+ Set the minimum percentage of holders that must participate + in a vote for a proposal to pass. +

+ setQuorumBps.mutate(quorumBps)} + /> +
+
+ )} ); diff --git a/src/services/course.service.ts b/src/services/course.service.ts index 9eb549d..36af6b9 100644 --- a/src/services/course.service.ts +++ b/src/services/course.service.ts @@ -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. */