Skip to content

Commit 13819fc

Browse files
committed
fix: improve grantee report submission feature - Fix TypeScript and ESLint issues, add error handling, improve email template and validation, fix bot token usage
1 parent 8171fb9 commit 13819fc

9 files changed

Lines changed: 461 additions & 241 deletions

File tree

components/ErrorBoundary.tsx

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import React from 'react'
2+
3+
interface Props {
4+
children: React.ReactNode
5+
}
6+
7+
interface State {
8+
hasError: boolean
9+
error?: Error
10+
}
11+
12+
class ErrorBoundary extends React.Component<Props, State> {
13+
constructor(props: Props) {
14+
super(props)
15+
this.state = { hasError: false }
16+
}
17+
18+
static getDerivedStateFromError(error: Error): State {
19+
return { hasError: true, error }
20+
}
21+
22+
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
23+
console.error('Error caught by boundary:', error, errorInfo)
24+
}
25+
26+
render() {
27+
if (this.state.hasError) {
28+
return (
29+
<div className="min-h-screen bg-white px-4 py-16 dark:bg-gray-900 sm:px-6 sm:py-24 md:grid md:place-items-center lg:px-8">
30+
<div className="mx-auto max-w-max">
31+
<main className="sm:flex">
32+
<p className="text-4xl font-bold tracking-tight text-orange-500 sm:text-5xl">
33+
Oops!
34+
</p>
35+
<div className="sm:ml-6">
36+
<div className="dark:border-gray-700 sm:border-l sm:border-gray-200 sm:pl-6">
37+
<h1 className="text-4xl font-bold tracking-tight text-gray-900 dark:text-white sm:text-5xl">
38+
Something went wrong
39+
</h1>
40+
<p className="mt-1 text-base text-gray-500 dark:text-gray-400">
41+
{this.state.error?.message ||
42+
'An unexpected error occurred'}
43+
</p>
44+
<div className="mt-6 flex space-x-3 sm:border-l sm:border-transparent sm:pl-6">
45+
<button
46+
onClick={() => window.location.reload()}
47+
className="inline-flex items-center rounded-md border border-transparent bg-orange-500 px-4 py-2 text-sm font-medium text-white shadow-sm hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2"
48+
>
49+
Try again
50+
</button>
51+
<button
52+
onClick={() => (window.location.href = '/')}
53+
className="inline-flex items-center rounded-md border border-transparent bg-gray-100 px-4 py-2 text-sm font-medium text-gray-700 shadow-sm hover:bg-gray-200 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
54+
>
55+
Go back home
56+
</button>
57+
</div>
58+
</div>
59+
</div>
60+
</main>
61+
</div>
62+
</div>
63+
)
64+
}
65+
66+
return this.props.children
67+
}
68+
}
69+
70+
export default ErrorBoundary

components/FormButton.tsx

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,63 @@
1-
function FormButton({ variant, children, ...rest }) {
1+
interface FormButtonProps
2+
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
3+
variant?: 'enabled' | 'disabled' | 'primary'
4+
loading?: boolean
5+
children?: React.ReactNode
6+
}
7+
8+
function FormButton({
9+
variant = 'primary',
10+
loading = false,
11+
children,
12+
className = '',
13+
...rest
14+
}: FormButtonProps) {
215
const defaultVariant =
3-
'bg-orange-500 hover:bg-orange-700 text-xl text-white font-bold py-2 px-4 rounded'
16+
'inline-flex justify-center items-center bg-orange-500 hover:bg-orange-600 text-white font-bold py-2 px-4 rounded-md transition-colors duration-200 ease-in-out border-0'
417
const buttonVariants = {
518
enabled: defaultVariant,
6-
disabled: `${defaultVariant} opacity-50 cursor-not-allowed`,
19+
disabled: `${defaultVariant} cursor-not-allowed opacity-50`,
20+
primary: defaultVariant,
721
}
822

923
return (
10-
<button className={`${buttonVariants[variant]} ...`} {...rest}>
11-
{children}
24+
<button
25+
className={`${buttonVariants[variant]} ${
26+
loading ? 'cursor-not-allowed opacity-50' : ''
27+
} ${className}`}
28+
disabled={loading}
29+
type="submit"
30+
{...rest}
31+
>
32+
{loading ? (
33+
<span className="flex w-full items-center justify-center">
34+
<svg
35+
className="-ml-1 mr-3 h-5 w-5 animate-spin text-white"
36+
xmlns="http://www.w3.org/2000/svg"
37+
fill="none"
38+
viewBox="0 0 24 24"
39+
>
40+
<circle
41+
className="opacity-25"
42+
cx="12"
43+
cy="12"
44+
r="10"
45+
stroke="currentColor"
46+
strokeWidth="4"
47+
/>
48+
<path
49+
className="opacity-75"
50+
fill="currentColor"
51+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
52+
/>
53+
</svg>
54+
Processing...
55+
</span>
56+
) : (
57+
<span className="flex w-full items-center justify-center">
58+
{children || 'Submit'}
59+
</span>
60+
)}
1261
</button>
1362
)
1463
}

components/GrantReportForm.tsx

Lines changed: 39 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useState, useEffect } from 'react'
22
import { useForm } from 'react-hook-form'
33
import { fetchPostJSON } from '../utils/api-helpers'
4-
import FormButton from '@/components/FormButton'
54
import CustomLink from '@/components/Link'
65
import { useRouter } from 'next/router'
76
import ReportPreview from '@/components/ReportPreview'
@@ -35,7 +34,6 @@ export default function GrantReportForm({
3534
email_hash,
3635
}: GrantReportFormProps) {
3736
const router = useRouter()
38-
const [loading, setLoading] = useState(false)
3937
const [submitted, setSubmitted] = useState(false)
4038
const [error, setError] = useState<string>()
4139
const [showPreview, setShowPreview] = useState(false)
@@ -130,7 +128,7 @@ export default function GrantReportForm({
130128
console.error('Error loading saved form data:', e)
131129
// If there's an error, we just continue with empty form
132130
}
133-
}, [grantDetails.issue_number, setValue])
131+
}, [grantDetails.issue_number, setValue, watchAllFields.report_number])
134132

135133
// Save form data to localStorage whenever it changes
136134
useEffect(() => {
@@ -172,33 +170,29 @@ export default function GrantReportForm({
172170
])
173171

174172
const onSubmit = async (data: GrantReportFormData) => {
175-
setLoading(true)
176173
setError(undefined)
177174

178175
try {
179-
const response = await fetchPostJSON('/api/submit-report', {
176+
const response = await fetchPostJSON('/api/report-bot', {
180177
...data,
181178
issue_number: grantDetails.issue_number,
182179
email_hash,
183180
})
184181

185182
if (response.error) {
186183
setError(response.error)
187-
setLoading(false)
188184
return
189185
}
190186

191187
// Clear all saved form data for this grant after successful submission
192188
clearSavedData()
193189

194190
setSubmitted(true)
195-
setLoading(false)
196191
router.push('/reports/success')
197192
} catch (e) {
198193
setError(
199194
'Error submitting report. Your data has been saved locally. Please try again later.'
200195
)
201-
setLoading(false)
202196
}
203197
}
204198

@@ -479,9 +473,13 @@ export default function GrantReportForm({
479473

480474
{!showPreview ? (
481475
<div className="flex justify-end">
482-
<FormButton type="button" onClick={() => setShowPreview(true)}>
476+
<button
477+
type="button"
478+
onClick={() => setShowPreview(true)}
479+
className="rounded bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
480+
>
483481
Preview Report
484-
</FormButton>
482+
</button>
485483
</div>
486484
) : (
487485
<>
@@ -498,13 +496,7 @@ export default function GrantReportForm({
498496
strokeLinecap="round"
499497
strokeLinejoin="round"
500498
strokeWidth={2}
501-
d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"
502-
/>
503-
<path
504-
strokeLinecap="round"
505-
strokeLinejoin="round"
506-
strokeWidth={2}
507-
d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"
499+
d="M15 12a3 3 0 11-6 0 3 3 0 016 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2h-1V9a1 1 0 00-1-1z"
508500
/>
509501
</svg>
510502
Report Preview
@@ -545,9 +537,12 @@ export default function GrantReportForm({
545537
</svg>
546538
Back to Edit
547539
</button>
548-
<FormButton type="submit" loading={loading}>
540+
<button
541+
type="submit"
542+
className="rounded bg-orange-500 px-4 py-2 text-sm font-medium text-white hover:bg-orange-600 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 dark:bg-orange-600 dark:hover:bg-orange-700"
543+
>
549544
Submit Report
550-
</FormButton>
545+
</button>
551546
</div>
552547
</div>
553548
</>
@@ -634,4 +629,29 @@ export default function GrantReportForm({
634629
</div>
635630
</form>
636631
)
632+
633+
if (recoveredData) {
634+
return (
635+
<div className="mt-4 flex justify-end space-x-2">
636+
<button
637+
type="button"
638+
onClick={() => {
639+
clearSavedData()
640+
reset({
641+
project_name: grantDetails.project_name,
642+
report_number: '',
643+
time_spent: '',
644+
next_quarter: '',
645+
money_usage: '',
646+
help_needed: '',
647+
})
648+
setRecoveredData(false)
649+
}}
650+
className="rounded bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600"
651+
>
652+
Clear Data
653+
</button>
654+
</div>
655+
)
656+
}
637657
}

components/GrantValidationForm.tsx

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,14 @@ import { useState } from 'react'
22
import { useForm } from 'react-hook-form'
33
import { fetchPostJSON } from '../utils/api-helpers'
44
import FormButton from '@/components/FormButton'
5+
import dynamic from 'next/dynamic'
56
import * as EmailValidator from 'email-validator'
67

8+
// Dynamically import the component with SSR disabled
9+
const DynamicFormButton = dynamic(() => Promise.resolve(FormButton), {
10+
ssr: false,
11+
})
12+
713
interface ValidationResult {
814
grant_details: {
915
project_name: string
@@ -137,15 +143,14 @@ export default function GrantValidationForm({
137143
)}
138144
</div>
139145

140-
<div className="flex justify-center">
141-
<FormButton
142-
type="submit"
146+
<div className="mt-8 flex justify-center">
147+
<DynamicFormButton
143148
loading={loading}
144149
variant={loading ? 'disabled' : 'primary'}
145-
className="px-8"
150+
className="text-base shadow-sm"
146151
>
147152
Start Report
148-
</FormButton>
153+
</DynamicFormButton>
149154
</div>
150155

151156
{error && (

lib/iron-session-compat/next.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Custom implementation of iron-session/next using iron-session
2+
import { getIronSession } from 'iron-session'
3+
import {
4+
NextApiHandler,
5+
GetServerSidePropsContext,
6+
GetServerSidePropsResult,
7+
NextApiRequest,
8+
NextApiResponse,
9+
} from 'next'
10+
11+
export interface IronSessionOptions {
12+
password: string
13+
cookieName: string
14+
cookieOptions?: {
15+
secure?: boolean
16+
httpOnly?: boolean
17+
sameSite?: boolean | 'none' | 'strict' | 'lax'
18+
path?: string
19+
domain?: string
20+
maxAge?: number
21+
}
22+
ttl?: number
23+
}
24+
25+
export interface IronSessionData {
26+
[key: string]: unknown
27+
}
28+
29+
export interface IronSession extends IronSessionData {
30+
destroy(): void
31+
save(): Promise<void>
32+
}
33+
34+
export function withIronSessionApiRoute<T extends NextApiHandler>(
35+
handler: (
36+
req: Parameters<T>[0] & { session: IronSession },
37+
res: Parameters<T>[1]
38+
) => ReturnType<T>,
39+
options: IronSessionOptions
40+
): NextApiHandler {
41+
return async function withIronSessionApiRouteWrapper(
42+
req: NextApiRequest & { session?: IronSession },
43+
res: NextApiResponse
44+
) {
45+
const session = await getIronSession(req, res, options)
46+
req.session = session
47+
return handler(req as Parameters<T>[0] & { session: IronSession }, res)
48+
}
49+
}
50+
51+
export function withIronSessionSsr<
52+
P extends { [key: string]: unknown } = { [key: string]: unknown }
53+
>(
54+
handler: (
55+
context: GetServerSidePropsContext & { req: { session: IronSession } }
56+
) => GetServerSidePropsResult<P> | Promise<GetServerSidePropsResult<P>>,
57+
options: IronSessionOptions
58+
): (
59+
context: GetServerSidePropsContext
60+
) => Promise<GetServerSidePropsResult<P>> {
61+
return async function withIronSessionSsrWrapper(context) {
62+
const { req, res } = context
63+
const session = await getIronSession(req, res, options)
64+
;(req as { session?: IronSession }).session = session
65+
return handler(
66+
context as GetServerSidePropsContext & { req: { session: IronSession } }
67+
)
68+
}
69+
}

0 commit comments

Comments
 (0)