Skip to content

Commit db623da

Browse files
authored
feat: implement milestone submission flow (#143)
- Add MilestoneSubmissionModal component with BoundlessSheet - Add MilestoneSubmissionPage component for full-page experience - Add MilestoneSubmissionSuccess component for post-submit feedback - Update BoundlessSheet with accessibility improvements (DialogTitle) - Fix import casing issue in backing-history page - Export new components from project index - Implement pixel-perfect design matching Figma specifications - Add file upload with preview and removal functionality - Add external links with Save button and www. persistence - Add responsive design for mobile, desktop, and 4K monitors - Use BoundlessButton for all interactive elements - Add success page with Projects page link and Continue button
1 parent 75805ab commit db623da

5 files changed

Lines changed: 866 additions & 7 deletions

File tree

Lines changed: 379 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,379 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import BoundlessSheet from '../sheet/boundless-sheet';
5+
import { BoundlessButton } from '../buttons/BoundlessButton';
6+
import { Label } from '../ui/label';
7+
import {
8+
Calendar,
9+
Coins,
10+
Link as LinkIcon,
11+
ChevronUp,
12+
CloudUpload,
13+
Check,
14+
Trash2,
15+
} from 'lucide-react';
16+
import { cn } from '@/lib/utils';
17+
import MilestoneSubmissionSuccess from './MilestoneSubmissionSuccess';
18+
19+
export interface MilestoneSubmissionData {
20+
files: File[];
21+
externalLinks: string[];
22+
}
23+
24+
interface MilestoneSubmissionModalProps {
25+
open: boolean;
26+
setOpen: (open: boolean) => void;
27+
milestone: {
28+
id: string;
29+
title: string;
30+
description: string;
31+
deliveryDate: string;
32+
fundAmount: number;
33+
status: 'ready' | 'pending' | 'completed' | 'failed';
34+
};
35+
onSubmit: (data: MilestoneSubmissionData) => void;
36+
loading?: boolean;
37+
}
38+
39+
const MilestoneSubmissionModal: React.FC<MilestoneSubmissionModalProps> = ({
40+
open,
41+
setOpen,
42+
milestone,
43+
onSubmit,
44+
loading = false,
45+
}) => {
46+
const [files, setFiles] = useState<File[]>([]);
47+
const [externalLinks, setExternalLinks] = useState<string[]>(['www.']);
48+
const [isExpanded, setIsExpanded] = useState(true);
49+
const [focusedInput, setFocusedInput] = useState<number | null>(null);
50+
const [showSuccess, setShowSuccess] = useState(false);
51+
52+
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
53+
const selectedFiles = Array.from(event.target.files || []);
54+
setFiles(prev => [...prev, ...selectedFiles]);
55+
};
56+
57+
const handleRemoveFile = (index: number) => {
58+
setFiles(prev => prev.filter((_, i) => i !== index));
59+
};
60+
61+
const handleExternalLinkChange = (index: number, value: string) => {
62+
const newLinks = [...externalLinks];
63+
// Ensure the value starts with "www."
64+
if (!value.startsWith('www.')) {
65+
newLinks[index] = 'www.' + value;
66+
} else {
67+
newLinks[index] = value;
68+
}
69+
setExternalLinks(newLinks);
70+
};
71+
72+
const handleSaveLink = () => {
73+
// Add a new field with "www."
74+
setExternalLinks(prev => [...prev, 'www.']);
75+
};
76+
77+
const handleSubmit = () => {
78+
const filteredLinks = externalLinks.filter(link => link.trim() !== '');
79+
onSubmit({
80+
files,
81+
externalLinks: filteredLinks,
82+
});
83+
setShowSuccess(true);
84+
};
85+
86+
const formatDate = (dateString: string) => {
87+
const date = new Date(dateString);
88+
return date.toLocaleDateString('en-US', {
89+
year: 'numeric',
90+
month: 'long',
91+
day: 'numeric',
92+
});
93+
};
94+
95+
const formatCurrency = (amount: number) => {
96+
return new Intl.NumberFormat('en-US', {
97+
style: 'currency',
98+
currency: 'USD',
99+
}).format(amount);
100+
};
101+
102+
return (
103+
<BoundlessSheet open={open} setOpen={setOpen} size='large'>
104+
{showSuccess ? (
105+
<MilestoneSubmissionSuccess
106+
onContinue={() => {
107+
setShowSuccess(false);
108+
setOpen(false);
109+
}}
110+
/>
111+
) : (
112+
<div className='space-y-6'>
113+
{/* Milestone Header */}
114+
<div className='flex justify-center'>
115+
<div className='text-left' style={{ width: '500px' }}>
116+
<h2 className='text-xl font-semibold text-white mb-2'>
117+
Milestone Summary
118+
</h2>
119+
<p className='text-sm text-gray-400'>Milestone 1</p>
120+
</div>
121+
</div>
122+
123+
{/* Milestone Card - Centered */}
124+
<div className='flex justify-center'>
125+
<div
126+
className='bg-[#1C1C1C] border border-[#2B2B2B] rounded-[12px]'
127+
style={{
128+
width: '500px',
129+
height: '210px',
130+
padding: '16px',
131+
gap: '12px',
132+
}}
133+
>
134+
<div className='flex items-start justify-between mb-3'>
135+
<h3 className='text-white font-semibold text-base'>
136+
{milestone.title}
137+
</h3>
138+
<div className='flex items-center gap-2'>
139+
<span className='px-2 py-1 rounded-full text-xs font-medium bg-[#012657] text-white'>
140+
{milestone.status.charAt(0).toUpperCase() +
141+
milestone.status.slice(1)}
142+
</span>
143+
<button
144+
onClick={() => setIsExpanded(!isExpanded)}
145+
className='text-white hover:text-gray-300 transition-colors'
146+
>
147+
<ChevronUp
148+
className={cn(
149+
'w-4 h-4 transition-transform',
150+
!isExpanded && 'rotate-180'
151+
)}
152+
/>
153+
</button>
154+
</div>
155+
</div>
156+
157+
{isExpanded && (
158+
<>
159+
<p className='text-gray-300 text-sm mb-4 leading-relaxed'>
160+
{milestone.description}
161+
</p>
162+
163+
<div className='flex items-center gap-6 text-sm text-gray-400'>
164+
<div className='flex items-center gap-2'>
165+
<Calendar className='w-4 h-4' />
166+
<span>1 {formatDate(milestone.deliveryDate)}</span>
167+
</div>
168+
<div className='flex items-center gap-2'>
169+
<Coins className='w-4 h-4' />
170+
<span>{formatCurrency(milestone.fundAmount)}</span>
171+
</div>
172+
</div>
173+
</>
174+
)}
175+
</div>
176+
</div>
177+
178+
{/* File Upload Section */}
179+
<div className='flex justify-center'>
180+
<div className='space-y-3' style={{ width: '500px' }}>
181+
<h3 className='text-white font-medium text-lg'>
182+
Upload Proof of Completion
183+
</h3>
184+
185+
<div className='text-left'>
186+
<Label className='text-white font-medium'>
187+
File Upload <span className='text-red-500'>*</span>
188+
</Label>
189+
</div>
190+
191+
<div className='border-2 border-dashed border-[#2B2B2B] rounded-xl p-6 bg-[#1C1C1C] flex items-center justify-between'>
192+
<div className='flex items-center gap-4'>
193+
<div
194+
className='rounded-full flex items-center justify-center'
195+
style={{
196+
width: '48px',
197+
height: '48px',
198+
backgroundColor: '#FFFFFF',
199+
opacity: 1,
200+
}}
201+
>
202+
<CloudUpload className='w-6 h-6 text-[#1C1C1C]' />
203+
</div>
204+
205+
<div className='text-left'>
206+
<p className='text-white font-medium'>Upload your files</p>
207+
<p className='text-gray-400 text-sm'>
208+
JPEG, PNG, PDF, Docs • Max. 20MB
209+
</p>
210+
</div>
211+
</div>
212+
213+
<BoundlessButton
214+
variant='default'
215+
onClick={() =>
216+
document.getElementById('file-upload')?.click()
217+
}
218+
>
219+
Upload
220+
</BoundlessButton>
221+
222+
<input
223+
id='file-upload'
224+
type='file'
225+
multiple
226+
accept='.jpg,.jpeg,.png,.pdf,.doc,.docx'
227+
onChange={handleFileUpload}
228+
className='hidden'
229+
/>
230+
</div>
231+
232+
{/* Uploaded Files Section */}
233+
{files.length > 0 && (
234+
<div className='space-y-3'>
235+
{/* Header with count */}
236+
<div className='flex items-center gap-2'>
237+
<h3 className='text-white font-medium'>Uploaded Files</h3>
238+
<div className='w-6 h-6 rounded-full bg-[#1C1C1C] border border-white flex items-center justify-center'>
239+
<span className='text-white text-xs font-medium'>
240+
{files.length}
241+
</span>
242+
</div>
243+
</div>
244+
245+
{/* Files List */}
246+
<div className='space-y-2'>
247+
{files.map((file, index) => (
248+
<div
249+
key={index}
250+
className='flex items-center justify-between bg-[#1C1C1C] border border-[#2B2B2B] rounded-lg p-3'
251+
>
252+
<div className='flex items-center gap-3'>
253+
{/* Green check circle */}
254+
<div className='w-6 h-6 rounded-full bg-green-500 flex items-center justify-center'>
255+
<Check className='w-3 h-3 text-white' />
256+
</div>
257+
258+
{/* File info */}
259+
<div className='flex flex-col'>
260+
<span className='text-white text-sm font-medium'>
261+
{file.name}
262+
</span>
263+
<div className='flex items-center gap-2'>
264+
<span className='text-green-400 text-xs'>
265+
Upload complete
266+
</span>
267+
<span className='text-gray-400 text-xs'></span>
268+
<span className='text-white text-xs'>
269+
{(file.size / (1024 * 1024)).toFixed(1)}MB
270+
</span>
271+
</div>
272+
</div>
273+
</div>
274+
275+
{/* Red trash button */}
276+
<button
277+
onClick={() => handleRemoveFile(index)}
278+
className='w-8 h-8 rounded-lg bg-red-500 hover:bg-red-600 flex items-center justify-center transition-colors'
279+
>
280+
<Trash2 className='w-4 h-4 text-white' />
281+
</button>
282+
</div>
283+
))}
284+
</div>
285+
</div>
286+
)}
287+
</div>
288+
</div>
289+
290+
{/* External Links Section */}
291+
<div className='flex justify-center'>
292+
<div className='space-y-3' style={{ width: '500px' }}>
293+
<Label className='text-white font-medium'>
294+
Links to External Evidence
295+
</Label>
296+
297+
{externalLinks.map((link, index) => (
298+
<div key={index} className='flex items-center gap-2'>
299+
<div className='flex-1 relative'>
300+
<LinkIcon className='absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400' />
301+
<input
302+
type='url'
303+
placeholder=''
304+
value={link}
305+
onChange={e =>
306+
handleExternalLinkChange(index, e.target.value)
307+
}
308+
onFocus={() => setFocusedInput(index)}
309+
onBlur={() => setFocusedInput(null)}
310+
style={{
311+
width: '500px',
312+
height: '48px',
313+
borderRadius: '12px',
314+
borderWidth: '1px',
315+
borderStyle: 'solid',
316+
borderColor:
317+
focusedInput === index ? '#A7F950' : '#2B2B2B',
318+
backgroundColor: '#1C1C1C',
319+
color: '#FFFFFF',
320+
paddingLeft: '40px',
321+
paddingRight:
322+
link.trim() && link !== 'www.' ? '80px' : '16px',
323+
paddingTop: '16px',
324+
paddingBottom: '16px',
325+
gap: '12px',
326+
opacity: 1,
327+
outline: 'none',
328+
transition: 'border-color 0.2s ease',
329+
}}
330+
className='placeholder:text-gray-400'
331+
/>
332+
333+
{/* Save button - only show when there's more text than just "www." */}
334+
{link.trim() && link !== 'www.' && (
335+
<BoundlessButton
336+
className='absolute right-2 top-1/2 transform -translate-y-1/2 w-[65px] h-[36px] rounded-[10px] border-[0.3px] border-[#A7F950] bg-[#A7F950] text-black text-[14px] font-[500] transition-colors'
337+
style={{
338+
backgroundImage:
339+
'linear-gradient(314.7deg, rgba(147, 229, 60, 0.14) 3.33%, rgba(117, 199, 30, 0) 21.54%, rgba(107, 185, 20, 0.14) 87.82%)',
340+
}}
341+
onClick={handleSaveLink}
342+
>
343+
Save
344+
</BoundlessButton>
345+
)}
346+
</div>
347+
</div>
348+
))}
349+
</div>
350+
</div>
351+
352+
{/* Submit Button */}
353+
<div className='flex justify-center'>
354+
<BoundlessButton
355+
onClick={handleSubmit}
356+
disabled={files.length === 0 || loading}
357+
className={cn(
358+
'w-[500px] h-[40px] rounded-[10px] border-[1.4px] text-[14px] font-[500] transition-all duration-200',
359+
files.length === 0 || loading
360+
? 'border-[#2B2B2B] bg-[#1C1C1C] text-white opacity-50 cursor-not-allowed'
361+
: 'border-[#A7F950] bg-[#A7F950] text-black hover:bg-[#8BE03A]'
362+
)}
363+
style={{
364+
backgroundImage:
365+
files.length === 0 || loading
366+
? 'none'
367+
: 'linear-gradient(314.7deg, rgba(147, 229, 60, 0.14) 3.33%, rgba(117, 199, 30, 0) 21.54%, rgba(107, 185, 20, 0.14) 87.82%)',
368+
}}
369+
>
370+
{loading ? 'Submitting...' : 'Submit'}
371+
</BoundlessButton>
372+
</div>
373+
</div>
374+
)}
375+
</BoundlessSheet>
376+
);
377+
};
378+
379+
export default MilestoneSubmissionModal;

0 commit comments

Comments
 (0)