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) && (
-
-
-
Photos
-
-
+
)}
@@ -532,10 +591,12 @@ export default function ListingWrite({ initialListing, user, profile }) {
-
-
+
+
{isStub
? "This listing will not contain your contact information. Others can claim it as their own and take it over."
: "This listing will be presented just like any other."}
-
+
)}
@@ -600,6 +661,7 @@ export default function ListingWrite({ initialListing, user, profile }) {
initialButtonText="Delete listing"
dialogTitle="Delete listing"
confirmButtonText="Yes, delete listing"
+ confirmLoadingText="Deleting..."
onSubmit={handleDeleteListing}
>
Are you sure you want to delete your listing? This is irreversible.
diff --git a/src/components/ListingWrite/index.js b/src/components/ListingWrite/index.ts
similarity index 100%
rename from src/components/ListingWrite/index.js
rename to src/components/ListingWrite/index.ts
diff --git a/src/components/ProfileAccountSettings/ProfileAccountSettings.jsx b/src/components/ProfileAccountSettings/ProfileAccountSettings.tsx
similarity index 91%
rename from src/components/ProfileAccountSettings/ProfileAccountSettings.jsx
rename to src/components/ProfileAccountSettings/ProfileAccountSettings.tsx
index a0f238d3..80e2f429 100644
--- a/src/components/ProfileAccountSettings/ProfileAccountSettings.jsx
+++ b/src/components/ProfileAccountSettings/ProfileAccountSettings.tsx
@@ -25,12 +25,11 @@ const List = styled("ul")(({ theme }) => ({
// gap: `calc(${theme.spacing.unit} * 1)`,
}));
-const ListItem = styled("li")(({ theme }) => ({
+const ListItem = styled("li")<{ editing?: boolean }>(({ theme }) => ({
display: "flex",
flexDirection: "row",
borderStyle: "solid",
- borderWidth: "0px",
borderColor: "transparent",
transition: "border-color 25ms linear",
// Assume middle row by default
@@ -75,11 +74,13 @@ const PasswordPreview = styled("p")(({ theme }) => ({
userSelect: "none",
}));
+const InputComponent = Input as React.ComponentType;
+
// New custom hook for managing edit states
function useEditableField(initialState = false) {
const [isEditing, setIsEditing] = useState(initialState);
const [isUpdating, setIsUpdating] = useState(false);
- const [error, setError] = useState(null);
+ const [error, setError] = useState(null);
const [success, setSuccess] = useState(false);
const [lastSentAt, setLastSentAt] = useState(0);
@@ -104,7 +105,20 @@ function useEditableField(initialState = false) {
};
}
-function ProfileAccountSettings({ user, profile }) {
+type ProfileAccountSettingsProps = {
+ user: {
+ email: string;
+ };
+ profile: {
+ first_name?: string;
+ is_newsletter_subscribed?: boolean;
+ };
+};
+
+function ProfileAccountSettings({
+ user,
+ profile,
+}: ProfileAccountSettingsProps) {
// Use our custom hook for each editable field
const firstName = useEditableField();
const email = useEditableField();
@@ -134,7 +148,7 @@ function ProfileAccountSettings({ user, profile }) {
// }
// };
- const handleEmailUpdate = async (formData) => {
+ const handleEmailUpdate = async (formData: FormData) => {
const newEmail = formData.get("email")?.toString();
// Client-side validation for unchanged email
@@ -161,10 +175,10 @@ function ProfileAccountSettings({ user, profile }) {
}
};
- const handleFirstNameUpdate = async (formData) => {
+ const handleFirstNameUpdate = async (formData: FormData) => {
const validation = validateName(formData.get("first_name"));
if (!validation.isValid) {
- firstName.setError(validation.error);
+ firstName.setError(validation.error ?? null);
return;
}
@@ -176,7 +190,7 @@ function ProfileAccountSettings({ user, profile }) {
if (result?.error) {
firstName.setError(result.error);
} else {
- setTempFirstName(validation.value);
+ setTempFirstName(validation.value ?? "");
firstName.setIsEditing(false);
}
} catch (error) {
@@ -191,12 +205,13 @@ function ProfileAccountSettings({ user, profile }) {
firstName.reset();
};
- const handleNewslettePreferenceUpdate = async (formData) => {
+ const handleNewslettePreferenceUpdate = async (formData: FormData) => {
const nextNewsletterPreference =
formData.get("newsletter_preference") === "true";
console.log("Updating newsletter preference to", nextNewsletterPreference);
newsletterPreference.setIsUpdating(true);
+ newsletterPreference.setError(null);
try {
const result = await updateNewsletterPreferenceAction(formData);
@@ -228,7 +243,7 @@ function ProfileAccountSettings({ user, profile }) {