Skip to content
Open
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
103 changes: 77 additions & 26 deletions src/app/create/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import { useState, useMemo, useEffect, useRef } from 'react';
import { useState, useMemo, useEffect, useRef, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import CreateCommitmentStepSelectType from '@/components/CreateCommitmentStepSelectType';
import CreateCommitmentStepConfigure from '@/components/CreateCommitmentStepConfigure';
Expand All @@ -16,6 +16,7 @@ import { GuidedTour } from '@/components/onboarding/GuidedTour';
import { HelpCircle } from 'lucide-react';
import { usePrefillFromCommitment } from '@/hooks/usePrefillFromCommitment';
import { type CommitmentPreset } from '@/components/create/commitmentPresets';
import { trackApiCall, startLatencyTimer } from '@/lib/telemetry';

type CommitmentType = 'safe' | 'balanced' | 'aggressive';

Expand Down Expand Up @@ -44,6 +45,9 @@ const VALIDATION = {
MAX_LOSS_MAX: 100,
} as const;

/** Maximum wall-clock ms to wait for on-chain submission before timing out. */
const SUBMISSION_TIMEOUT_MS = 30_000;

// Generate a random commitment ID (in production, this comes from the blockchain)
function generateCommitmentId(): string {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
Expand All @@ -57,7 +61,7 @@ function generateCommitmentId(): string {
export default function CreateCommitment() {
const router = useRouter();
const { address: ownerAddress } = useWallet();
const { draft, saveDraft, clearDraft } = useDraftPersistence();
const { allDrafts, saveDraft, clearDraft, clearAllDrafts } = useDraftPersistence();
const prefill = usePrefillFromCommitment();
const [showResumePrompt, setShowResumePrompt] = useState(false);
const [step, setStep] = useState(1);
Expand Down Expand Up @@ -99,6 +103,7 @@ export default function CreateCommitment() {
const suppressDraftSave = useRef(false);
const isMounted = useRef(true);
const wasSubmittingRef = useRef(false);
const submissionTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// In production this would come from the connected wallet hook.
// Passed as undefined while wallet integration is pending; the fund
Expand All @@ -110,6 +115,7 @@ export default function CreateCommitment() {
return () => {
isMounted.current = false;
submissionEpoch.current += 1;
if (submissionTimeoutRef.current) clearTimeout(submissionTimeoutRef.current);
};
}, []);

Expand All @@ -130,12 +136,15 @@ export default function CreateCommitment() {
wasSubmittingRef.current = isSubmitting && submitStatus === 'submitting';
}, [isSubmitting, submitStatus]);

// Show the resume prompt when there are saved drafts on mount (and no prefill sourceId).
useEffect(() => {
if (draft) {
if (allDrafts.length > 0 && !prefill) {
suppressDraftSave.current = true;
setShowResumePrompt(true);
}
}, [draft]);
// Only run on mount; allDrafts is stable after initial load.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

// When a source commitment is loaded via ?sourceId=, prefill the wizard fields
// and skip straight to step 2 so the user can review / adjust the copied parameters.
Expand Down Expand Up @@ -168,27 +177,29 @@ export default function CreateCommitment() {
}
}, [startTour]);

const handleResumeDraft = () => {
if (draft) {
suppressDraftSave.current = false;
updateSubmitStatus('idle');
setSubmitError(null);
setStep(draft.step);
setSelectedType(draft.selectedType);
setCommitmentType(draft.commitmentType);
setAmount(draft.amount);
setAsset(draft.asset);
setDurationDays(draft.durationDays);
setMaxLossPercent(draft.maxLossPercent);
setShowResumePrompt(false);
}
};
const handleResumeDraft = useCallback((draftId: string) => {
const target = allDrafts.find((d) => d.id === draftId);
if (!target) return;
const data = target.data;
suppressDraftSave.current = false;
updateSubmitStatus('idle');
setSubmitError(null);
setStep(data.step);
setSelectedType(data.selectedType);
setCommitmentType(data.commitmentType);
setAmount(data.amount);
setAsset(data.asset);
setDurationDays(data.durationDays);
setMaxLossPercent(data.maxLossPercent);
setShowResumePrompt(false);
trackApiCall({ path: 'draft/resume', method: 'READ', latencyMs: 0, ok: true });
}, [allDrafts]);

const handleStartFresh = () => {
const handleStartFresh = useCallback(() => {
suppressDraftSave.current = false;
updateSubmitStatus('idle');
setSubmitError(null);
clearDraft();
clearAllDrafts();
setShowResumePrompt(false);
setSelectedType(null);
setCommitmentType('balanced');
Expand All @@ -197,7 +208,13 @@ export default function CreateCommitment() {
setDurationDays(90);
setMaxLossPercent(100);
setStep(1);
};
trackApiCall({ path: 'draft/discard', method: 'DELETE', latencyMs: 0, ok: true });
}, [clearAllDrafts]);

const handleDeleteDraft = useCallback((draftId: string) => {
clearDraft(draftId);
trackApiCall({ path: 'draft/delete', method: 'DELETE', latencyMs: 0, ok: true });
}, [clearDraft]);

useEffect(() => {
if (suppressDraftSave.current || showSuccessModal || isSubmitting) {
Expand Down Expand Up @@ -309,6 +326,10 @@ export default function CreateCommitment() {
if (submitStatusRef.current === 'submitting' || isSubmitting) {
// Cancel any in-flight submission to avoid stale completion.
submissionEpoch.current += 1;
if (submissionTimeoutRef.current) {
clearTimeout(submissionTimeoutRef.current);
submissionTimeoutRef.current = null;
}
setIsSubmitting(false);
suppressDraftSave.current = false;
}
Expand Down Expand Up @@ -352,6 +373,25 @@ export default function CreateCommitment() {
const currentEpoch = submissionEpoch.current;
setIsSubmitting(true);

const stop = startLatencyTimer();

// Enforce a hard submission timeout so the UI never hangs indefinitely.
submissionTimeoutRef.current = setTimeout(() => {
if (!isMounted.current || submissionEpoch.current !== currentEpoch) return;
submissionEpoch.current += 1; // invalidate any late response
setIsSubmitting(false);
setSubmitError('Submission timed out. Please check your wallet and try again.');
updateSubmitStatus('error');
suppressDraftSave.current = false;
trackApiCall({
path: 'commitment/submit',
method: 'POST',
latencyMs: stop(),
ok: false,
code: 'TIMEOUT',
});
}, SUBMISSION_TIMEOUT_MS);

new Promise<string>((resolve) => {
setTimeout(() => {
// Simulated on-chain submission. Replace with real contract interaction.
Expand All @@ -361,6 +401,11 @@ export default function CreateCommitment() {
})
.then((newCommitmentId) => {
if (!isMounted.current || submissionEpoch.current !== currentEpoch) return;
if (submissionTimeoutRef.current) {
clearTimeout(submissionTimeoutRef.current);
submissionTimeoutRef.current = null;
}
const latencyMs = stop();
setIsSubmitting(false);
setCommitmentId(newCommitmentId);
if (typeof window !== 'undefined') {
Expand All @@ -370,13 +415,20 @@ export default function CreateCommitment() {
setShowSuccessModal(true);
suppressDraftSave.current = false;
clearDraft();
trackApiCall({ path: 'commitment/submit', method: 'POST', latencyMs, ok: true, status: 200 });
})
.catch((error: Error) => {
if (!isMounted.current || submissionEpoch.current !== currentEpoch) return;
if (submissionTimeoutRef.current) {
clearTimeout(submissionTimeoutRef.current);
submissionTimeoutRef.current = null;
}
const latencyMs = stop();
setIsSubmitting(false);
setSubmitError(error.message);
updateSubmitStatus('error');
suppressDraftSave.current = false;
trackApiCall({ path: 'commitment/submit', method: 'POST', latencyMs, ok: false, code: 'SUBMIT_ERROR' });
});
};

Expand Down Expand Up @@ -412,8 +464,6 @@ export default function CreateCommitment() {
setShowSuccessModal(false);
const numericId = commitmentId.split('-')[1] || '1';
router.push(`/commitments/${numericId}`);
};entId.split('-')[1] || '1';
router.push(`/commitments/${numericId}`);
};

const handleViewOnExplorer = () => {
Expand Down Expand Up @@ -449,11 +499,12 @@ export default function CreateCommitment() {
</div>
)}

{showResumePrompt && draft && (
{showResumePrompt && allDrafts.length > 0 && (
<ResumeDraftPrompt
draft={draft}
drafts={allDrafts}
onResume={handleResumeDraft}
onStartFresh={handleStartFresh}
onDeleteDraft={handleDeleteDraft}
/>
)}

Expand Down
10 changes: 5 additions & 5 deletions src/components/create/ResumeDraftPrompt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,8 @@ export default function ResumeDraftPrompt({
</div>

{error && (
<div role="alert" className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-70 text-sm">
{error
<div role="alert" className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-red-700 text-sm">
{error}
</div>
)}

Expand All @@ -144,7 +144,7 @@ export default function ResumeDraftPrompt({
{data.amount || 'Not set'} {data.asset}
</span>
<span>Duration:</span>
<span className="text-gray-700">{data.durationDays}d|/span>
<span className="text-gray-700">{data.durationDays}d</span>
<span>Step:</span>
<span className="text-gray-700">{data.step} of 3</span>
</div>
Expand All @@ -166,7 +166,7 @@ export default function ResumeDraftPrompt({
onClick={() => handleDelete(id)}
disabled={!!pendingAction}
className="px-3 py-1.5 border border-gray-200 rounded-lg text-gray-500 text-xs font-medium hover:bg-gray-100 transition-colors focus:outline-none focus:ring-2 focus:ring-gray-300 disabled:opacity-50 disabled:cursor-not-allowed"
aria-label=`Delete draft ${id}`
aria-label={`Delete draft ${id}`}
>
Delete
</button>
Expand All @@ -186,6 +186,6 @@ export default function ResumeDraftPrompt({
</button>
</div>
</div>
</div>
</div >
);
}
39 changes: 36 additions & 3 deletions src/hooks/useDraftPersistence.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { z } from 'zod';
import { trackApiCall, startLatencyTimer } from '@/lib/telemetry';

type CommitmentType = 'safe' | 'balanced' | 'aggressive';

Expand All @@ -25,6 +26,8 @@ export type DraftMap = Record<string, NamedDraft>;
const DRAFT_STORAGE_KEY = 'commitlabs-create-draft';
const DRAFT_MULTI_STORAGE_KEY = 'commitlabs-create-drafts';
export const DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/** Hard cap on concurrent saved drafts. Oldest-by-updatedAt are evicted when exceeded. */
export const MAX_DRAFTS = 5;

const DraftStateSchema = z.object({
step: z.number(),
Expand Down Expand Up @@ -55,6 +58,23 @@ export function pruneExpiredDrafts(drafts: DraftMap, ttlMs: number): DraftMap {
return Object.fromEntries(Object.entries(drafts).filter(([, d]) => now - d.updatedAt < ttlMs));
}

/**
* Enforce the MAX_DRAFTS cap by evicting the oldest drafts (by updatedAt) when
* the map exceeds the limit. The draft being saved (targetId) is always kept.
*/
export function enforceDraftCap(drafts: DraftMap, targetId: string, cap: number = MAX_DRAFTS): DraftMap {
const entries = Object.entries(drafts);
if (entries.length <= cap) return drafts;
// Sort ascending by updatedAt; oldest first. Always retain targetId.
const sorted = entries.sort(([, a], [, b]) => a.updatedAt - b.updatedAt);
const evicted = sorted.slice(0, entries.length - cap).filter(([id]) => id !== targetId);
if (evicted.length === 0) return drafts;
const result = { ...drafts };
for (const [id] of evicted) {
delete result[id];
}
return result;
}
export function migrateLegacyDraft(): DraftMap | null {
try {
const stored = localStorage.getItem(DRAFT_STORAGE_KEY);
Expand All @@ -76,22 +96,31 @@ export function migrateLegacyDraft(): DraftMap | null {
}

export function loadDraftsFromStorage(): DraftMap {
const stop = startLatencyTimer();
try {
const stored = localStorage.getItem(DRAFT_MULTI_STORAGE_KEY);
if (!stored) {
const migrated = migrateLegacyDraft();
if (migrated) return migrated;
if (migrated) {
trackApiCall({ path: 'draft/load', method: 'READ', latencyMs: stop(), ok: true, code: 'MIGRATED' });
return migrated;
}
trackApiCall({ path: 'draft/load', method: 'READ', latencyMs: stop(), ok: true, code: 'EMPTY' });
return {};
}
const parsed = JSON.parse(stored);
const result = DraftMapSchema.safeParse(parsed);
if (!result.success) {
localStorage.removeItem(DRAFT_MULTI_STORAGE_KEY);
trackApiCall({ path: 'draft/load', method: 'READ', latencyMs: stop(), ok: false, code: 'INVALID_SCHEMA' });
return {};
}
return pruneExpiredDrafts(result.data, DRAFT_TTL_MS);
const pruned = pruneExpiredDrafts(result.data, DRAFT_TTL_MS);
trackApiCall({ path: 'draft/load', method: 'READ', latencyMs: stop(), ok: true });
return pruned;
} catch {
localStorage.removeItem(DRAFT_MULTI_STORAGE_KEY);
trackApiCall({ path: 'draft/load', method: 'READ', latencyMs: stop(), ok: false, code: 'PARSE_ERROR' });
return {};
}
}
Expand All @@ -115,10 +144,11 @@ export function useDraftPersistence(draftId?: string) {
const targetId = id ?? draftId ?? `draft-${Date.now()}`;
if (debounceTimerRef.current) clearTimeout(debounceTimerRef.current);
debounceTimerRef.current = setTimeout(() => {
const stop = startLatencyTimer();
setDrafts((prev) => {
const now = Date.now();
const existing = prev[targetId];
const updated: DraftMap = {
const withNew: DraftMap = {
...prev,
[targetId]: {
id: targetId,
Expand All @@ -127,9 +157,12 @@ export function useDraftPersistence(draftId?: string) {
updatedAt: now,
},
};
const updated = enforceDraftCap(withNew, targetId);
try {
localStorage.setItem(DRAFT_MULTI_STORAGE_KEY, JSON.stringify(updated));
trackApiCall({ path: 'draft/save', method: 'WRITE', latencyMs: stop(), ok: true });
} catch {
trackApiCall({ path: 'draft/save', method: 'WRITE', latencyMs: stop(), ok: false, code: 'STORAGE_FULL' });
console.warn('Failed to save draft to localStorage');
}
return updated;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/draftRecovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ export function reduce(state: DraftState | undefined, event: DraftEvent): DraftS
if (s.status === 'submitting' || s.status === 'confirmed') return s;
return ok({ status: 'draft', draftId: event.draftId, step: event.step, data: event.data, id: null, error: null });

case 'RECOVER:':
case 'RECOVER':
if (s.status === 'submitting' || s.status === 'confirmed') return s;
const from = event.from;
if (from && typeof from === 'object') {
Expand Down