diff --git a/messages/de.json b/messages/de.json index 76ebe393..b8202eb2 100644 --- a/messages/de.json +++ b/messages/de.json @@ -111,5 +111,27 @@ "answer": "

We only send emails that are necessary to keep Peels working. That includes one or two account-related emails, and emails to notify you whenever a fellow Peels member has sent you message.

Email notifications are required for listing hosts, as it means a prospective donor has enquired about dropping off scraps (or picking something up). Hosts who longer wish to receive emails can either hide their listing from the map (making it impossible for new donors to reach out) or delete their listing entirely (meaning previous donors can no longer message, either).

People without listings cannot be messaged (and thus emailed) unless they initiate contact with a host first.

Anyone can report or block individual Peels members via our messaging system. Blocking someone means they can no longer message or email you.

" } } + }, + "Contact": { + "title": "Kontakt", + "subtitle": "So erreichst du das Peels-Team.", + "via": { + "therot": "Hallo, Leser von The Rot. Danke fürs Vorbeischauen. Hier ist ein direkter Draht zu Danny.", + "general": "Hallo. Hier sind einige E-Mail-Adressen, unter denen du uns erreichen kannst." + }, + "contactLabel": "Wenn du", + "contactOptions": { + "general": "Eine allgemeine Anfrage stellen möchtest", + "support": "Hilfe bei etwas brauchst", + "dw": "Mit Danny sprechen möchtest", + "newsletter": "Über den Newsletter sprechen möchtest" + }, + "emailLabel": "Am besten schreibst du an", + "copyButton": { + "copied": "Kopiert", + "copying": "Wird kopiert...", + "copyFailed": "Kopieren fehlgeschlagen", + "copyAddress": "Adresse kopieren" + } } } diff --git a/messages/en.json b/messages/en.json index 14ae0444..f1bfb350 100644 --- a/messages/en.json +++ b/messages/en.json @@ -129,6 +129,8 @@ "emailLabel": "You’re best off emailing", "copyButton": { "copied": "Copied!", + "copying": "Copying...", + "copyFailed": "Copy failed", "copyAddress": "Copy address" } } diff --git a/messages/es.json b/messages/es.json index eed5de26..1b27ab1e 100644 --- a/messages/es.json +++ b/messages/es.json @@ -111,5 +111,27 @@ "answer": "

We only send emails that are necessary to keep Peels working. That includes one or two account-related emails, and emails to notify you whenever a fellow Peels member has sent you message.

Email notifications are required for listing hosts, as it means a prospective donor has enquired about dropping off scraps (or picking something up). Hosts who longer wish to receive emails can either hide their listing from the map (making it impossible for new donors to reach out) or delete their listing entirely (meaning previous donors can no longer message, either).

People without listings cannot be messaged (and thus emailed) unless they initiate contact with a host first.

Anyone can report or block individual Peels members via our messaging system. Blocking someone means they can no longer message or email you.

" } } + }, + "Contact": { + "title": "Contacto", + "subtitle": "Así puedes contactar con el equipo de Peels.", + "via": { + "therot": "Hola, lector de The Rot. Gracias por pasar por aquí. Esta es una línea directa con Danny.", + "general": "Hola. Aquí tienes algunas direcciones de correo para contactarnos." + }, + "contactLabel": "Si quieres", + "contactOptions": { + "general": "Hacer una consulta general", + "support": "Obtener ayuda con algo", + "dw": "Hablar con Danny", + "newsletter": "Hablar sobre el boletín" + }, + "emailLabel": "Lo mejor es escribir a", + "copyButton": { + "copied": "Copiado", + "copying": "Copiando...", + "copyFailed": "No se pudo copiar", + "copyAddress": "Copiar dirección" + } } } diff --git a/scripts/seed-local-media.mjs b/scripts/seed-local-media.mjs index cffacf41..e06fa9f8 100644 --- a/scripts/seed-local-media.mjs +++ b/scripts/seed-local-media.mjs @@ -79,7 +79,10 @@ function walkFiles(sourceDir, currentDir = sourceDir) { return { absolutePath, - objectPath: path.relative(sourceDir, absolutePath).split(path.sep).join("/"), + objectPath: path + .relative(sourceDir, absolutePath) + .split(path.sep) + .join("/"), }; }); } @@ -105,7 +108,8 @@ async function ensureBucket(supabase, bucketName, bucketConfig) { allowedMimeTypes: bucketConfig.allowedMimeTypes, }; - const { data: buckets, error: listError } = await supabase.storage.listBuckets(); + const { data: buckets, error: listError } = + await supabase.storage.listBuckets(); if (listError) { throw listError; @@ -147,14 +151,12 @@ async function uploadBucketObjects(supabase, bucketName, bucketConfig) { const body = readFileSync(file.absolutePath); const contentType = getContentType(file.absolutePath); - const { error } = await supabase.storage.from(bucketName).upload( - file.objectPath, - body, - { + const { error } = await supabase.storage + .from(bucketName) + .upload(file.objectPath, body, { contentType, upsert: true, - } - ); + }); if (error) { throw new Error( diff --git a/src/app/(forms)/auth/complete/page.tsx b/src/app/(forms)/auth/complete/page.tsx index 6e57e29e..06f6ec11 100644 --- a/src/app/(forms)/auth/complete/page.tsx +++ b/src/app/(forms)/auth/complete/page.tsx @@ -9,8 +9,7 @@ export default function AuthCompletePage() {

- We’re securely confirming your link. You’ll be redirected in a - moment. + We’re securely confirming your link. You’ll be redirected in a moment.

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.jsx deleted file mode 100644 index d05ca5e7..00000000 --- a/src/components/Button/Button.jsx +++ /dev/null @@ -1,216 +0,0 @@ -import { Button as UnstyledButton } from "@headlessui/react"; -import Link from "next/link"; -import { styled } from "@pigment-css/react"; - -const buttonStyles = ({ theme }) => ({ - // Resets - border: "none", - appearance: "none", - // Base styles that both button and link will share - flexShrink: 0, - borderRadius: `calc(${theme.corners.base} * 1)`, - cursor: "pointer", - fontWeight: "500", - display: "inline-flex", // Added to help with alignment - alignItems: "center", // Added to help with alignment - justifyContent: "center", // Added to help with alignment - textDecoration: "none", - padding: `0 calc(${theme.spacing.unit} * 2)`, - transition: - "background 100ms ease-in-out, color 75ms ease-in-out, box-shadow 100ms ease-in-out", - - // Ellipsize text - "& span": { - display: "inline-block", - alignItems: "center", - overflow: "hidden", - whiteSpace: "nowrap", - textOverflow: "ellipsis", - }, - - "&:focus": { - outline: `3px solid ${theme.colors.focus.outline}`, - }, - "&[data-focus]": { - outline: `3px solid ${theme.colors.focus.outline}`, - }, - - variants: [ - { - props: { width: "contained" }, - style: { - alignSelf: "flex-start", - }, - }, - { - props: { width: "full" }, - style: { - width: "100%", - }, - }, - { - props: { size: "massive" }, - style: { - height: "4rem", - fontSize: "1.3rem", - borderRadius: `calc(${theme.corners.base} * 1.25)`, - padding: `0 calc(${theme.spacing.unit} * 4)`, - }, - }, - { - props: { size: "large" }, - style: { - height: "3.5rem", - fontSize: "1.125rem", - }, - }, - { - props: { size: "normal" }, - style: { - height: "3rem", - fontSize: "1.0625rem", // 17px - }, - }, - { - props: { size: "small" }, - style: { - height: "2.25rem", - fontSize: "0.875rem", - padding: `0 calc(${theme.spacing.unit} * 1.5)`, - }, - }, - { - props: { variant: "primary" }, - style: { - background: theme.colors.button.primary.background, - color: theme.colors.button.primary.text, - - "&:not([disabled])": { - boxShadow: `0px 0px 0px 2px ${theme.colors.button.primary.background}`, // Match visual height of sibling buttons with box-shadow - }, - "&:hover&:not([disabled])": { - background: `color-mix(in srgb, ${theme.colors.button.primary.background}, ${theme.colors.button.primary.hover.tint} ${theme.colors.button.primary.hover.mix})`, - boxShadow: `0px 0px 0px 2px color-mix(in srgb, ${theme.colors.button.primary.background}, ${theme.colors.button.primary.hover.tint} ${theme.colors.button.primary.hover.mix})`, - }, - }, - }, - { - props: { variant: "secondary" }, - style: { - background: theme.colors.button.secondary.background, - color: theme.colors.button.secondary.text, - // borderColor: theme.colors.border.base, - boxShadow: `0px 0px 0px 2px ${theme.colors.border.base}`, - "&:hover&:not([disabled])": { - color: `color-mix(in srgb, ${theme.colors.button.secondary.text}, ${theme.colors.button.secondary.hover.tint} ${theme.colors.button.secondary.hover.mix})`, - }, - }, - }, - { - props: { variant: "danger" }, - style: { - background: theme.colors.button.danger.background, - color: theme.colors.button.danger.text, - // borderColor: theme.colors.border.base, - boxShadow: `0px 0px 0px 2px ${theme.colors.border.base}`, - "&:hover&:not([disabled])": { - color: `color-mix(in srgb, ${theme.colors.button.danger.text}, ${theme.colors.button.danger.hover.tint} ${theme.colors.button.danger.hover.mix})`, - }, - }, - }, - { - props: { variant: "send" }, - style: { - backgroundColor: theme.colors.button.send.background, - border: "none", - color: theme.colors.button.send.text, - - "&:hover&:not([disabled])": { - backgroundColor: `color-mix(in srgb, ${theme.colors.button.send.text}, ${theme.colors.button.send.hover.tint} ${theme.colors.button.send.hover.mix})`, - }, - }, - }, - { - // The default style for IconButton - props: { variant: "subtle" }, - style: { - // Assume styles from secondary, mainly so I don't have to put visual styles in size: "icon" (TODO: avoid repetition) - background: theme.colors.button.secondary.background, - color: theme.colors.button.secondary.text, - border: `1px solid ${theme.colors.border.base}`, - "&:hover&:not([disabled])": { - backgroundColor: theme.colors.background.sunk, - }, - }, - }, - { - props: { size: "icon" }, - style: { - width: "2rem", - height: "2rem", - padding: 0, - borderRadius: "50%", - }, - }, - { - props: { disabled: true }, - style: { - cursor: "default", - background: theme.colors.button.disabled.background, - color: theme.colors.button.disabled.text, - }, - }, - ], -}); - -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 sharedProps = { - disabled: isDisabled, - variant, - tabIndex: isDisabled ? -1 : tabIndex, - "aria-disabled": isDisabled, - size, - ...props, - }; - - const buttonContent = {loading ? loadingText : children}; - - // Render either a button or a link based on the presence of href - return href ? ( - - {buttonContent} - - ) : ( - - {buttonContent} - - ); -} diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx new file mode 100644 index 00000000..b978fe1b --- /dev/null +++ b/src/components/Button/Button.tsx @@ -0,0 +1,380 @@ +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"; + +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; +}; + +type BaseButtonProps = ButtonStyleProps & { + children?: ReactNode; + loading?: boolean; + loadingText?: string; + onClick?: React.MouseEventHandler; +}; + +type DataAttributes = { + [dataAttribute: `data-${string}`]: string | number | boolean | undefined; +}; + +type SharedDomProps = React.AriaAttributes & + DataAttributes & { + autoFocus?: boolean; + className?: string; + id?: string; + role?: React.AriaRole; + style?: React.CSSProperties; + tabIndex?: number; + title?: string; + }; + +type LinkNavigationProps = { + href: string; + locale?: string | false; + onNavigate?: (event: { preventDefault: () => void }) => void; + prefetch?: boolean | "auto" | null; + replace?: boolean; + scroll?: boolean; + shallow?: boolean; +}; + +export type ButtonElementProps = BaseButtonProps & + SharedDomProps & { + as?: ElementType; + form?: string; + formAction?: string | ((formData: FormData) => void | Promise); + formEncType?: string; + formMethod?: string; + formNoValidate?: boolean; + formTarget?: string; + href?: undefined; + name?: string; + type?: "button" | "submit" | "reset"; + value?: string | number | readonly string[]; + }; + +export type LinkButtonProps = BaseButtonProps & + SharedDomProps & + LinkNavigationProps & { + as?: never; + download?: boolean | string; + formAction?: never; + formEncType?: never; + formMethod?: never; + formNoValidate?: never; + formTarget?: never; + name?: never; + rel?: string; + target?: React.HTMLAttributeAnchorTarget; + type?: never; + value?: never; + }; + +export type ButtonProps = ButtonElementProps | LinkButtonProps; + +type ButtonElementRestProps = Omit< + ButtonElementProps, + keyof BaseButtonProps | "href" +>; + +type LinkButtonRestProps = Omit< + LinkButtonProps, + keyof BaseButtonProps | "href" +>; + +const isLinkButton = (props: ButtonProps): props is LinkButtonProps => + props.href !== undefined; + +const getCommonProps = (props: ButtonProps) => { + const { + variant = "secondary", + disabled = false, + children, + tabIndex = 0, + loading = false, + loadingText = "Loading...", + size = "normal", + onClick, + href, + ...restProps + } = props; + + return { + variant, + disabled, + children, + tabIndex, + loading, + loadingText, + size, + onClick, + href, + restProps, + }; +}; + +const getButtonElementProps = (props: ButtonElementRestProps) => { + const { type = "button", ...buttonProps } = props; + + return { + type, + buttonProps, + }; +}; + +const getLinkButtonProps = (props: LinkButtonRestProps) => props; + +const buttonStyles = ({ theme }: { theme: any }): any => ({ + // Resets + border: "none", + appearance: "none", + // Base styles that both button and link will share + flexShrink: 0, + borderRadius: `calc(${theme.corners.base} * 1)`, + cursor: "pointer", + fontWeight: "500", + display: "inline-flex", // Added to help with alignment + alignItems: "center", // Added to help with alignment + justifyContent: "center", // Added to help with alignment + textDecoration: "none", + padding: `0 calc(${theme.spacing.unit} * 2)`, + transition: + "background 100ms ease-in-out, color 75ms ease-in-out, box-shadow 100ms ease-in-out", + + // Ellipsize text + "& span": { + display: "inline-block", + alignItems: "center", + overflow: "hidden", + whiteSpace: "nowrap", + textOverflow: "ellipsis", + }, + + "&:focus": { + outline: `3px solid ${theme.colors.focus.outline}`, + }, + "&[data-focus]": { + outline: `3px solid ${theme.colors.focus.outline}`, + }, + '&[aria-disabled="true"]': { + cursor: "default", + background: theme.colors.button.disabled.background, + color: theme.colors.button.disabled.text, + }, + + variants: [ + { + props: { width: "contained" }, + style: { + alignSelf: "flex-start", + }, + }, + { + props: { width: "full" }, + style: { + width: "100%", + }, + }, + { + props: { size: "massive" }, + style: { + height: "4rem", + fontSize: "1.3rem", + borderRadius: `calc(${theme.corners.base} * 1.25)`, + padding: `0 calc(${theme.spacing.unit} * 4)`, + }, + }, + { + props: { size: "large" }, + style: { + height: "3.5rem", + fontSize: "1.125rem", + }, + }, + { + props: { size: "normal" }, + style: { + height: "3rem", + fontSize: "1.0625rem", // 17px + }, + }, + { + props: { size: "small" }, + style: { + height: "2.25rem", + fontSize: "0.875rem", + padding: `0 calc(${theme.spacing.unit} * 1.5)`, + }, + }, + { + props: { variant: "primary" }, + style: { + background: theme.colors.button.primary.background, + color: theme.colors.button.primary.text, + + "&:not([disabled])": { + boxShadow: `0px 0px 0px 2px ${theme.colors.button.primary.background}`, // Match visual height of sibling buttons with box-shadow + }, + '&:hover&:not([disabled]):not([aria-disabled="true"])': { + background: `color-mix(in srgb, ${theme.colors.button.primary.background}, ${theme.colors.button.primary.hover.tint} ${theme.colors.button.primary.hover.mix})`, + boxShadow: `0px 0px 0px 2px color-mix(in srgb, ${theme.colors.button.primary.background}, ${theme.colors.button.primary.hover.tint} ${theme.colors.button.primary.hover.mix})`, + }, + }, + }, + { + props: { variant: "secondary" }, + style: { + background: theme.colors.button.secondary.background, + color: theme.colors.button.secondary.text, + // borderColor: theme.colors.border.base, + boxShadow: `0px 0px 0px 2px ${theme.colors.border.base}`, + '&:hover&:not([disabled]):not([aria-disabled="true"])': { + color: `color-mix(in srgb, ${theme.colors.button.secondary.text}, ${theme.colors.button.secondary.hover.tint} ${theme.colors.button.secondary.hover.mix})`, + }, + }, + }, + { + props: { variant: "danger" }, + style: { + background: theme.colors.button.danger.background, + color: theme.colors.button.danger.text, + // borderColor: theme.colors.border.base, + boxShadow: `0px 0px 0px 2px ${theme.colors.border.base}`, + '&:hover&:not([disabled]):not([aria-disabled="true"])': { + color: `color-mix(in srgb, ${theme.colors.button.danger.text}, ${theme.colors.button.danger.hover.tint} ${theme.colors.button.danger.hover.mix})`, + }, + }, + }, + { + props: { variant: "send" }, + style: { + backgroundColor: theme.colors.button.send.background, + border: "none", + color: theme.colors.button.send.text, + + '&:hover&:not([disabled]):not([aria-disabled="true"])': { + backgroundColor: `color-mix(in srgb, ${theme.colors.button.send.text}, ${theme.colors.button.send.hover.tint} ${theme.colors.button.send.hover.mix})`, + }, + }, + }, + { + // The default style for IconButton + props: { variant: "subtle" }, + style: { + // Assume styles from secondary, mainly so I don't have to put visual styles in size: "icon" (TODO: avoid repetition) + background: theme.colors.button.secondary.background, + color: theme.colors.button.secondary.text, + border: `1px solid ${theme.colors.border.base}`, + '&:hover&:not([disabled]):not([aria-disabled="true"])': { + backgroundColor: theme.colors.background.sunk, + }, + }, + }, + { + props: { size: "icon" }, + style: { + width: "2rem", + height: "2rem", + padding: 0, + borderRadius: "50%", + }, + }, + { + props: { disabled: true }, + style: { + cursor: "default", + background: theme.colors.button.disabled.background, + color: theme.colors.button.disabled.text, + }, + }, + ], +}); + +const StyledButton = styled(UnstyledButton)(buttonStyles); +const StyledLink = styled(Link)(buttonStyles); + +const Button = forwardRef( + function Button(allProps, ref) { + const { + variant, + disabled, + href, + children, + tabIndex, + loading, + loadingText, + size, + onClick, + restProps, + } = getCommonProps(allProps); + const isLink = isLinkButton(allProps); + const isLoading = loading && !isLink; + const isDisabled = disabled || isLoading; + const buttonContent = {isLoading ? loadingText : children}; + const handleLinkClick: React.MouseEventHandler = (event) => { + if (isDisabled) { + event.preventDefault(); + event.stopPropagation(); + return; + } + + onClick?.(event); + }; + + if (isLink) { + const linkProps = getLinkButtonProps(restProps as LinkButtonRestProps); + + return ( + } + variant={variant} + tabIndex={isDisabled ? -1 : tabIndex} + aria-disabled={isDisabled || undefined} + aria-busy={isLoading || undefined} + size={size} + onClick={handleLinkClick} + {...linkProps} + > + {buttonContent} + + ); + } + + const { type, buttonProps } = getButtonElementProps( + restProps as ButtonElementRestProps + ); + + return ( + } + disabled={isDisabled} + variant={variant} + tabIndex={isDisabled ? -1 : tabIndex} + aria-disabled={isDisabled || undefined} + aria-busy={isLoading || undefined} + size={size} + onClick={onClick} + {...buttonProps} + > + {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 && } -