diff --git a/pkgs/frontend/app/components/splits/RoleMultiplierField.tsx b/pkgs/frontend/app/components/splits/RoleMultiplierField.tsx new file mode 100644 index 00000000..cbda43e3 --- /dev/null +++ b/pkgs/frontend/app/components/splits/RoleMultiplierField.tsx @@ -0,0 +1,68 @@ +import type { FC } from "react"; +import { + DEFAULT_MULTIPLIER, + MULTIPLIER_HINT, + isValidMultiplier, +} from "utils/multiplier"; +import { FieldLabel } from "~/components/composite/field-label"; +import { Input } from "~/components/ui/input"; +import { Typography } from "~/components/ui/typography"; +import { cn } from "~/lib/utils"; + +interface RoleMultiplierFieldProps { + /** Unique per row — also seeds the error message's `aria-describedby`. */ + id: string; + value: string; + onChange: (value: string) => void; + className?: string; +} + +/** + * Per-role 分配係数 input, shared by the splits and scheduled authoring + * flows so both screens validate and label the field the same way. + * + * `type="text"` + `inputMode="decimal"` rather than `type="number"`: a number + * input accepts "e"/"-" and hands back an empty string for values the browser + * considers invalid, which would hide bad input from our own validation. + */ +export const RoleMultiplierField: FC = ({ + id, + value, + onChange, + className, +}) => { + const invalid = !isValidMultiplier(value); + return ( +
+ + 分配係数 + + onChange(e.target.value)} + inputMode="decimal" + placeholder={DEFAULT_MULTIPLIER} + aria-invalid={invalid} + aria-describedby={invalid ? `${id}-hint` : undefined} + className="h-9 w-[72px] px-2.5" + /> + + 倍 + + {invalid && ( + + {MULTIPLIER_HINT} + + )} +
+ ); +}; diff --git a/pkgs/frontend/app/routes/$treeId_.scheduled.new.tsx b/pkgs/frontend/app/routes/$treeId_.scheduled.new.tsx index 2843bed8..f9b6db7a 100644 --- a/pkgs/frontend/app/routes/$treeId_.scheduled.new.tsx +++ b/pkgs/frontend/app/routes/$treeId_.scheduled.new.tsx @@ -14,6 +14,7 @@ import { LuPlus, LuTrash2 } from "react-icons/lu"; import { Link, useNavigate, useParams } from "react-router"; import { toast } from "sonner"; import { ipfs2https } from "utils/ipfs"; +import { DEFAULT_MULTIPLIER, packMultipliers } from "utils/multiplier"; import { abbreviateAddress } from "utils/wallet"; import { type Address, @@ -30,6 +31,7 @@ import { import { StepBar } from "~/components/composite/step-bar"; import { TokenSelector } from "~/components/composite/token-selector"; import { ScreenHeader } from "~/components/layout/ScreenHeader"; +import { RoleMultiplierField } from "~/components/splits/RoleMultiplierField"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; import { Button } from "~/components/ui/button"; import { Card } from "~/components/ui/card"; @@ -70,7 +72,8 @@ type DepositRow = TokenRow & { interface RoleInput { hatId: Address; active: boolean; - multiplier: number; + /** Raw user input — packed into a contract fraction at submit time. */ + multiplier: string; /** * Lowercased addresses the user has explicitly deselected for this role. * Tracking exclusions (rather than inclusions) keeps user deselects stable @@ -166,7 +169,7 @@ const ScheduledNew: FC = () => { next[key] = { hatId: o.hatId, active: true, - multiplier: 1, + multiplier: DEFAULT_MULTIPLIER, excludedWearers: [], }; changed = true; @@ -185,6 +188,15 @@ const ScheduledNew: FC = () => { }); }; + const updateMultiplier = (hatId: Address, multiplier: string) => { + const key = hatId.toLowerCase(); + setRoles((current) => { + const existing = current[key]; + if (!existing) return current; + return { ...current, [key]: { ...existing, multiplier } }; + }); + }; + // The wearer-detail dialog renders against the FULL wearer set from // dutyOptions; this flip records the user's intent in `excludedWearers`. const toggleWearer = (hatId: Address, wearer: Address) => { @@ -330,31 +342,27 @@ const ScheduledNew: FC = () => { const multiplierBottoms: bigint[] = []; const confirmedWearers: Address[][] = []; + // Same batch packing as the splits authoring flow — the ratios between the + // roles' multipliers are what the contract can represent, see + // `utils/multiplier`. + const fractions = packMultipliers(activeRoles.map((r) => r.multiplier)); + if (!fractions) { + toast.error("分配係数の値を確認してください"); + return; + } + let totalConfirmed = 0; - for (const r of activeRoles) { + activeRoles.forEach((r, i) => { hatIds.push(BigInt(r.hatId)); - // Same multiplier-fraction packing as the splits authoring flow: a - // non-integer multiplier becomes top/bottom so the contract can stay in - // integer math. - const [top, bottom] = r.multiplier - ? String(r.multiplier).includes(".") - ? [ - BigInt( - r.multiplier * 10 ** String(r.multiplier).split(".")[1].length, - ), - BigInt(10 ** String(r.multiplier).split(".")[1].length), - ] - : [BigInt(r.multiplier), BigInt(1)] - : [BigInt(1), BigInt(1)]; - multiplierTops.push(top); - multiplierBottoms.push(bottom); + multiplierTops.push(fractions[i].top); + multiplierBottoms.push(fractions[i].bottom); // Resolve confirmed wearers from the LATEST dutyOptions (not the role // snapshot) so wearers minted between page load and submit are included // by default. The user's exclusions still apply. const wearers = includedWearersFor(r.hatId); confirmedWearers.push(wearers); totalConfirmed += wearers.length; - } + }); if (totalConfirmed < 2) { toast.error("確定者は合計2人以上必要です"); @@ -651,78 +659,90 @@ const ScheduledNew: FC = () => {
0 && "border-t border-border", )} > - -
-
- - {duty.name} - - {totalWearers > 0 && ( + > + {checked && } + + - {totalWearers > 0 && ( - + {totalWearers > 0 && ( + + 対象メンバー{" "} + {allSelected + ? `全${totalWearers}人` + : `${selectedWearers} / ${totalWearers}人`} + + )} +
+ + {totalWearers > 0 && ( + + )} + + {checked && ( + + updateMultiplier(duty.hatId, value) + } + className="px-5 pb-3 pl-[54px]" + /> )} ); diff --git a/pkgs/frontend/app/routes/$treeId_.splits.new.tsx b/pkgs/frontend/app/routes/$treeId_.splits.new.tsx index a8950af4..defba901 100644 --- a/pkgs/frontend/app/routes/$treeId_.splits.new.tsx +++ b/pkgs/frontend/app/routes/$treeId_.splits.new.tsx @@ -17,6 +17,11 @@ import { import { useNavigate, useParams } from "react-router"; import { toast } from "sonner"; import { ipfs2https } from "utils/ipfs"; +import { + DEFAULT_MULTIPLIER, + isValidMultiplier, + packMultipliers, +} from "utils/multiplier"; import { abbreviateAddress } from "utils/wallet"; import type { Address } from "viem"; import { @@ -31,6 +36,7 @@ import { import { SectionLabel } from "~/components/composite/section-label"; import { StepBar } from "~/components/composite/step-bar"; import { ScreenHeader } from "~/components/layout/ScreenHeader"; +import { RoleMultiplierField } from "~/components/splits/RoleMultiplierField"; import { RuleCard, type WeightInfo } from "~/components/splits/RuleCard"; import { SplitBreakdownCard } from "~/components/splits/SplitBreakdownCard"; import { Avatar, AvatarFallback, AvatarImage } from "~/components/ui/avatar"; @@ -61,7 +67,8 @@ const STEP_INDEX: Record = { interface RoleInput { hatId: Address; active: boolean; - multiplier: number; + /** Raw user input — packed into a contract fraction at submit time. */ + multiplier: string; wearers: Address[]; } @@ -149,7 +156,7 @@ const SplitterNew: FC = () => { next[key] = { hatId: o.hatId, active: true, - multiplier: 1, + multiplier: DEFAULT_MULTIPLIER, wearers: o.wearers, }; changed = true; @@ -168,6 +175,15 @@ const SplitterNew: FC = () => { }); }; + const updateMultiplier = (hatId: Address, multiplier: string) => { + const key = hatId.toLowerCase(); + setRoles((current) => { + const existing = current[key]; + if (!existing) return current; + return { ...current, [key]: { ...existing, multiplier } }; + }); + }; + // Flip an individual wearer in / out of the per-duty selection. The duty row // toggles inclusion of the duty as a whole; this toggles which wearers count // when the duty IS included (mirrors the legacy "詳細設定" dialog). @@ -258,44 +274,45 @@ const SplitterNew: FC = () => { [dutyWeight, recvWeight], ); + const activeRoles = useMemo( + () => Object.values(roles).filter((r) => r.active), + [roles], + ); + + // Multipliers are packed as one batch so their ratios survive the contract's + // integer division — see `utils/multiplier`. Returns null only when an input + // is invalid, which `isFormValid` already blocks. const calcSplitsParams = useCallback(() => { - return Object.values(roles) - .filter((r) => r.active) - .map((r) => { - // Preserves the original multiplier-fraction packing: a non-integer - // multiplier becomes a `top/bottom` pair so the contract can use - // integer math. - const [multiplierTop, multiplierBottom] = r.multiplier - ? String(r.multiplier).includes(".") - ? [ - BigInt( - r.multiplier * - 10 ** String(r.multiplier).split(".")[1].length, - ), - BigInt(10 ** String(r.multiplier).split(".")[1].length), - ] - : [BigInt(r.multiplier), BigInt(1)] - : [BigInt(1), BigInt(1)]; - return { - hatId: BigInt(r.hatId), - multiplierTop, - multiplierBottom, - wearers: r.wearers, - }; - }); - }, [roles]); + const fractions = packMultipliers(activeRoles.map((r) => r.multiplier)); + if (!fractions) return null; + return activeRoles.map((r, i) => ({ + hatId: BigInt(r.hatId), + multiplierTop: fractions[i].top, + multiplierBottom: fractions[i].bottom, + wearers: r.wearers, + })); + }, [activeRoles]); - const selectedDutyCount = useMemo( - () => Object.values(roles).filter((r) => r.active).length, - [roles], + const selectedDutyCount = activeRoles.length; + const hasInvalidMultiplier = useMemo( + () => activeRoles.some((r) => !isValidMultiplier(r.multiplier)), + [activeRoles], ); - const isFormValid = !!splitterName && availableName && selectedDutyCount > 0; + const isFormValid = + !!splitterName && + availableName && + selectedDutyCount > 0 && + !hasInvalidMultiplier; const handlePreview = useCallback(async () => { if (!isFormValid) return; + const splitsParams = calcSplitsParams(); + if (!splitsParams) { + toast.error("分配係数の値を確認してください"); + return; + } setIsPreviewing(true); try { - const splitsParams = calcSplitsParams(); const res = await previewSplits([splitsParams, weightParams]); let totalOwnership = 0; const consolidated = res[0].reduce((acc, address, index) => { @@ -354,8 +371,12 @@ const SplitterNew: FC = () => { const { setName } = useSetName(); const handleCreate = useCallback(async () => { + const splitsParams = calcSplitsParams(); + if (!splitsParams) { + toast.error("分配係数の値を確認してください"); + return; + } try { - const splitsParams = calcSplitsParams(); const res = await createSplits({ args: [splitsParams, weightParams] }); const created = res?.find((r) => r.eventName === "SplitsCreated")?.args .split; @@ -543,79 +564,95 @@ const SplitterNew: FC = () => {
0 && "border-t border-border", - checked && "hover:bg-bg", )} > - -
-
- - {duty.name} - - {totalWearers > 0 && ( + > + {checked && } + + - {totalWearers > 0 && ( - + {totalWearers > 0 && ( + + 対象メンバー{" "} + {allSelected + ? `全${totalWearers}人` + : `${selectedWearers} / ${totalWearers}人`} + + )} +
+ + {totalWearers > 0 && ( + + )} + + {checked && ( + + updateMultiplier(duty.hatId, value) + } + className="px-4 pb-3 pl-[50px]" + /> )} ); @@ -788,9 +825,15 @@ const SplitterNew: FC = () => { tone="secondary" className="mt-0.5 leading-relaxed" > - {Object.values(roles) - .filter((r) => r.active) - .map((r) => dutyNameById.get(r.hatId.toLowerCase()) ?? "当番") + {activeRoles + .map((r) => { + const name = + dutyNameById.get(r.hatId.toLowerCase()) ?? "当番"; + // Only annotate adjusted roles — "1倍" on every line is noise. + return Number(r.multiplier) === 1 + ? name + : `${name}(${r.multiplier.trim()}倍)`; + }) .join("、")} diff --git a/pkgs/frontend/utils/multiplier.ts b/pkgs/frontend/utils/multiplier.ts new file mode 100644 index 00000000..529ec4f7 --- /dev/null +++ b/pkgs/frontend/utils/multiplier.ts @@ -0,0 +1,96 @@ +/** + * Per-role distribution multiplier (分配係数) shared by the splits authoring + * flow and the scheduled-distribution flow. + * + * The value is kept as the raw string the user typed so partial input ("1.", + * "") stays editable, and is packed into the contract's + * `multiplierTop` / `multiplierBottom` pair only at submit time. + * + * Why the packing is a *batch* operation instead of one fraction per role: + * `SplitsCreator` collapses the pair with integer division + * (`roleMultiplier = multiplierTop / multiplierBottom`, SplitsCreator.sol:322), + * so handing it `3 / 2` for "1.5倍" would truncate to `1` and the input would + * silently do nothing — and `1 / 2` for "0.5倍" would zero the role out. Role + * allocations are normalised against their own total afterwards + * (SplitsCreator.sol:212), so scaling *every* active role by the same constant + * leaves the outcome untouched. That lets us express fractional multipliers as + * a common-denominator integer ratio the contract can evaluate exactly: + * `1.5, 1` → `3, 2`, `0.5, 1` → `1, 2`. + * + * Digits are also assembled string-wise rather than through float math: + * `1.15 * 100` is `114.99999999999999` in IEEE-754 and `BigInt()` throws on it. + */ + +/** Seed value for a freshly added role — "1倍", i.e. no adjustment. */ +export const DEFAULT_MULTIPLIER = "1"; + +/** + * Decimal places accepted. Two is plenty for a weighting knob and keeps the + * common-denominator scaling (and therefore the on-chain integers) small. + */ +export const MAX_MULTIPLIER_DECIMALS = 2; + +export const MULTIPLIER_HINT = `0より大きい数を小数第${MAX_MULTIPLIER_DECIMALS}位まで入力してください(例: 1、1.5)`; + +export interface MultiplierFraction { + top: bigint; + bottom: bigint; +} + +interface ParsedMultiplier { + /** All digits with the decimal point removed, e.g. "1.5" → 15n. */ + digits: bigint; + /** Number of decimal places, e.g. "1.5" → 1. */ + decimals: number; +} + +const MULTIPLIER_PATTERN = /^(\d*)(?:\.(\d*))?$/; + +const parseMultiplier = (input: string): ParsedMultiplier | null => { + const match = MULTIPLIER_PATTERN.exec(input.trim()); + if (!match) return null; + const intPart = match[1] ?? ""; + const fracPart = match[2] ?? ""; + if (fracPart.length > MAX_MULTIPLIER_DECIMALS) return null; + const digits = `${intPart}${fracPart}`; + // Rejects "" and "." — the pattern matches both. + if (digits.length === 0) return null; + const value = BigInt(digits); + // A zero multiplier drops every wearer of the role to a 0 allocation, which + // is what deselecting the role is for. + if (value === 0n) return null; + return { digits: value, decimals: fracPart.length }; +}; + +/** True when the raw input is a positive number within the decimal limit. */ +export const isValidMultiplier = (input: string): boolean => + parseMultiplier(input) !== null; + +const gcd = (a: bigint, b: bigint): bigint => (b === 0n ? a : gcd(b, a % b)); + +/** + * Pack the active roles' multipliers into contract fractions, preserving their + * ratios exactly. Returns `null` if any input is invalid — callers gate on + * {@link isValidMultiplier} first and treat `null` as a bug guard. + */ +export const packMultipliers = ( + inputs: string[], +): MultiplierFraction[] | null => { + const parsed: ParsedMultiplier[] = []; + for (const input of inputs) { + const value = parseMultiplier(input); + if (!value) return null; + parsed.push(value); + } + if (parsed.length === 0) return []; + + // Lift everything onto the widest decimal place so the ratios become whole + // numbers, then divide through by the common factor — a uniform scale + // cancels out, so this only keeps the on-chain integers small. + const maxDecimals = parsed.reduce((max, p) => Math.max(max, p.decimals), 0); + const tops = parsed.map( + (p) => p.digits * 10n ** BigInt(maxDecimals - p.decimals), + ); + const divisor = tops.reduce(gcd); + return tops.map((top) => ({ top: top / divisor, bottom: 1n })); +};