mirror of
https://github.com/luxfhe/research-ocp-fhe.git
synced 2026-11-03 06:05:38 +00:00
617 lines
22 KiB
TypeScript
617 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect } from 'react';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Plus, Trash2, Loader2 } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { isAddress } from 'viem';
|
|
import { useCreateRound, InvestorFormData } from '@/hooks/use-create-round';
|
|
import { useCompany } from '@/hooks/use-company';
|
|
|
|
// Treasury shares constant (founder's initial shares)
|
|
const TREASURY_SHARES = 10_000;
|
|
|
|
interface CreateRoundModalProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
onCreateRound: () => void;
|
|
companyId: string;
|
|
contractAddress: string;
|
|
}
|
|
|
|
export function CreateRoundModal({
|
|
open,
|
|
onOpenChange,
|
|
onCreateRound,
|
|
companyId,
|
|
contractAddress,
|
|
}: CreateRoundModalProps) {
|
|
const [roundName, setRoundName] = useState('');
|
|
const [roundDate, setRoundDate] = useState('');
|
|
const [preMoneyValuation, setPreMoneyValuation] = useState('');
|
|
const [investors, setInvestors] = useState<InvestorFormData[]>([
|
|
{ name: '', address: '', shares: '', pricePerShare: '' },
|
|
]);
|
|
|
|
const {
|
|
createRound,
|
|
saveToDatabase,
|
|
reset,
|
|
step,
|
|
isPending,
|
|
isConfirming,
|
|
isEncrypting,
|
|
isSaving,
|
|
isFhevmLoading,
|
|
error,
|
|
canProceed,
|
|
} = useCreateRound(companyId, contractAddress);
|
|
|
|
// Fetch company data to get total shares issued
|
|
const {
|
|
data: company,
|
|
isLoading: isLoadingCompany,
|
|
error: companyError,
|
|
} = useCompany(companyId);
|
|
|
|
// Calculate total shares issued including founder's treasury shares and all previous rounds
|
|
const calculateTotalSharesIssued = (): number => {
|
|
if (!company || !company.rounds) return TREASURY_SHARES;
|
|
|
|
const investorShares = company.rounds.reduce((total, round) => {
|
|
return (
|
|
total +
|
|
round.investments.reduce((roundTotal, investment) => {
|
|
return roundTotal + investment.shareAmount;
|
|
}, 0)
|
|
);
|
|
}, 0);
|
|
|
|
return TREASURY_SHARES + investorShares;
|
|
};
|
|
|
|
// Calculate fixed price per share based on total shares and pre-money valuation
|
|
const calculateFixedPricePerShare = (): number => {
|
|
const totalShares = calculateTotalSharesIssued();
|
|
const preMoneyValue = parseFloat(preMoneyValuation) || 0;
|
|
|
|
if (totalShares === 0 || preMoneyValue === 0) return 0;
|
|
|
|
return preMoneyValue / totalShares;
|
|
};
|
|
|
|
// Calculate rounded price per share (integer) for on-chain storage
|
|
const getRoundedPricePerShare = (): number => {
|
|
const exactPrice = calculateFixedPricePerShare();
|
|
return Math.round(exactPrice);
|
|
};
|
|
|
|
// Check if rounding occurred
|
|
const isPriceRounded = (): boolean => {
|
|
const exactPrice = calculateFixedPricePerShare();
|
|
const roundedPrice = getRoundedPricePerShare();
|
|
return exactPrice > 0 && Math.abs(exactPrice - roundedPrice) > 0.001;
|
|
};
|
|
|
|
// Effect to update all investors' price per share when pre-money valuation changes
|
|
useEffect(() => {
|
|
const roundedPrice = getRoundedPricePerShare();
|
|
if (roundedPrice > 0) {
|
|
const roundedPriceString = roundedPrice.toString();
|
|
setInvestors(prevInvestors =>
|
|
prevInvestors.map(investor => ({
|
|
...investor,
|
|
pricePerShare: roundedPriceString,
|
|
}))
|
|
);
|
|
}
|
|
}, [preMoneyValuation, company]);
|
|
|
|
const addInvestor = () => {
|
|
const roundedPrice = getRoundedPricePerShare();
|
|
const roundedPriceString = roundedPrice > 0 ? roundedPrice.toString() : '';
|
|
|
|
setInvestors([
|
|
...investors,
|
|
{ name: '', address: '', shares: '', pricePerShare: roundedPriceString },
|
|
]);
|
|
};
|
|
|
|
const removeInvestor = (index: number) => {
|
|
if (investors.length > 1) {
|
|
setInvestors(investors.filter((_, i) => i !== index));
|
|
}
|
|
};
|
|
|
|
const updateInvestor = (
|
|
index: number,
|
|
field: keyof InvestorFormData,
|
|
value: string
|
|
) => {
|
|
const updated = [...investors];
|
|
updated[index] = { ...updated[index], [field]: value };
|
|
setInvestors(updated);
|
|
};
|
|
|
|
const calculateInvestment = (
|
|
shares: string,
|
|
pricePerShare: string
|
|
): string => {
|
|
const sharesNum = parseFloat(shares) || 0;
|
|
const priceNum = parseFloat(pricePerShare) || 0;
|
|
const investment = sharesNum * priceNum;
|
|
return investment > 0
|
|
? `$${investment.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
|
|
: '$0';
|
|
};
|
|
|
|
const calculateTotalInvestment = (): number => {
|
|
return investors.reduce((total, investor) => {
|
|
const shares = parseFloat(investor.shares) || 0;
|
|
const pricePerShare = parseFloat(investor.pricePerShare) || 0;
|
|
return total + shares * pricePerShare;
|
|
}, 0);
|
|
};
|
|
|
|
const calculatePostMoneyValuation = (): number => {
|
|
const preMoneyValue = parseFloat(preMoneyValuation) || 0;
|
|
const totalInvestment = calculateTotalInvestment();
|
|
return preMoneyValue + totalInvestment;
|
|
};
|
|
|
|
const formatCurrency = (amount: number): string => {
|
|
return amount > 0
|
|
? `$${amount.toLocaleString(undefined, { maximumFractionDigits: 2 })}`
|
|
: '$0';
|
|
};
|
|
|
|
const validateForm = (): string | null => {
|
|
if (!roundName.trim()) return 'Round name is required';
|
|
if (!roundDate) return 'Round date is required';
|
|
if (!preMoneyValuation || parseFloat(preMoneyValuation) <= 0)
|
|
return 'Pre-money valuation must be greater than 0';
|
|
|
|
// Check if company data is loaded and if there are previous rounds
|
|
if (isLoadingCompany) return 'Loading company data. Please wait...';
|
|
if (companyError) return 'Error loading company data. Please try again.';
|
|
|
|
for (let i = 0; i < investors.length; i++) {
|
|
const investor = investors[i];
|
|
if (!investor.name.trim()) return `Investor ${i + 1}: Name is required`;
|
|
if (!investor.address.trim())
|
|
return `Investor ${i + 1}: Address is required`;
|
|
if (!isAddress(investor.address))
|
|
return `Investor ${i + 1}: Invalid Ethereum address`;
|
|
if (!investor.shares || parseFloat(investor.shares) <= 0)
|
|
return `Investor ${i + 1}: Shares must be greater than 0`;
|
|
if (!investor.pricePerShare || parseFloat(investor.pricePerShare) <= 0)
|
|
return `Investor ${i + 1}: Price per share must be greater than 0`;
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
// Handle step progression and auto-save when ready
|
|
useEffect(() => {
|
|
if (step === 'saving') {
|
|
saveToDatabase(roundName, roundDate);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [step]);
|
|
|
|
// Handle completion
|
|
useEffect(() => {
|
|
if (step === 'complete') {
|
|
onCreateRound();
|
|
toast.success(`Round "${roundName}" created successfully!`);
|
|
handleReset();
|
|
onOpenChange(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [step]);
|
|
|
|
// Handle errors
|
|
useEffect(() => {
|
|
if (error) {
|
|
console.error('Error creating round:', error);
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Failed to create round. Please try again.'
|
|
);
|
|
}
|
|
}, [error]);
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
const validationError = validateForm();
|
|
if (validationError) {
|
|
toast.error(validationError);
|
|
return;
|
|
}
|
|
|
|
if (!canProceed.encrypt) {
|
|
toast.error(
|
|
'FHEVM not ready. Please ensure your wallet is connected and try again.'
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await createRound({
|
|
roundName: roundName.trim(),
|
|
roundDate,
|
|
investors,
|
|
preMoneyValuationForm: preMoneyValuation,
|
|
});
|
|
} catch (error) {
|
|
console.error('Error creating round:', error);
|
|
toast.error(
|
|
error instanceof Error
|
|
? error.message
|
|
: 'Failed to create round. Please try again.'
|
|
);
|
|
}
|
|
};
|
|
|
|
const handleReset = () => {
|
|
setRoundName('');
|
|
setRoundDate('');
|
|
setPreMoneyValuation('');
|
|
setInvestors([{ name: '', address: '', shares: '', pricePerShare: '' }]);
|
|
reset();
|
|
};
|
|
|
|
const handleCancel = () => {
|
|
setRoundName('');
|
|
setRoundDate('');
|
|
setPreMoneyValuation('');
|
|
setInvestors([{ name: '', address: '', shares: '', pricePerShare: '' }]);
|
|
onOpenChange(false);
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="max-w-6xl w-full max-h-[90vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle>Create New Round</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<form onSubmit={handleSubmit} className="space-y-6">
|
|
{/* Round Details */}
|
|
<div className="space-y-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="roundName">Round Name</Label>
|
|
<Input
|
|
id="roundName"
|
|
placeholder="e.g., Seed, Series A"
|
|
value={roundName}
|
|
onChange={e => setRoundName(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="roundDate">Round Date</Label>
|
|
<Input
|
|
id="roundDate"
|
|
type="date"
|
|
value={roundDate}
|
|
onChange={e => setRoundDate(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="preMoneyValuation">Pre-Money Valuation ($)</Label>
|
|
<Input
|
|
id="preMoneyValuation"
|
|
type="number"
|
|
placeholder="e.g., 5000000"
|
|
min="0"
|
|
step="0.01"
|
|
value={preMoneyValuation}
|
|
onChange={e => setPreMoneyValuation(e.target.value)}
|
|
required
|
|
/>
|
|
</div>
|
|
|
|
{/* Calculation Information */}
|
|
{isLoadingCompany ? (
|
|
<div className="flex items-center gap-2 p-3 bg-blue-50 rounded-lg border">
|
|
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
|
<span className="text-sm text-blue-700">
|
|
Loading company data...
|
|
</span>
|
|
</div>
|
|
) : companyError ? (
|
|
<div className="p-3 bg-red-50 rounded-lg border border-red-200">
|
|
<p className="text-sm text-red-700">
|
|
Error loading company data. Price per share cannot be
|
|
calculated.
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
|
|
<div className="space-y-2">
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm font-medium text-blue-900">
|
|
Founder Shares (Treasury):
|
|
</span>
|
|
<span className="text-sm font-mono text-blue-700">
|
|
{TREASURY_SHARES.toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm font-medium text-blue-900">
|
|
Previous Investment Shares:
|
|
</span>
|
|
<span className="text-sm font-mono text-blue-700">
|
|
{(
|
|
calculateTotalSharesIssued() - TREASURY_SHARES
|
|
).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between items-center border-t pt-2">
|
|
<span className="text-sm font-semibold text-blue-900">
|
|
Total Outstanding Shares:
|
|
</span>
|
|
<span className="text-sm font-mono text-blue-700 font-semibold">
|
|
{calculateTotalSharesIssued().toLocaleString()}
|
|
</span>
|
|
</div>
|
|
{preMoneyValuation && parseFloat(preMoneyValuation) > 0 ? (
|
|
<>
|
|
<div className="flex justify-between items-center">
|
|
<span className="text-sm font-medium text-blue-900">
|
|
Calculated Price per Share:
|
|
</span>
|
|
<span className="text-sm font-mono text-blue-700 font-semibold">
|
|
${getRoundedPricePerShare()}
|
|
</span>
|
|
</div>
|
|
{isPriceRounded() && (
|
|
<div className="p-2 bg-amber-50 rounded border border-amber-200">
|
|
<p className="text-sm text-amber-700">
|
|
Exact share price would be $
|
|
{calculateFixedPricePerShare().toFixed(2)}. Rounded
|
|
to ${getRoundedPricePerShare()} (required for
|
|
on-chain storage). This may slightly change
|
|
post-money totals.
|
|
</p>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="text-sm text-zinc-600">
|
|
Enter pre-money valuation to calculate price per share
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Investors Section */}
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-base font-semibold">Investors</Label>
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={addInvestor}
|
|
className="flex items-center gap-2"
|
|
>
|
|
<Plus className="h-4 w-4" />
|
|
Add Investor
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Investors Table */}
|
|
<div className="border rounded-lg overflow-hidden">
|
|
<div className="bg-zinc-50 px-4 py-4 border-b">
|
|
<div className="flex gap-4 text-sm font-medium text-zinc-600">
|
|
<div className="w-40">Investor Name</div>
|
|
<div className="flex-1 min-w-0">Ethereum Address</div>
|
|
<div className="w-20">Shares</div>
|
|
<div className="w-24">Price/Share</div>
|
|
<div className="w-28">Investment</div>
|
|
<div className="w-10"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="divide-y">
|
|
{investors.map((investor, index) => (
|
|
<div key={index} className="p-4">
|
|
<div className="flex gap-4 items-start">
|
|
<div className="w-40">
|
|
<Input
|
|
placeholder="Investor name"
|
|
value={investor.name}
|
|
onChange={e =>
|
|
updateInvestor(index, 'name', e.target.value)
|
|
}
|
|
className="h-10"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<Input
|
|
placeholder="0x..."
|
|
value={investor.address}
|
|
onChange={e =>
|
|
updateInvestor(index, 'address', e.target.value)
|
|
}
|
|
className={`h-10 font-mono text-sm ${
|
|
investor.address && !isAddress(investor.address)
|
|
? 'border-red-300'
|
|
: ''
|
|
}`}
|
|
required
|
|
/>
|
|
{investor.address && !isAddress(investor.address) && (
|
|
<p className="text-xs text-red-600 mt-1">
|
|
Invalid Ethereum address
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="w-20">
|
|
<Input
|
|
type="number"
|
|
placeholder="0"
|
|
min="0"
|
|
step="1"
|
|
value={investor.shares}
|
|
onChange={e =>
|
|
updateInvestor(index, 'shares', e.target.value)
|
|
}
|
|
className="h-10"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="w-24">
|
|
<Input
|
|
type="number"
|
|
placeholder="0.00"
|
|
min="0"
|
|
step="0.01"
|
|
value={investor.pricePerShare}
|
|
readOnly
|
|
className="h-10 bg-zinc-50 text-zinc-600 cursor-not-allowed"
|
|
title="Price per share is automatically calculated based on pre-money valuation and total shares issued"
|
|
/>
|
|
</div>
|
|
<div className="w-28 flex items-center h-10">
|
|
<span className="text-sm font-mono text-zinc-700 font-medium truncate">
|
|
{calculateInvestment(
|
|
investor.shares,
|
|
investor.pricePerShare
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="w-10 flex justify-end items-center h-10">
|
|
{investors.length > 1 && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => removeInvestor(index)}
|
|
className="text-red-600 hover:text-red-700 hover:bg-red-50 h-8 w-8 p-0"
|
|
>
|
|
<Trash2 className="h-4 w-4" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Round Summary */}
|
|
<div className="bg-zinc-50 border rounded-lg p-4 space-y-3">
|
|
<Label className="text-base font-semibold text-zinc-900">
|
|
Round Summary
|
|
</Label>
|
|
<div className="grid grid-cols-2 gap-6">
|
|
<div className="space-y-1">
|
|
<div className="text-sm text-zinc-600">
|
|
Total Amount Invested
|
|
</div>
|
|
<div className="text-xl font-semibold text-zinc-900 font-mono">
|
|
{formatCurrency(calculateTotalInvestment())}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-1">
|
|
<div className="text-sm text-zinc-600">
|
|
Post-Money Valuation
|
|
</div>
|
|
<div className="text-xl font-semibold text-zinc-900 font-mono">
|
|
{formatCurrency(calculatePostMoneyValuation())}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Progress indicator */}
|
|
{(isEncrypting || isPending || isConfirming || isSaving) && (
|
|
<div className="flex items-center gap-3 p-4 bg-blue-50 rounded-lg border">
|
|
<Loader2 className="h-4 w-4 animate-spin text-blue-600" />
|
|
<div className="text-sm">
|
|
{isEncrypting && 'Encrypting investor data...'}
|
|
{isPending && 'Waiting for transaction confirmation...'}
|
|
{isConfirming && 'Transaction confirming...'}
|
|
{isSaving && 'Saving round data...'}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Actions */}
|
|
<div className="flex justify-end gap-3 pt-4 border-t">
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={isEncrypting || isPending || isConfirming || isSaving}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
disabled={
|
|
isEncrypting ||
|
|
isPending ||
|
|
isConfirming ||
|
|
isSaving ||
|
|
isFhevmLoading
|
|
}
|
|
className="min-w-[120px]"
|
|
>
|
|
{isEncrypting && (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
Encrypting...
|
|
</>
|
|
)}
|
|
{isPending && (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
Confirming...
|
|
</>
|
|
)}
|
|
{isConfirming && (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
Processing...
|
|
</>
|
|
)}
|
|
{isSaving && (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
Saving...
|
|
</>
|
|
)}
|
|
{!isEncrypting &&
|
|
!isPending &&
|
|
!isConfirming &&
|
|
!isSaving &&
|
|
'Create Round'}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|