diff --git a/src/app/hooks/use-toast.ts b/src/app/hooks/use-toast.ts new file mode 100644 index 0000000..b18ceb8 --- /dev/null +++ b/src/app/hooks/use-toast.ts @@ -0,0 +1,194 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { + ToastActionElement, + ToastProps, +} from "src/app/shadcn_components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + // ! Side effects ! - This could be extracted into a dismissToast() action, + // but I'll keep it here for simplicity + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx index 0bf4b89..a0001b1 100644 --- a/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx +++ b/src/app/portfolio/[slug]/components/RemoteAssessmentModal.tsx @@ -1,7 +1,7 @@ "use client"; import { Dialog, Transition, Menu } from "@headlessui/react"; -import { useState, Fragment, useEffect, useMemo } from "react"; +import { useState, Fragment, useMemo, useRef } from "react"; import { Input } from "@/app/shadcn_components/ui/input"; import { Button } from "@/app/shadcn_components/ui/button"; import { Float } from "@headlessui-float/react"; @@ -11,9 +11,15 @@ import { useSession } from "next-auth/react"; import { Form, useForm, FormProvider } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import * as z from "zod"; -import { FormField, FormItem, FormLabel, FormControl, FormMessage } from "@/app/shadcn_components/ui/form"; +import { + FormField, + FormItem, + FormLabel, + FormControl, + FormMessage, +} from "@/app/shadcn_components/ui/form"; import { Toast } from "@/app/shadcn_components/ui/toast"; -// import { Form } from "aws-sdk/clients/amplifyuibuilder"; +import { FileKey } from "lucide-react"; type Option = { label: string; @@ -198,7 +204,11 @@ async function generatePresignedUrl({ throw new Error("Failed to generate presigned url"); } - return response.json(); + const data = await response.json(); + + data.fileKey = fileKey; + + return data; } function generateS3Keys(userId: string, portfolioId: string) { @@ -251,26 +261,22 @@ function useCreateRemoteAssessment({ ); const { - mutate: mutateUploadFiles, - isLoading: uploadFilesIsLoading, - isError: uploadFilesIsError, - } = useMutation(async ({ assetList, valuationData }: { assetList: { presignedUrl: string; file: Blob }; valuationData: { presignedUrl: string; file: Blob } }) => { - - // Upload asset list - await uploadCsvToS3({ presignedUrl: assetList.presignedUrl, file: assetList.file }); - - // Upload valuation data - await uploadCsvToS3({ presignedUrl: valuationData.presignedUrl, file: valuationData.file }); - }, { - onSuccess: (data) => { // Callback for successful mutation - console.log("Files uploaded successfully"); - // Trigger the engine here if needed - }, - onError: (error) => { // Callback for failed mutation - console.error("Error uploading files:", error); - }, - }); - + mutate: mutateUploadFile, + isLoading: uploadFileIsLoading, + isError: uploadFileIsError, + } = useMutation(uploadCsvToS3, + { + onSuccess: (data) => { + // Callback for successful mutation + console.log("Files uploaded successfully"); + // Trigger the engine here if needed + }, + onError: (error) => { + // Callback for failed mutation + console.error("Error uploading files:", error); + }, + } + ); const { mutate: mutatePresignedUrl, @@ -278,71 +284,81 @@ function useCreateRemoteAssessment({ isError: presignedUrlIsError, } = useMutation(generatePresignedUrl, { onSuccess: (data) => { - console.log(data.url); - // On success, upload to that URL!!!! - const assetList = [ - { - uprn: uprn, - address: addressLineOne, - postcode: postcode, - }, - ]; - const valuationData = [ - { - uprn: uprn, - valuation: 0, - }, - ]; - const assetListCsvString = convertToCSV(assetList); - const assetListCsv = new Blob([assetListCsvString], { - type: "text/csv", - }); + // console.log(data.url); + // // On success, upload to that URL!!!! - const valuationDataCsvString = convertToCSV(valuationData); - const valuationDataCsv = new Blob([valuationDataCsvString], { - type: "text/csv", - }); + let csvFile: Blob = new Blob(); - mutateUploadFiles({ assetList: { presignedUrl: data.url, file: assetListCsv }, valuationData: { presignedUrl: data.url, file: valuationDataCsv } }); - + if (data.fileKey === assetListFileKey) { + + const assetList = [ + { + uprn: uprn, + address: addressLineOne, + postcode: postcode, + }, + ]; + + csvFile = new Blob([convertToCSV(assetList)], { + type: "text/csv", + }); + + } else if (data.fileKey === valuationDataFileKey) { + + const valuationData = [ + { + uprn: uprn, + valuation: valuation, + }, + ]; + + csvFile = new Blob([convertToCSV(valuationData)], { + type: "text/csv", + }); + } + + mutateUploadFile({ + file: csvFile, + presignedUrl: data.url, + }); }, onError: (error) => { console.error(error); }, - }); - + } + ); async function triggerEngine(data: FormValues) { try { - const triggerBody: EngineTriggerBody ={ - portfolio_id: portfolioId, - housing_type: data.housingType, - goal: data.goal, - goal_value: data.goalValue, - trigger_file_path: assetListFileKey, - already_installed_file_path: "", - patches_file_path: "", - non_invasive_recommendations_file_path: "", - valuation_file_path: valuationDataFileKey, - scenario_name: data.scenario, - multi_plan: true, - budget: null, - event_type: "Remote Assessment" + const triggerBody: EngineTriggerBody = { + portfolio_id: portfolioId, + housing_type: data.housingType, + goal: data.goal, + goal_value: data.goalValue, + trigger_file_path: assetListFileKey, + already_installed_file_path: "", + patches_file_path: "", + non_invasive_recommendations_file_path: "", + valuation_file_path: valuationDataFileKey, + scenario_name: data.scenario, + multi_plan: true, + budget: null, + event_type: "Remote Assessment", }; const response = await fetch("/api/plan/trigger", { method: "POST", headers: { - 'Content-Type': 'application/json', + "Content-Type": "application/json", }, body: JSON.stringify(triggerBody), }); if (!response.ok) { - throw new Error('Failed to trigger engine'); + throw new Error("Failed to trigger engine"); } } catch (error) { - console.error('Error triggering engine:', error); + console.error("Error triggering engine:", error); throw error; } } @@ -350,12 +366,18 @@ function useCreateRemoteAssessment({ async function handleSubmit(formData: FormValues) { try { const [assetListUrl, valuationDataUrl] = await Promise.all([ - mutatePresignedUrl({ userId, portfolioId, fileKey: assetListFileKey }), - mutatePresignedUrl({ userId, portfolioId, fileKey: valuationDataFileKey }) + mutatePresignedUrl({ + userId, + portfolioId, + fileKey: assetListFileKey }), + mutatePresignedUrl({ + userId, + portfolioId, + fileKey: valuationDataFileKey, + }), ]); await triggerEngine(formData); - } catch (error) { console.error("Error in submission process:", error); } @@ -364,14 +386,14 @@ function useCreateRemoteAssessment({ return { handleSubmit, triggerEngine, + mutateUploadFile, presignedUrlIsLoading, presignedUrlIsError, - uploadFilesIsLoading, - uploadFilesIsError, + uploadFileIsLoading, + uploadFileIsError, }; } - export default function RemoteAssessmentModal({ portfolioId, isOpen, @@ -381,7 +403,6 @@ export default function RemoteAssessmentModal({ setIsOpen: (isOpen: boolean) => void; portfolioId: string; }) { - const form = useForm({ resolver: zodResolver(formSchema), defaultValues: { @@ -396,13 +417,14 @@ export default function RemoteAssessmentModal({ }, }); + const onSubmit = async (data: FormValues) => { try { await handleSubmit(data); form.reset(); setIsOpen(false); } catch (error) { - console.error('Error submitting form:', error); + console.error("Error submitting form:", error); } }; @@ -447,169 +469,184 @@ export default function RemoteAssessmentModal({ leaveTo="opacity-0 scale-95" > - - Remote Assessment Details - - -
- ( - - Scenario Name - - - - - - )} - /> + + Remote Assessment Details + + + + ( + + Scenario Name + + + + + + )} + /> - ( - - Housing Type - - field.onChange(option.value)} - /> - - - - )} - /> + ( + + Housing Type + + + field.onChange(option.value) + } + /> + + + + )} + /> - ( - - Goal - - field.onChange(option.value)} - /> - - - - )} - /> + ( + + Goal + + + field.onChange(option.value) + } + /> + + + + )} + /> - ( - - Goal Value - - field.onChange(option.value)} - /> - - - - )} - /> + ( + + Goal Value + + + field.onChange(option.value) + } + /> + + + + )} + /> - ( - - Address - - - - - - )} - /> + ( + + Address + + + + + + )} + /> - ( - - Postcode - - - - - - )} - /> + ( + + Postcode + + + + + + )} + /> - ( - - UPRN - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> + ( + + UPRN + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> - ( - - Valuation - - field.onChange(Number(e.target.value))} - /> - - - - )} - /> + ( + + Valuation + + + field.onChange(Number(e.target.value)) + } + /> + + + + )} + /> -
- - -
- {presignedUrlIsError && ( -

Error uploading files

- )} - -
-
+
+ + +
+ {presignedUrlIsError && ( +

+ Error uploading files +

+ )} + + + @@ -621,4 +658,3 @@ export default function RemoteAssessmentModal({ function setIsOpen(arg0: boolean) { throw new Error("Function not implemented."); } - diff --git a/src/app/shadcn_components/ui/toast.tsx b/src/app/shadcn_components/ui/toast.tsx new file mode 100644 index 0000000..84a7326 --- /dev/null +++ b/src/app/shadcn_components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "s/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/src/app/shadcn_components/ui/toaster.tsx b/src/app/shadcn_components/ui/toaster.tsx new file mode 100644 index 0000000..04165e3 --- /dev/null +++ b/src/app/shadcn_components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { useToast } from "src/app/shadcn_components/hooks/use-toast" +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "src/app/shadcn_components/ui/toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +}