From 785fd5885d700b5ff69bf6dccff7d5d33a932be9 Mon Sep 17 00:00:00 2001 From: Danny White <3104761+dnywh@users.noreply.github.com> Date: Sun, 12 Apr 2026 13:08:03 +1000 Subject: [PATCH 1/5] add loading states and refactor to TSX --- src/app/(forms)/forgot-password/page.tsx | 5 +- .../(forms)/profile/reset-password/page.tsx | 5 +- ...tarUploadView.jsx => AvatarUploadView.tsx} | 61 ++++- .../AvatarUploadView/{index.js => index.ts} | 0 .../Button/{Button.jsx => Button.tsx} | 140 +++++++---- src/components/Button/{index.js => index.ts} | 0 ...{ButtonToDialog.jsx => ButtonToDialog.tsx} | 42 +++- .../ButtonToDialog/{index.js => index.ts} | 0 .../{ChatComposer.jsx => ChatComposer.tsx} | 28 ++- .../ChatComposer/{index.js => index.ts} | 0 .../{ChatWindow.jsx => ChatWindow.tsx} | 76 ++++-- .../ChatWindow/{index.js => index.ts} | 0 .../{EmailSelector.jsx => EmailSelector.tsx} | 59 +++-- .../EmailSelector/{index.js => index.ts} | 0 src/components/IconButton/IconButton.jsx | 70 ------ src/components/IconButton/IconButton.tsx | 159 ++++++++++++ .../IconButton/{index.js => index.ts} | 0 ...osManager.jsx => ListingPhotosManager.tsx} | 79 ++++-- .../{index.js => index.ts} | 0 .../{ListingWrite.jsx => ListingWrite.tsx} | 238 +++++++++++------- .../ListingWrite/{index.js => index.ts} | 0 ...ettings.jsx => ProfileAccountSettings.tsx} | 44 +++- .../{index.js => index.ts} | 0 ...{ProfileActions.jsx => ProfileActions.tsx} | 31 ++- .../ProfileActions/{index.js => index.ts} | 0 src/components/SubmitButton/SubmitButton.jsx | 20 -- src/components/SubmitButton/SubmitButton.tsx | 34 +++ .../SubmitButton/{index.js => index.ts} | 0 28 files changed, 772 insertions(+), 319 deletions(-) rename src/components/AvatarUploadView/{AvatarUploadView.jsx => AvatarUploadView.tsx} (69%) rename src/components/AvatarUploadView/{index.js => index.ts} (100%) rename src/components/Button/{Button.jsx => Button.tsx} (66%) rename src/components/Button/{index.js => index.ts} (100%) rename src/components/ButtonToDialog/{ButtonToDialog.jsx => ButtonToDialog.tsx} (67%) rename src/components/ButtonToDialog/{index.js => index.ts} (100%) rename src/components/ChatComposer/{ChatComposer.jsx => ChatComposer.tsx} (70%) rename src/components/ChatComposer/{index.js => index.ts} (100%) rename src/components/ChatWindow/{ChatWindow.jsx => ChatWindow.tsx} (83%) rename src/components/ChatWindow/{index.js => index.ts} (100%) rename src/components/EmailSelector/{EmailSelector.jsx => EmailSelector.tsx} (71%) rename src/components/EmailSelector/{index.js => index.ts} (100%) delete mode 100644 src/components/IconButton/IconButton.jsx create mode 100644 src/components/IconButton/IconButton.tsx rename src/components/IconButton/{index.js => index.ts} (100%) rename src/components/ListingPhotosManager/{ListingPhotosManager.jsx => ListingPhotosManager.tsx} (80%) rename src/components/ListingPhotosManager/{index.js => index.ts} (100%) rename src/components/ListingWrite/{ListingWrite.jsx => ListingWrite.tsx} (73%) rename src/components/ListingWrite/{index.js => index.ts} (100%) rename src/components/ProfileAccountSettings/{ProfileAccountSettings.jsx => ProfileAccountSettings.tsx} (91%) rename src/components/ProfileAccountSettings/{index.js => index.ts} (100%) rename src/components/ProfileActions/{ProfileActions.jsx => ProfileActions.tsx} (80%) rename src/components/ProfileActions/{index.js => index.ts} (100%) delete mode 100644 src/components/SubmitButton/SubmitButton.jsx create mode 100644 src/components/SubmitButton/SubmitButton.tsx rename src/components/SubmitButton/{index.js => index.ts} (100%) diff --git a/src/app/(forms)/forgot-password/page.tsx b/src/app/(forms)/forgot-password/page.tsx index 5624990c..6e2b0b17 100644 --- a/src/app/(forms)/forgot-password/page.tsx +++ b/src/app/(forms)/forgot-password/page.tsx @@ -50,7 +50,10 @@ export default async function ForgotPassword(props: { {searchParams.error && ( )} - + Email me the link diff --git a/src/app/(forms)/profile/reset-password/page.tsx b/src/app/(forms)/profile/reset-password/page.tsx index 5c002b70..d782d9fc 100644 --- a/src/app/(forms)/profile/reset-password/page.tsx +++ b/src/app/(forms)/profile/reset-password/page.tsx @@ -67,7 +67,10 @@ export default async function ResetPassword(props: { {searchParams.error && ( )} - + Reset password diff --git a/src/components/AvatarUploadView/AvatarUploadView.jsx b/src/components/AvatarUploadView/AvatarUploadView.tsx similarity index 69% rename from src/components/AvatarUploadView/AvatarUploadView.jsx rename to src/components/AvatarUploadView/AvatarUploadView.tsx index 9b1d9e85..3a95ad2d 100644 --- a/src/components/AvatarUploadView/AvatarUploadView.jsx +++ b/src/components/AvatarUploadView/AvatarUploadView.tsx @@ -30,7 +30,6 @@ const LoadingSpinner = styled("div")(({ theme }) => ({ display: "flex", justifyContent: "center", alignItems: "center", - color: "white", fontSize: "20px", position: "absolute", @@ -42,6 +41,20 @@ const LoadingSpinner = styled("div")(({ theme }) => ({ borderRadius: theme.corners.avatar, })); +const AvatarComponent = Avatar as React.ComponentType; + +type AvatarUploadViewProps = { + avatar?: string; + onChange: ( + event: React.ChangeEvent + ) => void | Promise; + onDelete: () => void | Promise; + getAvatarUrl?: (filename: string) => string; + bucket: string; + inputHintShown?: boolean; + listingType?: string; +}; + function AvatarUploadView({ avatar, onChange, @@ -50,19 +63,36 @@ function AvatarUploadView({ bucket, inputHintShown = false, listingType, -}) { +}: AvatarUploadViewProps) { // Hidden file input that we'll trigger programmatically - const fileInputRef = useRef(null); + const fileInputRef = useRef(null); const [loading, setLoading] = useState(false); + const [isDeleting, setIsDeleting] = useState(false); + const isBusy = loading || isDeleting; const handleFileSelect = () => { + if (isBusy) return; fileInputRef.current?.click(); }; - const handleUpload = async (event) => { + const handleUpload = async (event: React.ChangeEvent) => { setLoading(true); - await onChange(event); - setLoading(false); + try { + await onChange(event); + } finally { + setLoading(false); + } + }; + + const handleDelete = async () => { + if (isBusy) return; + + setIsDeleting(true); + try { + await onDelete(); + } finally { + setIsDeleting(false); + } }; return ( @@ -75,11 +105,12 @@ function AvatarUploadView({ accept="image/*" multiple={false} onChange={handleUpload} + disabled={isBusy} style={{ display: "none" }} /> - Add @@ -106,6 +140,9 @@ function AvatarUploadView({ as={AvatarButton} variant="secondary" size="small" + loading={loading || isDeleting} + loadingText={loading ? "Uploading..." : "Deleting..."} + disabled={isBusy} > Edit @@ -119,12 +156,20 @@ function AvatarUploadView({ onClick={handleFileSelect} variant="secondary" size="small" + disabled={isBusy} > Replace - diff --git a/src/components/AvatarUploadView/index.js b/src/components/AvatarUploadView/index.ts similarity index 100% rename from src/components/AvatarUploadView/index.js rename to src/components/AvatarUploadView/index.ts diff --git a/src/components/Button/Button.jsx b/src/components/Button/Button.tsx similarity index 66% rename from src/components/Button/Button.jsx rename to src/components/Button/Button.tsx index d05ca5e7..1619e668 100644 --- a/src/components/Button/Button.jsx +++ b/src/components/Button/Button.tsx @@ -1,8 +1,42 @@ import { Button as UnstyledButton } from "@headlessui/react"; import Link from "next/link"; import { styled } from "@pigment-css/react"; +import { forwardRef, type ElementType, type ReactNode, type Ref } from "react"; -const buttonStyles = ({ theme }) => ({ +export type ButtonVariant = + | "primary" + | "secondary" + | "danger" + | "send" + | "subtle"; +export type ButtonSize = "massive" | "large" | "normal" | "small" | "icon"; +export type ButtonWidth = "contained" | "full"; + +type ButtonStyleProps = { + variant?: ButtonVariant; + size?: ButtonSize; + width?: ButtonWidth; + disabled?: boolean; +}; + +export type ButtonProps = ButtonStyleProps & + Omit< + React.ButtonHTMLAttributes, + keyof ButtonStyleProps | "children" | "onClick" + > & + Omit< + React.AnchorHTMLAttributes, + keyof ButtonStyleProps | "children" | "onClick" | "href" + > & { + as?: ElementType; + children?: ReactNode; + href?: string; + loading?: boolean; + loadingText?: string; + onClick?: React.MouseEventHandler; + }; + +const buttonStyles = ({ theme }: { theme: any }): any => ({ // Resets border: "none", appearance: "none", @@ -163,54 +197,64 @@ const buttonStyles = ({ theme }) => ({ ], }); -const StyledButton = styled(UnstyledButton)(buttonStyles); -const StyledLink = styled(Link)(buttonStyles); +const StyledButton = styled(UnstyledButton)(buttonStyles); +const StyledLink = styled(Link)(buttonStyles); -/** - * @param {object} props - * @param {string} [props.variant] - * @param {boolean} [props.disabled] - * @param {string} [props.href] - If provided, renders as Link. Otherwise renders as Button. - * @param {React.ReactNode} props.children - * @param {number} [props.tabIndex] - * @param {boolean} [props.loading] - * @param {string} [props.loadingText] - * @param {string} [props.type] - * @param {string} [props.size] - */ -export default function Button({ - variant = "secondary", - disabled = false, - href = undefined, - children, - tabIndex = 0, - loading = false, - loadingText = "Loading...", - type = "button", - size = "normal", - ...props -}) { - const isDisabled = disabled || loading; +const Button = forwardRef( + function Button( + { + variant = "secondary", + disabled = false, + href = undefined, + children, + tabIndex = 0, + loading = false, + loadingText = "Loading...", + type = "button", + size = "normal", + ...props + }, + ref + ) { + const isLink = Boolean(href); + const isLoading = loading && !isLink; + const isDisabled = disabled || isLoading; + const buttonContent = {isLoading ? loadingText : children}; - const sharedProps = { - disabled: isDisabled, - variant, - tabIndex: isDisabled ? -1 : tabIndex, - "aria-disabled": isDisabled, - size, - ...props, - }; + if (href) { + return ( + } + variant={variant} + tabIndex={isDisabled ? -1 : tabIndex} + aria-disabled={isDisabled || undefined} + aria-busy={isLoading || undefined} + size={size} + disabled={isDisabled} + {...props} + > + {buttonContent} + + ); + } - const buttonContent = {loading ? loadingText : children}; + return ( + } + disabled={isDisabled} + variant={variant} + tabIndex={isDisabled ? -1 : tabIndex} + aria-disabled={isDisabled || undefined} + aria-busy={isLoading || undefined} + size={size} + {...props} + > + {buttonContent} + + ); + } +); - // Render either a button or a link based on the presence of href - return href ? ( - - {buttonContent} - - ) : ( - - {buttonContent} - - ); -} +export default Button; diff --git a/src/components/Button/index.js b/src/components/Button/index.ts similarity index 100% rename from src/components/Button/index.js rename to src/components/Button/index.ts diff --git a/src/components/ButtonToDialog/ButtonToDialog.jsx b/src/components/ButtonToDialog/ButtonToDialog.tsx similarity index 67% rename from src/components/ButtonToDialog/ButtonToDialog.jsx rename to src/components/ButtonToDialog/ButtonToDialog.tsx index c09377d8..d56b8aa2 100644 --- a/src/components/ButtonToDialog/ButtonToDialog.jsx +++ b/src/components/ButtonToDialog/ButtonToDialog.tsx @@ -5,6 +5,7 @@ import Button from "@/components/Button"; import SubmitButton from "@/components/SubmitButton"; import { styled } from "@pigment-css/react"; +import { useState, type ReactNode } from "react"; const DialogContent = styled(Dialog.Content)(({ theme }) => ({ background: "white", @@ -44,6 +45,19 @@ const DialogOverlay = styled(Dialog.Overlay)({ zIndex: 3, // Stop map controls, AvatarButton, etc from showing above overlay and dialog }); +type ButtonToDialogProps = { + variant?: "primary" | "secondary" | "danger"; + size?: "massive" | "large" | "normal" | "small"; + initialButtonText: ReactNode; + dialogTitle: ReactNode; + children: ReactNode; + confirmButtonText?: ReactNode; + confirmLoadingText?: string; + cancelButtonText?: ReactNode; + action?: React.FormHTMLAttributes["action"]; + onSubmit?: React.FormEventHandler; +}; + function ButtonToDialog({ variant = "danger", size, @@ -51,11 +65,33 @@ function ButtonToDialog({ dialogTitle, children, confirmButtonText, + confirmLoadingText, cancelButtonText = "No, cancel", action, onSubmit, // ...props // Setting this on Button (for size etc) seems to stop the dialog from opening -}) { +}: ButtonToDialogProps) { + const [isSubmitting, setIsSubmitting] = useState(false); + const resolvedConfirmLoadingText = + confirmLoadingText || (variant === "danger" ? "Deleting..." : "Working..."); + + const handleSubmit: React.FormEventHandler | undefined = + onSubmit + ? async (event) => { + if (isSubmitting) { + event.preventDefault(); + return; + } + + setIsSubmitting(true); + try { + await onSubmit(event); + } finally { + setIsSubmitting(false); + } + } + : undefined; + return ( @@ -71,10 +107,12 @@ function ButtonToDialog({ {(action || onSubmit) && ( -
+ diff --git a/src/components/ButtonToDialog/index.js b/src/components/ButtonToDialog/index.ts similarity index 100% rename from src/components/ButtonToDialog/index.js rename to src/components/ButtonToDialog/index.ts diff --git a/src/components/ChatComposer/ChatComposer.jsx b/src/components/ChatComposer/ChatComposer.tsx similarity index 70% rename from src/components/ChatComposer/ChatComposer.jsx rename to src/components/ChatComposer/ChatComposer.tsx index e72372dd..7a05fc30 100644 --- a/src/components/ChatComposer/ChatComposer.jsx +++ b/src/components/ChatComposer/ChatComposer.tsx @@ -1,5 +1,4 @@ import Textarea from "@/components/Textarea"; -import SubmitButton from "@/components/SubmitButton"; import IconButton from "@/components/IconButton"; import { styled } from "@pigment-css/react"; import FormMessage from "@/components/FormMessage"; @@ -27,6 +26,18 @@ const StyledIconButton = styled(IconButton)(({ theme }) => ({ marginBottom: "0.6rem", // TODO: Make dynamic. Since we're aligning items to the bottom, we need to manually set this to appear horizontally centered })); +const TextareaComponent = Textarea as React.ComponentType; + +type ChatComposerProps = { + onSubmit: React.FormEventHandler; + message: string; + handleMessageChange: React.ChangeEventHandler; + error?: string | null; + recipientName?: string; + isDemo?: boolean; + isSending?: boolean; +}; + function ChatComposer({ onSubmit, message, @@ -34,26 +45,31 @@ function ChatComposer({ error, recipientName, isDemo, -}) { + isSending = false, +}: ChatComposerProps) { + const isSendDisabled = !message.trim() || (!isDemo && isSending); + return ( {error && } -