Product
diff --git a/src/components/footer/routes.ts b/src/components/footer/routes.ts
index 66a2ab7b2..d8b6e3e27 100644
--- a/src/components/footer/routes.ts
+++ b/src/components/footer/routes.ts
@@ -55,5 +55,9 @@ export const NAVIGATION_ROUTES = [
{
name: "Custom Storefront",
url: BosonRoutes.CreateStorefront
+ },
+ {
+ name: "Chat",
+ url: BosonRoutes.Chat
}
];
diff --git a/src/components/form/Checkbox.tsx b/src/components/form/Checkbox.tsx
index 9cc24333a..e0b1accfb 100644
--- a/src/components/form/Checkbox.tsx
+++ b/src/components/form/Checkbox.tsx
@@ -38,7 +38,7 @@ export default function Checkbox({ name, text, ...props }: CheckboxProps) {
{text || "Checkbox"}
- {" "}
+
>
);
}
diff --git a/src/components/form/Error.tsx b/src/components/form/Error.tsx
index 449f5ce4e..7db7d89e9 100644
--- a/src/components/form/Error.tsx
+++ b/src/components/form/Error.tsx
@@ -1,5 +1,4 @@
import { Warning } from "phosphor-react";
-import React from "react";
import { colors } from "../../lib/styles/colors";
import Typography from "../ui/Typography";
diff --git a/src/components/form/Input.tsx b/src/components/form/Input.tsx
index df7bc221a..287fd78a2 100644
--- a/src/components/form/Input.tsx
+++ b/src/components/form/Input.tsx
@@ -1,5 +1,4 @@
import { useField } from "formik";
-import React from "react";
import Error from "./Error";
import { FieldInput } from "./Field.styles";
diff --git a/src/components/form/Textarea.tsx b/src/components/form/Textarea.tsx
index cd8a27423..98107449a 100644
--- a/src/components/form/Textarea.tsx
+++ b/src/components/form/Textarea.tsx
@@ -10,7 +10,6 @@ export default function Textarea({ name, ...props }: TextareaProps) {
const errorMessage = meta.error && meta.touched ? meta.error : "";
const displayError =
typeof errorMessage === typeof "string" && errorMessage !== "";
-
return (
<>
diff --git a/src/components/form/Upload.tsx b/src/components/form/Upload/Upload.tsx
similarity index 68%
rename from src/components/form/Upload.tsx
rename to src/components/form/Upload/Upload.tsx
index e661c1737..ab827ef71 100644
--- a/src/components/form/Upload.tsx
+++ b/src/components/form/Upload/Upload.tsx
@@ -2,16 +2,14 @@ import { useField } from "formik";
import { Image, Trash } from "phosphor-react";
import { useEffect, useRef, useState } from "react";
-import { CONFIG } from "../../lib/config";
-import { colors } from "../../lib/styles/colors";
-import bytesToSize from "../../lib/utils/bytesToSize";
-import Button from "../ui/Button";
-import Grid from "../ui/Grid";
-import Typography from "../ui/Typography";
-import Error from "./Error";
-import { FieldInput } from "./Field.styles";
-import { FieldFileUploadWrapper, FileUploadWrapper } from "./Field.styles";
-import type { UploadProps } from "./types";
+import { colors } from "../../../lib/styles/colors";
+import bytesToSize from "../../../lib/utils/bytesToSize";
+import Button from "../../ui/Button";
+import Error from "../Error";
+import { FieldInput } from "../Field.styles";
+import { FieldFileUploadWrapper, FileUploadWrapper } from "../Field.styles";
+import type { UploadProps } from "../types";
+import UploadedFiles from "./UploadedFiles";
export default function Upload({
name,
@@ -19,7 +17,10 @@ export default function Upload({
disabled,
multiple = false,
trigger,
+ maxSize,
onFilesSelect,
+ files: initialFiles,
+ wrapperProps,
...props
}: UploadProps) {
const [, meta, helpers] = useField(name);
@@ -28,7 +29,7 @@ export default function Upload({
typeof errorMessage === typeof "string" && errorMessage !== "";
const inputRef = useRef(null);
- const [files, setFiles] = useState([]);
+ const [files, setFiles] = useState(initialFiles ? initialFiles : []);
const [preview, setPreview] = useState(null);
useEffect(() => {
@@ -65,20 +66,21 @@ export default function Upload({
if (!meta.touched) {
helpers.setTouched(true);
}
+
if (!e.target.files) {
return;
}
const { files } = e.target;
const filesArray = Object.values(files);
for (const file of filesArray) {
- if (file.size > CONFIG.maxUploadSize) {
- // TODO: change to notification
- console.error(
- `File size cannot exceed more than ${bytesToSize(
- CONFIG.maxUploadSize
- )}`
- );
- return;
+ if (maxSize) {
+ if (file.size > maxSize) {
+ const error = `File size cannot exceed more than ${bytesToSize(
+ maxSize
+ )}`;
+ // TODO: change to notification
+ console.error(error);
+ }
}
}
setFiles(filesArray);
@@ -86,7 +88,7 @@ export default function Upload({
return (
<>
-
+
)}
- {multiple &&
- files.map((file: File, index: number) => {
- return (
-
-
- {file.name}
- {bytesToSize(file.size)}
-
- handleRemoveFile(index)} theme="blank">
-
-
-
- );
- })}
+ {multiple && (
+
+ )}
>
diff --git a/src/components/form/Upload/UploadedFile.tsx b/src/components/form/Upload/UploadedFile.tsx
new file mode 100644
index 000000000..759b1f194
--- /dev/null
+++ b/src/components/form/Upload/UploadedFile.tsx
@@ -0,0 +1,82 @@
+import { ImageSquare, X } from "phosphor-react";
+import { useCallback } from "react";
+import styled from "styled-components";
+
+import { colors } from "../../../lib/styles/colors";
+import bytesToSize from "../../../lib/utils/bytesToSize";
+import Button from "../../ui/Button";
+import Grid from "../../ui/Grid";
+import Typography from "../../ui/Typography";
+
+const AttachmentContainer = styled.div<{ $isLeftAligned: boolean }>`
+ position: relative;
+ display: flex;
+ cursor: pointer;
+ align-items: center;
+ padding: 1rem;
+ background-color: ${({ $isLeftAligned }) =>
+ $isLeftAligned ? "inherit" : colors.lightGrey};
+ ${({ $isLeftAligned }) =>
+ $isLeftAligned ? `border: 2px solid ${colors.white}` : ""};
+ color: ${({ $isLeftAligned }) => ($isLeftAligned ? "inherit" : colors.black)};
+ margin-bottom: 0.3rem;
+ svg:nth-of-type(2) {
+ position: absolute;
+ right: 1rem;
+ }
+`;
+
+interface Props {
+ fileName: string;
+ fileSize: number;
+ base64Content?: string;
+ showSize: boolean;
+ color: "grey" | "white";
+ handleRemoveFile?: () => void;
+}
+export default function UploadedFile({
+ fileName,
+ fileSize,
+ color,
+ base64Content,
+ showSize,
+ handleRemoveFile
+}: Props) {
+ const FileContent = useCallback(() => {
+ return (
+ <>
+
+
+ {fileName}
+
+ {showSize && (
+
+ {bytesToSize(fileSize)}
+
+ )}
+ >
+ );
+ }, [fileName, fileSize, showSize]);
+ return (
+
+
+ {base64Content ? (
+
+
+
+ ) : (
+
+ )}
+ {handleRemoveFile && (
+ handleRemoveFile()} theme="blank">
+
+
+ )}
+
+
+ );
+}
diff --git a/src/components/form/Upload/UploadedFiles.tsx b/src/components/form/Upload/UploadedFiles.tsx
new file mode 100644
index 000000000..0ed5981cb
--- /dev/null
+++ b/src/components/form/Upload/UploadedFiles.tsx
@@ -0,0 +1,24 @@
+import UploadedFile from "./UploadedFile";
+
+interface Props {
+ files: File[];
+ handleRemoveFile: (index: number) => void;
+}
+export default function UploadedFiles({ files, handleRemoveFile }: Props) {
+ return (
+ <>
+ {files.map((file: File, index: number) => {
+ return (
+ handleRemoveFile(index)}
+ showSize
+ />
+ );
+ })}
+ >
+ );
+}
diff --git a/src/components/form/index.ts b/src/components/form/index.ts
index e4fcd0005..7c3f2faba 100644
--- a/src/components/form/index.ts
+++ b/src/components/form/index.ts
@@ -3,4 +3,4 @@ export { default as Datepicker } from "./Datepicker";
export { default as Input } from "./Input";
export { default as Select } from "./Select";
export { default as Textarea } from "./Textarea";
-export { default as Upload } from "./Upload";
+export { default as Upload } from "./Upload/Upload";
diff --git a/src/components/form/types.ts b/src/components/form/types.ts
index 2637ca3d6..dcff1af48 100644
--- a/src/components/form/types.ts
+++ b/src/components/form/types.ts
@@ -54,4 +54,7 @@ export interface UploadProps extends BaseProps {
multiple?: boolean;
trigger?: React.ReactNode | JSX.Element;
onFilesSelect?: (files: File[]) => void;
+ files?: File[];
+ wrapperProps?: React.HTMLAttributes;
+ maxSize?: number;
}
diff --git a/src/components/header/Header.tsx b/src/components/header/Header.tsx
index 184ac82b6..3d80be374 100644
--- a/src/components/header/Header.tsx
+++ b/src/components/header/Header.tsx
@@ -19,7 +19,7 @@ const Header = styled.header`
left: 0;
right: 0;
+ * {
- padding-top: calc(${HEADER_HEIGHT} + 2rem) !important;
+ padding-top: ${HEADER_HEIGHT};
}
width: 100%;
diff --git a/src/components/linkStoreFields/LinkStoreFields.tsx b/src/components/linkStoreFields/LinkStoreFields.tsx
index 822c4ec9d..defad2bf6 100644
--- a/src/components/linkStoreFields/LinkStoreFields.tsx
+++ b/src/components/linkStoreFields/LinkStoreFields.tsx
@@ -5,9 +5,10 @@ import { getKeepStoreFieldsQueryParams } from "../../lib/utils/hooks/useKeepQuer
interface Props {
children: string | JSX.Element;
to: string;
+ state?: Record;
[x: string]: unknown;
}
-export const LinkWithQuery = ({ children, to, ...props }: Props) => {
+export const LinkWithQuery = ({ children, to, state, ...props }: Props) => {
const location = useLocation();
// TODO: doesnt currently support passing query params in the 'to' parameter
const search = getKeepStoreFieldsQueryParams(location, null);
@@ -17,6 +18,7 @@ export const LinkWithQuery = ({ children, to, ...props }: Props) => {
pathname: to,
search
}}
+ state={{ ...state, prevPath: location.pathname }}
{...props}
>
{children}
diff --git a/src/components/modal/Modal.tsx b/src/components/modal/Modal.tsx
index 2c134fb67..8757ace8f 100644
--- a/src/components/modal/Modal.tsx
+++ b/src/components/modal/Modal.tsx
@@ -1,4 +1,5 @@
import { X as Close } from "phosphor-react";
+import { ReactNode } from "react";
import { createPortal } from "react-dom";
import styled from "styled-components";
@@ -8,6 +9,7 @@ import { zIndex } from "../../lib/styles/zIndex";
import Button from "../ui/Button";
import { scrollStyles } from "../ui/styles";
import Typography from "../ui/Typography";
+import { Store } from "./ModalContext";
const Root = styled.div`
position: fixed;
@@ -28,7 +30,28 @@ const RootBG = styled.div`
z-index: ${zIndex.Modal - 1};
`;
-const Wrapper = styled.div`
+const sizeToMargin = {
+ s: {
+ s: "4rem 10rem",
+ m: "4rem 19rem",
+ l: "4rem 22rem",
+ xl: "4rem 30rem"
+ },
+ m: {
+ s: "4rem 6rem",
+ m: "4rem 12rem",
+ l: "4rem 16rem",
+ xl: "4rem 25.75rem"
+ },
+ l: {
+ s: "4rem",
+ m: "4rem 8rem",
+ l: "4rem 10rem",
+ xl: "4rem 14rem"
+ }
+} as const;
+
+const Wrapper = styled.div<{ $size: Props["size"] }>`
position: relative;
z-index: ${zIndex.Modal};
color: ${colors.black};
@@ -37,27 +60,31 @@ const Wrapper = styled.div`
margin: 0;
${breakpoint.s} {
- margin: 4rem;
+ margin: ${({ $size }) =>
+ sizeToMargin[$size as keyof typeof sizeToMargin]["s"] || "4rem"};
}
${breakpoint.m} {
- margin: 4rem 8rem;
+ margin: ${({ $size }) =>
+ sizeToMargin[$size as keyof typeof sizeToMargin]["m"] || "4rem 8rem"};
}
${breakpoint.l} {
- margin: 4rem 10rem;
+ margin: ${({ $size }) =>
+ sizeToMargin[$size as keyof typeof sizeToMargin]["l"] || "4rem 10rem"};
}
${breakpoint.xl} {
- margin: 4rem 14rem;
+ margin: ${({ $size }) =>
+ sizeToMargin[$size as keyof typeof sizeToMargin]["xl"] || "4rem 14rem"};
}
`;
-const Title = styled(Typography)`
+const Header = styled(Typography)`
position: relative;
height: 4.25rem;
padding: 1rem 2rem;
margin: 0;
padding-right: 8rem;
border-bottom: 2px solid ${colors.border};
- > button {
+ > button[data-close] {
position: absolute;
top: 50%;
right: 0;
@@ -84,21 +111,48 @@ interface Props {
children: React.ReactNode;
hideModal: () => void;
title?: string;
+ headerComponent?: ReactNode;
+ size: NonNullable;
+ closable?: boolean;
}
-export default function Modal({ children, hideModal, title = "modal" }: Props) {
+export default function Modal({
+ children,
+ hideModal,
+ title = "modal",
+ headerComponent: HeaderComponent,
+ size,
+ closable = true
+}: Props) {
return createPortal(
-
-
- {title}
-
-
-
-
+
+ {HeaderComponent ? (
+
+ {HeaderComponent}
+ {closable && (
+
+
+
+ )}
+
+ ) : (
+
+ {title}
+ {closable && (
+
+
+
+ )}
+
+ )}
{children}
-
+ {
+ closable && hideModal();
+ }}
+ />
,
document.body
);
diff --git a/src/components/modal/ModalComponents.tsx b/src/components/modal/ModalComponents.tsx
index 76bbe591d..5ca51819f 100644
--- a/src/components/modal/ModalComponents.tsx
+++ b/src/components/modal/ModalComponents.tsx
@@ -1,20 +1,34 @@
/* eslint @typescript-eslint/no-explicit-any: "off" */
+import CancelExchangeModal from "./components/Chat/CancelExchangeModal";
+import InitializeChatModal from "./components/Chat/InitializeChatModal";
+import MakeProposalModal from "./components/Chat/MakeProposal/MakeProposalModal";
+import ResolveDisputeModal from "./components/Chat/ResolveDisputeModal";
import CustomStore from "./components/CustomStore";
import DetailWidget from "./components/DetailWidget";
+import DisputeModal from "./components/DisputeModal/DisputeModal";
+import Upload from "./components/Upload";
import WhatIsRedeem from "./components/WhatIsRedeem";
export const MODAL_TYPES = {
CUSTOM_STORE: "CUSTOM_STORE",
DETAIL_WIDGET: "DETAIL_WIDGET",
- WHAT_IS_REDEEM: "WHAT_IS_REDEEM"
+ WHAT_IS_REDEEM: "WHAT_IS_REDEEM",
+ CANCEL_EXCHANGE: "CANCEL_EXCHANGE",
+ RESOLVE_DISPUTE: "RESOLVE_DISPUTE",
+ UPLOAD_MODAL: "UPLOAD_MODAL",
+ MAKE_PROPOSAL: "MAKE_PROPOSAL",
+ INITIALIZE_CHAT: "INITIALIZE_CHAT",
+ DISPUTE_MODAL: "DISPUTE_MODAL"
} as const;
-type ModalComponentsType = {
- [key in keyof typeof MODAL_TYPES]: any;
-};
-
-export const MODAL_COMPONENTS: ModalComponentsType = {
+export const MODAL_COMPONENTS = {
[MODAL_TYPES.CUSTOM_STORE]: CustomStore,
[MODAL_TYPES.DETAIL_WIDGET]: DetailWidget,
- [MODAL_TYPES.WHAT_IS_REDEEM]: WhatIsRedeem
-};
+ [MODAL_TYPES.WHAT_IS_REDEEM]: WhatIsRedeem,
+ [MODAL_TYPES.CANCEL_EXCHANGE]: CancelExchangeModal,
+ [MODAL_TYPES.RESOLVE_DISPUTE]: ResolveDisputeModal,
+ [MODAL_TYPES.UPLOAD_MODAL]: Upload,
+ [MODAL_TYPES.MAKE_PROPOSAL]: MakeProposalModal,
+ [MODAL_TYPES.INITIALIZE_CHAT]: InitializeChatModal,
+ [MODAL_TYPES.DISPUTE_MODAL]: DisputeModal
+} as const;
diff --git a/src/components/modal/ModalContext.tsx b/src/components/modal/ModalContext.tsx
index 2900a980b..05d01dd18 100644
--- a/src/components/modal/ModalContext.tsx
+++ b/src/components/modal/ModalContext.tsx
@@ -1,36 +1,63 @@
/* eslint @typescript-eslint/no-empty-function: "off" */
/* eslint @typescript-eslint/no-explicit-any: "off" */
-import { subgraph } from "@bosonprotocol/react-kit";
-import { createContext } from "react";
+import { createContext, ReactNode } from "react";
-import { MODAL_TYPES } from "./ModalComponents";
+import { MODAL_COMPONENTS, MODAL_TYPES } from "./ModalComponents";
export type ModalProps = {
title?: string;
- message?: string;
- type?: string;
- state?: keyof typeof subgraph.ExchangeState;
- [x: string]: any;
+ headerComponent?: ReactNode;
+ hideModal?: () => void;
+ closable?: boolean;
};
export type ModalType = keyof typeof MODAL_TYPES | null;
export type Store = {
modalType: ModalType;
- modalProps?: ModalProps;
+ modalProps?: Parameters[1];
+ modalSize?: "xxs" | "xs" | "s" | "m" | "l" | "xl";
};
export interface ModalContextType {
- showModal: (modalType: ModalType, modalProps?: ModalProps) => void;
+ showModal: (
+ modalType: T,
+ modalProps?: Omit<
+ Parameters,
+ "hideModal"
+ >[0] extends undefined
+ ? Omit
+ : Omit &
+ Omit[0], "hideModal">,
+ modalSize?: Store["modalSize"]
+ ) => void;
hideModal: () => void;
+
+ updateProps: (
+ store: Store & {
+ modalProps: Omit<
+ Parameters,
+ "hideModal"
+ >[0] extends undefined
+ ? Partial>
+ : Partial<
+ Omit &
+ Omit[0], "hideModal">
+ >;
+ modalSize?: Store["modalSize"];
+ }
+ ) => void;
+
store: Store;
}
export const initalState: ModalContextType = {
showModal: () => {},
hideModal: () => {},
+ updateProps: () => {},
store: {
modalType: null,
- modalProps: {}
+ modalProps: {} as any,
+ modalSize: "l"
}
};
diff --git a/src/components/modal/ModalProvider.tsx b/src/components/modal/ModalProvider.tsx
index 69d36d26d..49a1d4a1a 100644
--- a/src/components/modal/ModalProvider.tsx
+++ b/src/components/modal/ModalProvider.tsx
@@ -1,3 +1,4 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
import { useCallback, useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
@@ -6,24 +7,61 @@ import { MODAL_COMPONENTS } from "./ModalComponents";
import ModalContext, {
initalState,
ModalContextType,
- ModalProps,
- ModalType
+ ModalType,
+ Store
} from "./ModalContext";
+const RenderModalComponent = ({
+ store,
+ hideModal
+}: {
+ store: Store;
+ hideModal: () => void;
+}) => {
+ const ModalComponent = store.modalType
+ ? MODAL_COMPONENTS[store.modalType]
+ : null;
+ if (!store.modalType || !ModalComponent) {
+ document.body.style.overflow = "";
+ return null;
+ }
+ document.body.style.overflow = "hidden";
+ return (
+
+
+
+ );
+};
+
interface Props {
children: React.ReactNode;
}
export default function ModalProvider({ children }: Props) {
const { pathname } = useLocation();
const [store, setStore] = useState(initalState.store);
- const { modalType, modalProps } = store;
const showModal = useCallback(
- (modalType: ModalType, modalProps?: ModalProps) => {
+ (
+ modalType: ModalType,
+ modalProps?: Store["modalProps"],
+ modalSize?: Store["modalSize"]
+ ) => {
setStore({
...store,
modalType,
- modalProps
+ modalProps,
+ modalSize
});
},
[store]
@@ -33,37 +71,33 @@ export default function ModalProvider({ children }: Props) {
setStore({
...store,
modalType: null,
- modalProps: {}
+ modalProps: {} as Store["modalProps"]
});
}, [store]);
+ const updateProps = useCallback((store: Store) => {
+ setStore({
+ ...store
+ });
+ }, []);
+
useEffect(() => {
- if (modalType !== null) {
+ if (store.modalType !== null) {
hideModal();
}
}, [pathname]); // eslint-disable-line
- const renderComponent = () => {
- const ModalComponent = modalType ? MODAL_COMPONENTS[modalType] : null;
- if (!modalType || !ModalComponent) {
- document.body.style.overflow = "unset";
- return null;
- }
-
- document.body.style.overflow = "hidden";
- return (
-
-
-
- );
+ const value: ModalContextType = {
+ store,
+ updateProps,
+ showModal,
+ hideModal
};
- const value: ModalContextType = { store, showModal, hideModal };
-
return (
{children}
- {renderComponent()}
+
);
}
diff --git a/src/components/modal/components/Chat/CancelExchangeModal.tsx b/src/components/modal/components/Chat/CancelExchangeModal.tsx
new file mode 100644
index 000000000..fcd42b269
--- /dev/null
+++ b/src/components/modal/components/Chat/CancelExchangeModal.tsx
@@ -0,0 +1,103 @@
+import { Info as InfoComponent } from "phosphor-react";
+import styled from "styled-components";
+
+import { colors } from "../../../../lib/styles/colors";
+import { getBuyerCancelPenalty } from "../../../../lib/utils/getPrices";
+import { Exchange } from "../../../../lib/utils/hooks/useExchanges";
+import DetailTable from "../../../detail/DetailTable";
+import { useConvertedPrice } from "../../../price/useConvertedPrice";
+import Button from "../../../ui/Button";
+import { ModalProps } from "../../ModalContext";
+
+interface Props {
+ exchange: Exchange;
+
+ hideModal: NonNullable;
+ title: ModalProps["title"];
+}
+
+const Line = styled.hr`
+ all: unset;
+ display: block;
+ width: 100%;
+ border-bottom: 2px solid ${colors.black};
+ margin: 1rem 0;
+`;
+
+const Info = styled.div`
+ padding: 1.5rem;
+ background-color: ${colors.lightGrey};
+ margin: 2rem 0;
+ color: ${colors.darkGrey};
+ display: flex;
+ align-items: center;
+`;
+
+const InfoIcon = styled(InfoComponent)`
+ margin-right: 1.1875rem;
+`;
+
+const ButtonsSection = styled.div`
+ border-top: 2px solid ${colors.border};
+ padding-top: 2rem;
+ display: flex;
+ justify-content: space-between;
+`;
+
+export default function CancelExchangeModal({ exchange, hideModal }: Props) {
+ const { offer } = exchange;
+ const convertedPrice = useConvertedPrice({
+ value: offer.price,
+ decimals: offer.exchangeToken.decimals,
+ symbol: offer.exchangeToken.symbol
+ });
+
+ const { buyerCancelationPenalty, convertedBuyerCancelationPenalty } =
+ getBuyerCancelPenalty(offer, convertedPrice);
+
+ const refund =
+ Number(offer.price) - (Number(offer.price) * buyerCancelationPenalty) / 100;
+ const convertedRefund = useConvertedPrice({
+ value: refund.toString(),
+ decimals: offer.exchangeToken.decimals,
+ symbol: offer.exchangeToken.symbol
+ });
+ return (
+ <>
+
+
+
+
+
+ Your rNFT will be burned after cancellation.
+
+
+ Confirm cancellation
+ hideModal()}>
+ Back
+
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/InitializeChatModal.tsx b/src/components/modal/components/Chat/InitializeChatModal.tsx
new file mode 100644
index 000000000..279c7d9d9
--- /dev/null
+++ b/src/components/modal/components/Chat/InitializeChatModal.tsx
@@ -0,0 +1,65 @@
+import { ChatDots } from "phosphor-react";
+import styled from "styled-components";
+import { useAccount } from "wagmi";
+
+import { colors } from "../../../../lib/styles/colors";
+import { useChatContext } from "../../../../pages/chat/ChatProvider/ChatContext";
+import ConnectButton from "../../../header/ConnectButton";
+import Button from "../../../ui/Button";
+import Grid from "../../../ui/Grid";
+import Typography from "../../../ui/Typography";
+
+const Info = styled(Grid)`
+ display: flex;
+ justify-content: space-between;
+ background-color: ${colors.lightGrey};
+ padding: 1.5rem;
+`;
+
+const Icon = styled(ChatDots)`
+ fill: var(--secondary);
+ path {
+ stroke: var(--secondary);
+ }
+`;
+
+const InfoMessage = styled(Typography)`
+ font-family: Plus Jakarta Sans;
+ font-size: 1rem;
+ font-weight: 600;
+ line-height: 1.5rem;
+ letter-spacing: 0px;
+ text-align: left;
+ flex: 1 1;
+`;
+
+export default function InitializeChatModal() {
+ const { initialize, bosonXmtp } = useChatContext();
+ const { address } = useAccount();
+
+ return (
+
+
+
+
+ To proceed you must first initialize your chat client
+
+
+
+ {address && !bosonXmtp ? (
+ {
+ initialize();
+ }}
+ >
+ Initialize
+
+ ) : !address ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/src/components/modal/components/Chat/MakeProposal/MakeProposalFormModel.ts b/src/components/modal/components/Chat/MakeProposal/MakeProposalFormModel.ts
new file mode 100644
index 000000000..5d02d237a
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/MakeProposalFormModel.ts
@@ -0,0 +1,26 @@
+export const FormModel = {
+ formFields: {
+ description: {
+ name: "description",
+ requiredErrorMessage: "This field is required"
+ },
+ proposalsTypes: {
+ name: "proposalsTypes"
+ },
+ escrow: {
+ name: "escrow"
+ },
+ refundAmount: {
+ name: "refundAmount"
+ },
+ refundPercentage: {
+ name: "refundPercentage",
+ moreThanErrorMessage: "The percentage should be more than 0",
+ maxErrorMessage: "The percentage should be less than or equal to 100",
+ emptyErrorMessage: "This field cannot be left empty"
+ },
+ upload: {
+ name: "upload"
+ }
+ }
+} as const;
diff --git a/src/components/modal/components/Chat/MakeProposal/MakeProposalModal.tsx b/src/components/modal/components/Chat/MakeProposal/MakeProposalModal.tsx
new file mode 100644
index 000000000..7647882ad
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/MakeProposalModal.tsx
@@ -0,0 +1,157 @@
+import { Form, Formik, FormikProps } from "formik";
+import { ReactNode } from "react";
+import * as Yup from "yup";
+
+import {
+ FileWithEncodedData,
+ getFilesWithEncodedData
+} from "../../../../../lib/utils/files";
+import { getOfferArtist } from "../../../../../lib/utils/hooks/offers/placeholders";
+import { Exchange } from "../../../../../lib/utils/hooks/useExchanges";
+import { validationOfFile } from "../../../../../pages/chat/components/UploadForm/const";
+import { NewProposal } from "../../../../../pages/chat/types";
+import Grid from "../../../../ui/Grid";
+import { ModalProps } from "../../../ModalContext";
+import ExchangePreview from "../components/ExchangePreview";
+import { FormModel } from "./MakeProposalFormModel";
+import DescribeProblemStep from "./steps/DescribeProblemStep";
+import MakeAProposalStep from "./steps/MakeAProposalStep/MakeAProposalStep";
+import ReviewAndSubmitStep from "./steps/ReviewAndSubmitStep";
+
+interface Props {
+ exchange: Exchange;
+ activeStep: number;
+ sendProposal: (
+ proposal: NewProposal,
+ proposalFiles: FileWithEncodedData[]
+ ) => void;
+ // modal props
+ hideModal: NonNullable;
+ headerComponent: ReactNode;
+ setActiveStep: (step: number) => void;
+}
+
+const validationSchemaPerStep = [
+ Yup.object({
+ [FormModel.formFields.description.name]: Yup.string()
+ .trim()
+ .required(FormModel.formFields.description.requiredErrorMessage),
+ [FormModel.formFields.upload.name]: validationOfFile({ isOptional: true })
+ }),
+ Yup.object({
+ [FormModel.formFields.refundPercentage.name]: Yup.number()
+ .moreThan(0, FormModel.formFields.refundPercentage.moreThanErrorMessage)
+ .max(100, FormModel.formFields.refundPercentage.maxErrorMessage)
+ .defined(FormModel.formFields.refundPercentage.emptyErrorMessage)
+ }),
+ Yup.object({})
+];
+
+export default function MakeProposalModal({
+ exchange,
+ hideModal,
+ setActiveStep,
+ sendProposal,
+ activeStep
+}: Props) {
+ const validationSchema = validationSchemaPerStep[activeStep];
+ return (
+ <>
+
+
+
+ {
+ try {
+ const artist = getOfferArtist(exchange.offer.metadata.name || "");
+ const userName = artist || `Seller ID: ${exchange.seller.id}`; // TODO: change to get real username
+ const proposal: NewProposal = {
+ title: `${userName} made a proposal`,
+ description: values[FormModel.formFields.description.name],
+ proposals: values[FormModel.formFields.proposalsTypes.name].map(
+ (proposalType) => {
+ return {
+ type: proposalType.label,
+ percentageAmount:
+ values[FormModel.formFields.refundPercentage.name] + "",
+ signature: ""
+ };
+ }
+ ),
+ disputeContext: []
+ };
+ // TODO: sign proposals
+ const proposalFiles = values[FormModel.formFields.upload.name];
+ const filesWithData = await getFilesWithEncodedData(proposalFiles);
+
+ sendProposal(proposal, filesWithData);
+ hideModal();
+ } catch (error) {
+ console.error(error); // TODO: handle error case
+ }
+ }}
+ initialValues={{
+ [FormModel.formFields.description.name]: "",
+ [FormModel.formFields.proposalsTypes.name]: [] as {
+ label: string;
+ value: string;
+ }[],
+ [FormModel.formFields.refundAmount.name]: "0",
+ [FormModel.formFields.refundPercentage.name]: 0,
+ [FormModel.formFields.upload.name]: [] as File[]
+ }}
+ validateOnMount
+ >
+ {(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ props: FormikProps
+ ) => {
+ // TODO: remove any
+ const isDescribeProblemOK = Object.keys(props.errors).length === 0;
+
+ const isReturnProposal = !!props.values[
+ FormModel.formFields.proposalsTypes.name
+ ].find(
+ (proposal: { label: string; value: string }) =>
+ proposal.value === "return"
+ );
+ const isRefundProposal = !!props.values[
+ FormModel.formFields.proposalsTypes.name
+ ].find(
+ (proposal: { label: string; value: string }) =>
+ proposal.value === "refund"
+ );
+ const isMakeAProposalOK =
+ (isRefundProposal &&
+ !props.errors[FormModel.formFields.refundPercentage.name]) ||
+ (!isRefundProposal && isReturnProposal);
+ const isFormValid = isDescribeProblemOK && isMakeAProposalOK;
+ return (
+
+ );
+ }}
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/MakeProposal/steps/DescribeProblemStep.tsx b/src/components/modal/components/Chat/MakeProposal/steps/DescribeProblemStep.tsx
new file mode 100644
index 000000000..48701eb26
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/steps/DescribeProblemStep.tsx
@@ -0,0 +1,56 @@
+import styled from "styled-components";
+
+import { colors } from "../../../../../../lib/styles/colors";
+// import { Exchange } from "../../../../../../lib/utils/hooks/useExchanges";
+import UploadForm from "../../../../../../pages/chat/components/UploadForm/UploadForm";
+import { Textarea } from "../../../../../form";
+import Button from "../../../../../ui/Button";
+import Grid from "../../../../../ui/Grid";
+import Typography from "../../../../../ui/Typography";
+import { FormModel } from "../MakeProposalFormModel";
+
+const ButtonsSection = styled.div`
+ padding-top: 2rem;
+ display: flex;
+ justify-content: space-between;
+`;
+
+const TextArea = styled(Textarea)`
+ width: 100%;
+ resize: none;
+`;
+
+interface Props {
+ onNextClick: () => void;
+ isValid: boolean;
+}
+
+export default function DescribeProblemStep({ onNextClick, isValid }: Props) {
+ return (
+ <>
+
+ Describe Problem
+
+
+ You may provide any information or attach any files that can support
+ your case.
+
+
+
+ Message
+
+
+
+
+
+ onNextClick()}
+ disabled={!isValid}
+ >
+ Next
+
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/MakeAProposalStep.tsx b/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/MakeAProposalStep.tsx
new file mode 100644
index 000000000..f62cd34e7
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/MakeAProposalStep.tsx
@@ -0,0 +1,87 @@
+import { useField } from "formik";
+import styled from "styled-components";
+
+import { colors } from "../../../../../../../lib/styles/colors";
+import { Exchange } from "../../../../../../../lib/utils/hooks/useExchanges";
+import { Select } from "../../../../../../form";
+import Button from "../../../../../../ui/Button";
+import Grid from "../../../../../../ui/Grid";
+import Typography from "../../../../../../ui/Typography";
+import { FormModel } from "../../MakeProposalFormModel";
+import RefundRequest from "./RefundRequest";
+
+const ButtonsSection = styled.div`
+ padding-top: 2rem;
+ display: flex;
+ justify-content: space-between;
+`;
+
+interface Props {
+ onBackClick: () => void;
+ onNextClick: () => void;
+ isValid: boolean;
+ exchange: Exchange;
+}
+
+export const proposals = [{ label: "Refund", value: "refund" }];
+
+export default function MakeAProposalStep({
+ exchange,
+ onNextClick,
+ onBackClick,
+ isValid
+}: Props) {
+ const [proposalsTypesField] = useField(
+ FormModel.formFields.proposalsTypes.name
+ );
+
+ return (
+ <>
+
+ Make a proposal
+
+
+ Here you can make a proposal to the seller on how you would like the
+ issue to be resolved. Note that this proposal is binding and if the
+ seller agrees to it, the proposal will be implemented automatically.
+
+
+
+ Type of proposals
+
+
+
+ {proposalsTypesField.value.map((proposalType) => {
+ if (proposalType.value === "refund") {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
+
+ onNextClick()}
+ disabled={!isValid}
+ >
+ Next
+
+ onBackClick()}>
+ Back
+
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/RefundRequest.tsx b/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/RefundRequest.tsx
new file mode 100644
index 000000000..d0a63a038
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/RefundRequest.tsx
@@ -0,0 +1,162 @@
+import { BigNumber, utils } from "ethers";
+import { useFormikContext } from "formik";
+import styled from "styled-components";
+import { useAccount } from "wagmi";
+
+import { Exchange } from "../../../../../../../lib/utils/hooks/useExchanges";
+import { useSellers } from "../../../../../../../lib/utils/hooks/useSellers";
+import useFunds from "../../../../../../../pages/account/funds/useFunds";
+import { Input } from "../../../../../../form";
+import Price from "../../../../../../price";
+import Grid from "../../../../../../ui/Grid";
+import Typography from "../../../../../../ui/Typography";
+import { FormModel } from "../../MakeProposalFormModel";
+import RequestedRefundInput from "./RequestedRefundInput";
+
+const InEscrowPriceWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ [data-icon-price] {
+ justify-content: space-between;
+ [data-currency] {
+ all: unset;
+ transform: scale(0.75);
+ }
+ }
+ [data-icon-price][data-with-symbol] {
+ /* change once :has is supported */
+ padding: 0;
+ width: 100%;
+ }
+`;
+
+const StyledPrice = styled(Price)`
+ position: absolute;
+ top: 0;
+ right: 1rem;
+ left: 0.5rem;
+ bottom: 0;
+
+ > div {
+ align-items: flex-end;
+ }
+
+ small {
+ margin: 0 !important;
+ > * {
+ font-size: 0.75rem;
+ }
+ }
+
+ svg {
+ transform: translate(0, -50%) scale(1);
+ }
+`;
+
+interface Props {
+ exchange: Exchange;
+}
+
+export default function RefundRequest({ exchange }: Props) {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const { setFieldValue, handleChange } = useFormikContext();
+
+ const { address } = useAccount();
+ const { data: sellers } = useSellers({ admin: address });
+ const accountId = sellers?.[0]?.id || "";
+ const { funds } = useFunds(accountId);
+ const currencyInDeposit = funds?.find(
+ (fund) => fund.token.address === offer.exchangeToken.address
+ );
+ const { offer } = exchange;
+ const decimals = Number(offer.exchangeToken.decimals);
+ const formatIntValueToDecimals = (value: string | BigNumber) => {
+ return utils.formatUnits(BigNumber.from(value), decimals);
+ };
+ const inEscrow: string = BigNumber.from(offer.price)
+ .add(BigNumber.from(currencyInDeposit?.availableAmount || "0"))
+ .toString();
+ const inEscrowWithDecimals: string = formatIntValueToDecimals(inEscrow);
+ const currencySymbol = offer.exchangeToken.symbol;
+ return (
+ <>
+
+ Refund request
+
+
+ You will keep your purchased product and get a partial refund.
+
+
+
+
+ In escrow
+
+
+ Item price + seller diposit
+
+
+
+
+
+
+
+
+ Requested refund
+
+
+ Request a specific amount as a refund
+
+
+
+
+
+ Percentage
+
+
+ Edit as %
+
+ {
+ handleChange(e);
+ const {
+ target: { valueAsNumber }
+ } = e;
+ if (isNaN(valueAsNumber)) {
+ return;
+ }
+ const valueInDecimals: string = formatIntValueToDecimals(
+ BigNumber.from(inEscrow)
+ .mul(valueAsNumber * 1000)
+ .div(100 * 1000)
+ );
+ setFieldValue(
+ FormModel.formFields.refundAmount.name,
+ valueInDecimals,
+ true
+ );
+ }}
+ />
+
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/RequestedRefundInput.tsx b/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/RequestedRefundInput.tsx
new file mode 100644
index 000000000..7f39479ef
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/steps/MakeAProposalStep/RequestedRefundInput.tsx
@@ -0,0 +1,133 @@
+import { BigNumber, utils } from "ethers";
+import { useField, useFormikContext } from "formik";
+import styled from "styled-components";
+
+import { colors } from "../../../../../../../lib/styles/colors";
+import { Offer } from "../../../../../../../lib/types/offer";
+import { Input } from "../../../../../../form";
+import ConvertedPrice from "../../../../../../price/ConvertedPrice";
+import CurrencyIcon from "../../../../../../price/CurrencyIcon";
+import { useConvertedPrice } from "../../../../../../price/useConvertedPrice";
+import { FormModel } from "../../MakeProposalFormModel";
+
+const RefundAmountWrapper = styled.div`
+ position: relative;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ background: ${colors.lightGrey};
+ border: 1px solid ${colors.border};
+ padding: 0 0 0 0.5rem;
+
+ [data-currency] {
+ all: unset;
+ transform: scale(0.75);
+ font-size: 1.25rem;
+ font-weight: 600;
+ -webkit-font-smoothing: antialiased;
+ letter-spacing: -1px;
+ }
+
+ input {
+ border: none;
+ background: none;
+ text-align: right;
+ }
+
+ [data-converted-price] * {
+ font-size: 0.65625rem;
+ }
+`;
+
+interface Props {
+ address: string;
+ inEscrow: string;
+ inEscrowWithDecimals: string;
+ exchangeToken: Offer["exchangeToken"];
+}
+
+export default function RequestedRefundInput({
+ address,
+ inEscrow,
+ inEscrowWithDecimals,
+ exchangeToken
+}: Props) {
+ const [refundAmountField] = useField(
+ FormModel.formFields.refundAmount.name
+ );
+ const decimals = Number(exchangeToken.decimals);
+ const formatDecimalsToIntValue = (value: string | number): BigNumber => {
+ return utils.parseUnits(
+ typeof value === "number" ? value.toString() : value || "0",
+ decimals
+ );
+ };
+ const refundAmountWithoutDecimals: string = formatDecimalsToIntValue(
+ refundAmountField.value
+ ).toString();
+ const currencySymbol = exchangeToken.symbol;
+ const price = useConvertedPrice({
+ value: refundAmountWithoutDecimals,
+ decimals: exchangeToken.decimals,
+ symbol: currencySymbol
+ });
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const { setFieldValue, handleChange } = useFormikContext();
+ return (
+
+
+ {
+ handleChange(e);
+ const {
+ target: { valueAsNumber: currentRefundAmount }
+ } = e;
+ const percentageFromInput = (
+ (currentRefundAmount / Number(inEscrowWithDecimals)) *
+ 100
+ ).toFixed(3);
+ setFieldValue(
+ FormModel.formFields.refundPercentage.name,
+ percentageFromInput,
+ true
+ );
+ }}
+ onBlur={() => {
+ const currentRefundAmount: string = refundAmountWithoutDecimals;
+ const percentageFromInput = (
+ (Number(currentRefundAmount) / Number(inEscrow)) *
+ 100
+ ).toFixed(3);
+ const refundAmountFromPercentage: string = (
+ (Number(inEscrowWithDecimals) * Number(percentageFromInput)) /
+ 100
+ ).toFixed(decimals);
+ const percentageFromRoundedRefundAmount = (
+ (Number(refundAmountFromPercentage) /
+ Number(inEscrowWithDecimals)) *
+ 100
+ ).toFixed(3);
+ setFieldValue(
+ FormModel.formFields.refundPercentage.name,
+ percentageFromRoundedRefundAmount,
+ true
+ );
+ if (
+ refundAmountFromPercentage !==
+ Number(refundAmountField.value).toFixed(decimals)
+ ) {
+ setFieldValue(
+ FormModel.formFields.refundAmount.name,
+ refundAmountFromPercentage,
+ true
+ );
+ }
+ }}
+ />
+
+
+ );
+}
diff --git a/src/components/modal/components/Chat/MakeProposal/steps/ReviewAndSubmitStep.tsx b/src/components/modal/components/Chat/MakeProposal/steps/ReviewAndSubmitStep.tsx
new file mode 100644
index 000000000..a6db31166
--- /dev/null
+++ b/src/components/modal/components/Chat/MakeProposal/steps/ReviewAndSubmitStep.tsx
@@ -0,0 +1,95 @@
+import { useField } from "formik";
+import styled from "styled-components";
+
+import { colors } from "../../../../../../lib/styles/colors";
+import { Exchange } from "../../../../../../lib/utils/hooks/useExchanges";
+import UploadedFiles from "../../../../../form/Upload/UploadedFiles";
+import Button from "../../../../../ui/Button";
+import Grid from "../../../../../ui/Grid";
+import Typography from "../../../../../ui/Typography";
+import ProposalTypeSummary from "../../components/ProposalTypeSummary";
+import { FormModel } from "../MakeProposalFormModel";
+import { proposals } from "./MakeAProposalStep/MakeAProposalStep";
+
+const ButtonsSection = styled.div`
+ padding-top: 2rem;
+ display: flex;
+ justify-content: space-between;
+`;
+
+interface Props {
+ onBackClick: () => void;
+ isValid: boolean;
+ exchange: Exchange;
+}
+
+export default function ReviewAndSubmitStep({
+ onBackClick,
+ isValid,
+ exchange
+}: Props) {
+ const [descriptionField] = useField({
+ name: FormModel.formFields.description.name
+ });
+ const [uploadField, , uploadFieldHelpers] = useField({
+ name: FormModel.formFields.upload.name
+ });
+ const [proposalsTypesField] = useField({
+ name: FormModel.formFields.proposalsTypes.name
+ });
+
+ const [refundPercentageField] = useField({
+ name: FormModel.formFields.refundPercentage.name
+ });
+ return (
+ <>
+
+ Review & Submit
+
+
+ Description
+
+ {descriptionField.value}
+ {
+ const files = uploadField.value.filter((_, idx) => idx !== index);
+ uploadFieldHelpers.setValue(files);
+ }}
+ />
+
+
+
+ Resolution proposal
+
+
+ {proposalsTypesField.value.map((proposalType) => {
+ if (proposalType.value === "refund") {
+ return (
+
+ );
+ }
+ return null;
+ })}
+
+
+
+
+ Sign & Submit
+
+
+ onBackClick()}>
+ Back
+
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/ResolveDisputeModal.tsx b/src/components/modal/components/Chat/ResolveDisputeModal.tsx
new file mode 100644
index 000000000..b2adbc70d
--- /dev/null
+++ b/src/components/modal/components/Chat/ResolveDisputeModal.tsx
@@ -0,0 +1,80 @@
+import { Info as InfoComponent } from "phosphor-react";
+import styled from "styled-components";
+
+import { colors } from "../../../../lib/styles/colors";
+import { Exchange } from "../../../../lib/utils/hooks/useExchanges";
+import { ProposalItem } from "../../../../pages/chat/types";
+import Button from "../../../ui/Button";
+import Grid from "../../../ui/Grid";
+import { ModalProps } from "../../ModalContext";
+import ExchangePreview from "./components/ExchangePreview";
+import ProposalTypeSummary from "./components/ProposalTypeSummary";
+
+interface Props {
+ exchange: Exchange;
+ proposal: ProposalItem;
+
+ // modal props
+ hideModal: NonNullable;
+ title: ModalProps["title"];
+}
+
+const ProposedSolution = styled.h4`
+ font-size: 1.25rem;
+ font-weight: 600;
+`;
+
+const Info = styled.div`
+ padding: 1.5rem;
+ background-color: ${colors.lightGrey};
+ margin: 2rem 0;
+ color: ${colors.darkGrey};
+ display: flex;
+ align-items: center;
+`;
+
+const InfoIcon = styled(InfoComponent)`
+ margin-right: 1.1875rem;
+`;
+
+const ButtonsSection = styled.div`
+ border-top: 2px solid ${colors.border};
+ padding-top: 2rem;
+ display: flex;
+ justify-content: space-between;
+`;
+
+export default function ResolveDisputeModal({
+ exchange,
+ hideModal,
+ proposal
+}: Props) {
+ return (
+ <>
+
+
+
+ Proposed solution
+
+
+
+ By accepting this proposal the dispute is resolved and the refund is
+ implemented
+
+
+ {
+ // TODO: implement
+ console.log("accept proposal");
+ }}
+ >
+ Accept proposal
+
+ hideModal()}>
+ Back
+
+
+ >
+ );
+}
diff --git a/src/components/modal/components/Chat/components/ExchangePreview.tsx b/src/components/modal/components/Chat/components/ExchangePreview.tsx
new file mode 100644
index 000000000..35611c6b4
--- /dev/null
+++ b/src/components/modal/components/Chat/components/ExchangePreview.tsx
@@ -0,0 +1,62 @@
+import styled from "styled-components";
+
+import { Exchange } from "../../../../../lib/utils/hooks/useExchanges";
+import Price from "../../../../price";
+import Grid from "../../../../ui/Grid";
+import SellerID from "../../../../ui/SellerID";
+
+interface Props {
+ exchange: Exchange;
+}
+
+const Name = styled.div`
+ font-size: 1.25rem;
+ font-weight: 600;
+`;
+
+const StyledPrice = styled(Price)`
+ > div {
+ align-items: flex-end;
+ }
+
+ small {
+ margin: 0 !important;
+ > * {
+ font-size: 0.75rem;
+ }
+ }
+`;
+
+export default function ExchangePreview({ exchange }: Props) {
+ const { offer } = exchange;
+ return (
+
+
+
+
+ {offer.metadata.name}
+
+
+
+
+
+ );
+}
diff --git a/src/components/modal/components/Chat/components/ProposalTypeSummary.tsx b/src/components/modal/components/Chat/components/ProposalTypeSummary.tsx
new file mode 100644
index 000000000..7d0781d5f
--- /dev/null
+++ b/src/components/modal/components/Chat/components/ProposalTypeSummary.tsx
@@ -0,0 +1,64 @@
+import { Check as CheckComponent } from "phosphor-react";
+import styled from "styled-components";
+
+import { colors } from "../../../../../lib/styles/colors";
+import { Exchange } from "../../../../../lib/utils/hooks/useExchanges";
+import { ProposalItem } from "../../../../../pages/chat/types";
+import { useConvertedPrice } from "../../../../price/useConvertedPrice";
+import Grid from "../../../../ui/Grid";
+
+const Line = styled.div`
+ border-right: 2px solid ${colors.border};
+ height: 0.75rem;
+ width: 0.001rem;
+ margin: 0 0.5rem;
+`;
+
+const CheckIcon = styled(CheckComponent)`
+ margin-right: 0.5rem;
+`;
+interface Props {
+ exchange: Exchange;
+ proposal: ProposalItem;
+}
+
+export default function ProposalTypeSummary({ proposal, exchange }: Props) {
+ const { offer } = exchange;
+ const { percentageAmount } = proposal;
+
+ const refund = (Number(offer.price) * Number(percentageAmount)) / 100;
+
+ const convertedRefund = useConvertedPrice({
+ value: refund.toString(),
+ decimals: offer.exchangeToken.decimals,
+ symbol: offer.exchangeToken.symbol
+ });
+ return (
+
+
+
+ {proposal.type}
+
+ {percentageAmount && percentageAmount !== "0" ? (
+
+
+
+
+ {convertedRefund.price} {offer.exchangeToken.symbol}
+
+ {convertedRefund.converted && (
+ <>
+
+
+ {convertedRefund.currency?.symbol} {convertedRefund.converted}
+
+ >
+ )}
+
+ {proposal.percentageAmount}%
+
+
+ ) : null}
+
+ );
+}
diff --git a/src/components/modal/components/DetailWidget.tsx b/src/components/modal/components/DetailWidget.tsx
index 04895baef..14c12be66 100644
--- a/src/components/modal/components/DetailWidget.tsx
+++ b/src/components/modal/components/DetailWidget.tsx
@@ -26,7 +26,7 @@ import Image from "../../ui/Image";
import Typography from "../../ui/Typography";
interface Props {
- id: string;
+ id?: string;
type: string;
state: string;
message: string;
diff --git a/src/components/modal/components/DisputeModal/DisputeModal.tsx b/src/components/modal/components/DisputeModal/DisputeModal.tsx
new file mode 100644
index 000000000..4a1da72f3
--- /dev/null
+++ b/src/components/modal/components/DisputeModal/DisputeModal.tsx
@@ -0,0 +1,257 @@
+import { CheckCircle, FileText, HandsClapping } from "phosphor-react";
+import React from "react";
+import styled from "styled-components";
+
+import { breakpoint } from "../../../../lib/styles/breakpoint";
+import { colors } from "../../../../lib/styles/colors";
+import Typography from "../../../ui/Typography";
+import { ModalProps } from "../../ModalContext";
+
+const ModalContainer = styled.div`
+ position: relative;
+ min-height: 31.25rem;
+ background: ${colors.white};
+ top: 50%;
+ padding-top: 2.5rem;
+ padding-bottom: 7.1875rem;
+ max-width: 92.5rem;
+ display: block;
+ margin-left: auto;
+ margin-right: auto;
+ margin-bottom: -1.875rem;
+ margin-top: -3.125rem;
+ min-height: max-content;
+ div {
+ svg {
+ color: ${colors.secondary};
+ }
+ }
+`;
+
+const ModalGrid = styled.div`
+ display: grid;
+ grid-gap: 1.5rem;
+ padding-left: 2.5rem;
+ padding-right: 4rem;
+ grid-template-columns: 100%;
+ ${breakpoint.m} {
+ grid-template-columns: 32% 32% 32%;
+ }
+ > div {
+ background-color: ${colors.lightGrey};
+ padding: 1.5625rem;
+ grid-gap: 0.3125rem;
+ position: relative;
+ &:nth-of-type(1) {
+ &:after {
+ background-color: ${colors.lightGrey};
+ content: "";
+ bottom: calc(-100% + 1px);
+ width: 100%;
+ top: unset;
+ clip-path: polygon(50% 10%, 0 0, 100% 0);
+ height: 100%;
+ position: absolute;
+ left: 0;
+ ${breakpoint.m} {
+ clip-path: polygon(0 0, 0% 100%, 13% 49%);
+ background-color: ${colors.lightGrey};
+ content: "";
+ height: 100%;
+ width: 6.25rem;
+ position: absolute;
+ right: -6.1875rem;
+ top: 0;
+ bottom: unset;
+ left: unset;
+ }
+ }
+ }
+ &:nth-of-type(2) {
+ &:before {
+ clip-path: polygon(50% 10%, 0 0, 100% 0);
+ background-color: ${colors.white};
+ content: "";
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ top: -0.0625rem;
+ ${breakpoint.m} {
+ clip-path: polygon(0 0, 0% 100%, 13% 49%);
+ background-color: ${colors.white};
+ content: "";
+ height: 100%;
+ width: 6.25rem;
+ position: absolute;
+ left: -0.0625rem;
+ top: 0;
+ }
+ }
+ &:after {
+ background-color: ${colors.lightGrey};
+ content: "";
+ bottom: calc(-100% + 0.0625rem);
+ width: 100%;
+ top: unset;
+ clip-path: polygon(50% 10%, 0 0, 100% 0);
+ height: 100%;
+ position: absolute;
+ left: 0;
+ ${breakpoint.m} {
+ clip-path: polygon(0 0, 0% 100%, 13% 49%);
+ background-color: ${colors.lightGrey};
+ content: "";
+ height: 100%;
+ width: 6.25rem;
+ position: absolute;
+ right: -6.1875rem;
+ top: 0;
+ bottom: unset;
+ left: unset;
+ }
+ }
+ }
+ &:nth-of-type(3) {
+ &:before {
+ clip-path: polygon(50% 10%, 0 0, 100% 0);
+ background-color: ${colors.white};
+ content: "";
+ height: 100%;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ top: -0.0625rem;
+ ${breakpoint.m} {
+ clip-path: polygon(0 0, 0% 100%, 13% 49%);
+ background-color: ${colors.white};
+ content: "";
+ height: 100%;
+ width: 6.25rem;
+ position: absolute;
+ left: -0.0625rem;
+ top: 0;
+ }
+ }
+ }
+ svg {
+ margin-bottom: 0.9375rem;
+ }
+ }
+`;
+
+const ButtonContainer = styled.div`
+ display: flex;
+ justify-content: space-between;
+ margin-top: 1.5rem;
+ border-top: 0.0625rem solid ${colors.lightGrey};
+ position: absolute;
+ width: 100%;
+ left: -2rem;
+ width: calc(100% + 3.875rem);
+ button {
+ font-family: "Plus Jakarta Sans";
+ font-weight: 600;
+ font-size: 1rem;
+ border: none;
+ margin-top: 1.25rem;
+ }
+ button:nth-of-type(1) {
+ background-color: ${colors.green};
+ padding: 1rem 2rem 1rem 2rem;
+ margin-left: 4.375rem;
+ }
+ button:nth-of-type(2) {
+ background: none;
+ padding: 1rem 2rem 1rem 2rem;
+ margin-right: 4.375rem;
+ }
+`;
+
+interface Props {
+ hideModal: NonNullable;
+}
+
+function DisputeModal({ hideModal }: Props) {
+ return (
+ <>
+
+
+
+
+
+ Explain your problem
+
+
+ Message the Seller about the issue. Most problems are resolved by
+ working with the Seller this way.
+
+
+
+
+
+ Submit Dispute
+
+
+ If you still need help or the Seller has not responded, you can
+ raise a dispute while the exchange is in the dispute period.
+
+
+
+
+
+ Take action
+
+
+ Find a solution to your dispute with the seller. If you are unable
+ to reach a resolution with the Seller, you always have the option
+ to escalate to a 3rd party dispute resolver.
+
+
+
+
+ Submit an issue
+ {
+ hideModal();
+ }}
+ >
+ Back
+
+
+
+ >
+ );
+}
+
+export default DisputeModal;
diff --git a/src/components/modal/components/Upload.tsx b/src/components/modal/components/Upload.tsx
new file mode 100644
index 000000000..2f33c116c
--- /dev/null
+++ b/src/components/modal/components/Upload.tsx
@@ -0,0 +1,85 @@
+import { Form, Formik, FormikProps } from "formik";
+import styled from "styled-components";
+import * as Yup from "yup";
+
+import {
+ FileWithEncodedData,
+ getFilesWithEncodedData
+} from "../../../lib/utils/files";
+import { validationOfFile } from "../../../pages/chat/components/UploadForm/const";
+import UploadForm from "../../../pages/chat/components/UploadForm/UploadForm";
+import Button from "../../ui/Button";
+import { ModalProps } from "../ModalContext";
+import { FormModel } from "./Chat/MakeProposal/MakeProposalFormModel";
+
+interface Props {
+ onUploadedFiles?: (files: File[]) => void;
+ onUploadedFilesWithData?: (filesWithData: FileWithEncodedData[]) => void;
+ onError?: (error: Error) => void;
+ withEncodedData?: boolean;
+ hideModal: NonNullable;
+ title: ModalProps["title"];
+}
+
+const ButtonsSection = styled.div`
+ padding-top: 2rem;
+ display: flex;
+ justify-content: space-between;
+`;
+
+const validationSchema = Yup.object({
+ [FormModel.formFields.upload.name]: validationOfFile({
+ isOptional: false
+ })
+});
+
+export default function Upload({
+ hideModal,
+ onUploadedFilesWithData,
+ onError,
+ onUploadedFiles,
+ withEncodedData // TODO: if this is defined, then onUploadedFilesWithData should be defined
+}: Props) {
+ return (
+ {
+ const files = values.upload;
+ if (withEncodedData) {
+ try {
+ const filesWithData = await getFilesWithEncodedData(files);
+
+ onUploadedFilesWithData?.(filesWithData);
+ } catch (error) {
+ onError?.(error as Error);
+ }
+ } else {
+ onUploadedFiles?.(files);
+ }
+
+ hideModal();
+ }}
+ initialValues={{
+ upload: [] as File[]
+ }}
+ validationSchema={validationSchema}
+ isInitialValid={false}
+ >
+ {(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ props: FormikProps
+ ) => {
+ const isFormValid = props.isValid;
+ return (
+
+ );
+ }}
+
+ );
+}
diff --git a/src/components/price/ConvertedPrice.tsx b/src/components/price/ConvertedPrice.tsx
new file mode 100644
index 000000000..d5b16eb87
--- /dev/null
+++ b/src/components/price/ConvertedPrice.tsx
@@ -0,0 +1,38 @@
+import { useMemo } from "react";
+
+import { CONFIG } from "../../lib/config";
+import { IPrice } from "../../lib/utils/convertPrice";
+
+interface Props {
+ price: IPrice;
+ withParethensis?: boolean;
+ isExchange?: boolean; // TODO: remove this prop
+}
+
+export default function ConvertedPrice({
+ price,
+ withParethensis,
+ isExchange
+}: Props) {
+ const ConvertedPriceComponent = useMemo(
+ () =>
+ price?.converted && (
+
+ {" "}
+
+ {withParethensis ? "(" : ""}
+ {CONFIG.defaultCurrency.symbol}
+ {" "}
+
+ {price.converted}
+ {withParethensis ? ")" : ""}
+
+
+ ),
+ [price, isExchange, withParethensis]
+ );
+ return <>{ConvertedPriceComponent}>;
+}
diff --git a/src/components/price/CurrencyIcon.tsx b/src/components/price/CurrencyIcon.tsx
index 8b630e7ab..2a09d4e63 100644
--- a/src/components/price/CurrencyIcon.tsx
+++ b/src/components/price/CurrencyIcon.tsx
@@ -14,11 +14,16 @@ const currencyImages = {
interface Props {
currencySymbol: string;
address?: string;
+ onError?: () => void;
}
const chain = "polygon";
-export default function CurrencyIcon({ currencySymbol, address }: Props) {
+export default function CurrencyIcon({
+ currencySymbol,
+ address,
+ onError
+}: Props) {
const [error, setError] = useState(false);
const symbolUpperCase =
currencySymbol.toUpperCase() as keyof typeof currencyImages;
@@ -42,6 +47,7 @@ export default function CurrencyIcon({ currencySymbol, address }: Props) {
src={url}
onError={() => {
setError(true);
+ onError?.();
}}
/>
);
diff --git a/src/components/price/index.tsx b/src/components/price/index.tsx
index 62c269caf..83b5fa198 100644
--- a/src/components/price/index.tsx
+++ b/src/components/price/index.tsx
@@ -1,10 +1,10 @@
-import { useMemo } from "react";
+import { useState } from "react";
import styled from "styled-components";
-import { CONFIG } from "../../lib/config";
import { breakpoint } from "../../lib/styles/breakpoint";
import Grid from "../ui/Grid";
import Typography from "../ui/Typography";
+import ConvertedPrice from "./ConvertedPrice";
import CurrencyIcon from "./CurrencyIcon";
import { useConvertedPrice } from "./useConvertedPrice";
@@ -61,22 +61,8 @@ export default function Price({
address,
...rest
}: IProps) {
- const price = useConvertedPrice({ value, decimals });
-
- const ConvertedPriceComponent = useMemo(
- () =>
- convert &&
- price && (
-
- {" "}
-
- {CONFIG.defaultCurrency.symbol}
- {" "}
- {price?.converted}
-
- ),
- [convert, price, isExchange]
- );
+ const [isSymbolShown, setIsSymbolShown] = useState(false); // TODO: remove once CSS :has is supported
+ const price = useConvertedPrice({ value, decimals, symbol: currencySymbol });
return (
@@ -89,8 +75,14 @@ export default function Price({
-
+ setIsSymbolShown(true)}
+ />
{price?.currency ? (
<>
{price.fractions === "0"
@@ -101,7 +93,9 @@ export default function Price({
price.price
)}
- {price?.currency && ConvertedPriceComponent}
+ {convert && price?.currency && (
+
+ )}
) : (
"-"
diff --git a/src/components/price/useConvertedPrice.tsx b/src/components/price/useConvertedPrice.tsx
index 443c67d6f..9a338e633 100644
--- a/src/components/price/useConvertedPrice.tsx
+++ b/src/components/price/useConvertedPrice.tsx
@@ -11,8 +11,13 @@ import {
interface Props {
value: string;
decimals: string;
+ symbol: string;
}
-export const useConvertedPrice = ({ value, decimals }: Props): IPrice => {
+export const useConvertedPrice = ({
+ value,
+ decimals,
+ symbol
+}: Props): IPrice => {
const [convertedPrice, setConvertedPrice] =
useState(null);
@@ -26,9 +31,13 @@ export const useConvertedPrice = ({ value, decimals }: Props): IPrice => {
}, [value, decimals]);
const getConvertedPrice = useCallback(async () => {
- const newPrice = await convertPrice(price, CONFIG.defaultCurrency);
+ const newPrice = await convertPrice(
+ price,
+ symbol.toUpperCase(),
+ CONFIG.defaultCurrency
+ );
setConvertedPrice(newPrice);
- }, [price]);
+ }, [price, symbol]);
useEffect(() => {
getConvertedPrice();
diff --git a/src/components/step/MultiSteps.tsx b/src/components/step/MultiSteps.tsx
index 4a4d1e8f3..dd9855f57 100644
--- a/src/components/step/MultiSteps.tsx
+++ b/src/components/step/MultiSteps.tsx
@@ -9,6 +9,7 @@ type StepData = {
};
interface Props {
+ disableInactiveSteps?: boolean;
active?: number;
data: Array;
callback?: (cur: number) => void;
@@ -17,6 +18,7 @@ export default function MultiSteps({
data,
active,
callback,
+ disableInactiveSteps,
...props
}: Props) {
const [current, setCurrent] = useState(active || 0);
@@ -25,7 +27,7 @@ export default function MultiSteps({
}, [active]);
return (
-
+
{data.map((el, i) => {
const steps = Array.from(Array(el.steps).keys());
const newData = data.slice(0, i);
@@ -44,12 +46,16 @@ export default function MultiSteps({
: currentKey < current
? StepState.Done
: StepState.Inactive;
+ const isStepDisabled =
+ !callback ||
+ (disableInactiveSteps && StepState.Inactive === state);
return (
{
- setCurrent(currentKey);
- if (callback) {
+ if (!isStepDisabled) {
+ setCurrent(currentKey);
callback(currentKey);
}
}}
diff --git a/src/components/step/Step.styles.ts b/src/components/step/Step.styles.ts
index c905e8815..fd60040a5 100644
--- a/src/components/step/Step.styles.ts
+++ b/src/components/step/Step.styles.ts
@@ -4,9 +4,12 @@ import { transition } from "../../components/ui/styles";
import { colors } from "../../lib/styles/colors";
import { StepState } from "./Step";
-export const StepStyle = styled.div.attrs((props: { state: StepState }) => ({
- state: props.state
-}))`
+export const StepStyle = styled.div.attrs(
+ (props: { state: StepState; disabled: boolean }) => ({
+ state: props.state,
+ disabled: props.disabled
+ })
+)`
position: relative;
display: flex;
flex-direction: row;
@@ -16,15 +19,16 @@ export const StepStyle = styled.div.attrs((props: { state: StepState }) => ({
min-width: 6rem;
height: 1.25rem;
- ${({ state }) =>
+ ${({ state, disabled }) =>
state !== StepState.Active &&
+ !disabled &&
css`
:hover {
cursor: pointer;
}
`}
- ${({ state }) =>
+ ${({ state, disabled }) =>
state === StepState.Inactive &&
css`
background: ${colors.white};
@@ -44,15 +48,17 @@ export const StepStyle = styled.div.attrs((props: { state: StepState }) => ({
width: 0.25rem;
height: 0.25rem;
}
-
- :hover {
- background: ${colors.lightGrey};
- :before {
- background: ${colors.darkGrey};
- width: 0.5rem;
- height: 0.5rem;
+ ${!disabled &&
+ css`
+ :hover {
+ background: ${colors.lightGrey};
+ :before {
+ background: ${colors.darkGrey};
+ width: 0.5rem;
+ height: 0.5rem;
+ }
}
- }
+ `}
> div {
display: none;
@@ -86,7 +92,7 @@ export const StepStyle = styled.div.attrs((props: { state: StepState }) => ({
}
`}
- ${({ state }) =>
+ ${({ state, disabled }) =>
state === StepState.Done &&
css`
background: var(--primary);
@@ -115,14 +121,16 @@ export const StepStyle = styled.div.attrs((props: { state: StepState }) => ({
> div {
display: none;
}
-
- :hover {
- background: ${colors.black};
- :before,
- :after {
- background: var(--primary);
+ ${!disabled &&
+ css`
+ :hover {
+ background: ${colors.black};
+ :before,
+ :after {
+ background: var(--primary);
+ }
}
- }
+ `}
`}
`;
diff --git a/src/components/step/Step.tsx b/src/components/step/Step.tsx
index 18a821dfd..52e7268b3 100644
--- a/src/components/step/Step.tsx
+++ b/src/components/step/Step.tsx
@@ -1,5 +1,3 @@
-import React from "react";
-
import { StepStyle } from "./Step.styles";
export enum StepState {
@@ -12,6 +10,7 @@ export interface Props {
className?: string;
onClick?: () => void;
state?: StepState;
+ disabled?: boolean;
}
export default function Step({ state = StepState.Inactive, ...props }: Props) {
diff --git a/src/components/timeline/Timeline.tsx b/src/components/timeline/Timeline.tsx
new file mode 100644
index 000000000..b5e24089a
--- /dev/null
+++ b/src/components/timeline/Timeline.tsx
@@ -0,0 +1,67 @@
+import React, { Fragment } from "react";
+import styled from "styled-components";
+
+import { colors } from "../../lib/styles/colors";
+import { ReactComponent as Dot } from "./timeline-dot.svg";
+import { ReactComponent as VerticalLineSVG } from "./timeline-line.svg";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ transform: translateX(-48%);
+`;
+
+const DotWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ position: relative;
+ [data-status] {
+ position: absolute;
+ left: 1.5rem;
+ top: -0.5rem;
+ width: max-content;
+ }
+`;
+
+const VerticalLine = styled(VerticalLineSVG).attrs({
+ height: "48px",
+ viewBox: "0 0 2 48"
+})``;
+
+const StyledStatus = styled.span`
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 21px;
+`;
+
+const StyledDate = styled.span`
+ color: ${colors.darkGrey};
+ font-size: 0.75rem;
+`;
+
+interface Props {
+ timesteps: { text: string; date?: string }[];
+}
+export default function Timeline({ timesteps }: Props) {
+ return (
+
+ {timesteps.map((step, index) => {
+ return (
+
+
+
+
+ {step.text}
+
+ {step.date}
+
+
+ {index !== timesteps.length - 1 && }
+
+ );
+ })}
+
+ );
+}
diff --git a/src/components/timeline/timeline-dot.svg b/src/components/timeline/timeline-dot.svg
new file mode 100644
index 000000000..9a71d2dcf
--- /dev/null
+++ b/src/components/timeline/timeline-dot.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/components/timeline/timeline-line.svg b/src/components/timeline/timeline-line.svg
new file mode 100644
index 000000000..74c615d96
--- /dev/null
+++ b/src/components/timeline/timeline-line.svg
@@ -0,0 +1,4 @@
+
+
+
+
\ No newline at end of file
diff --git a/src/components/ui/Button.tsx b/src/components/ui/Button.tsx
index 0065652ee..f6b8501f7 100644
--- a/src/components/ui/Button.tsx
+++ b/src/components/ui/Button.tsx
@@ -33,6 +33,10 @@ const BaseButton = styled.button<{
`
: ""
}
+ ${
+ props.theme.hover.borderColor &&
+ `border-color:${props.theme.hover.borderColor}`
+ };
}
`}
@@ -72,6 +76,17 @@ const allThemes = {
color: colors.white
}
},
+ primaryInverse: {
+ color: colors.white,
+ borderColor: "var(--secondary)",
+ background: "var(--secondary)",
+ borderWidth: 2,
+ hover: {
+ color: "var(--secondary)",
+ borderColor: "var(--secondary)",
+ background: colors.white
+ }
+ },
secondary: {
color: colors.black,
background: "var(--primary)",
@@ -83,7 +98,7 @@ const allThemes = {
}
},
outline: {
- text: colors.black,
+ color: colors.black,
borderColor: colors.border,
borderWidth: 1,
hover: {
@@ -91,6 +106,13 @@ const allThemes = {
color: "var(--secondary)"
}
},
+ orange: {
+ color: colors.orange,
+ borderColor: colors.border,
+ hover: {
+ background: colors.border
+ }
+ },
blank: {
color: `${colors.black}4d`,
padding: "0.75rem 0.5rem",
@@ -98,6 +120,16 @@ const allThemes = {
color: colors.black
}
},
+ blankOutline: {
+ color: colors.black,
+ padding: "1rem 2rem",
+ borderWidth: 1,
+ hover: {
+ borderColor: colors.secondary,
+ background: colors.border,
+ color: colors.black
+ }
+ },
warning: {
color: colors.black,
borderColor: colors.orange,
diff --git a/src/components/ui/Grid.tsx b/src/components/ui/Grid.tsx
index 187e1d443..9166b9c4b 100644
--- a/src/components/ui/Grid.tsx
+++ b/src/components/ui/Grid.tsx
@@ -1,20 +1,25 @@
+import React from "react";
import styled from "styled-components";
type JustifyContent =
| "flex-start"
| "center"
| "flex-end"
+ | "space-evently"
| "space-between"
| "space-around"
| "stretch";
type AlignItems = "flex-start" | "center" | "flex-end" | "baseline";
type FlexDirection = "row" | "column" | "row-reverse" | "column-reverse";
export interface IGrid {
+ $width?: string;
+ $height?: string;
alignItems?: AlignItems;
flexBasis?: string;
flexDirection?: FlexDirection;
justifyContent?: JustifyContent;
flexGrow?: string;
+ flexShrink?: string;
flexWrap?: string;
gap?: string;
flex?: string;
@@ -25,12 +30,14 @@ export interface IGrid {
}
const Container = styled.div`
- width: 100%;
+ width: ${({ $width }) => $width || "100%"};
+ height: ${({ $height }) => $height || "initial"};
display: flex;
align-items: ${({ alignItems }) => alignItems || "center"};
flex-basis: ${({ flexBasis }) => flexBasis || "auto"};
flex-direction: ${({ flexDirection }) => flexDirection || "row"};
- flex-grow: ${({ flexGrow }) => flexGrow || "0"};
+ flex-grow: ${({ flexGrow }) => flexGrow || "initial"};
+ flex-shrink: ${({ flexShrink }) => flexShrink || "initial"};
justify-content: ${({ justifyContent }) => justifyContent || "space-between"};
${({ flexWrap }) => (flexWrap ? `flex-wrap:${flexWrap};` : "")}
@@ -45,6 +52,7 @@ const Container = styled.div`
const Grid: React.FC<
{
children: React.ReactNode;
+ as?: React.ElementType;
} & IGrid &
React.HTMLAttributes
> = ({ children, ...props }) => {
diff --git a/src/components/ui/SellerID.tsx b/src/components/ui/SellerID.tsx
index 94a212966..4f608488d 100644
--- a/src/components/ui/SellerID.tsx
+++ b/src/components/ui/SellerID.tsx
@@ -16,12 +16,12 @@ const AddressContainer = styled(Grid)`
margin: 0;
`;
-const SellerContainer = styled.div`
- cursor: pointer;
+const SellerContainer = styled.div<{ $hasCursorPointer: boolean }>`
+ ${({ $hasCursorPointer }) => $hasCursorPointer && `cursor: pointer;`}
+
display: flex;
align-items: center;
gap: 10px;
- cursor: pointer;
`;
const SellerInfo = styled.div`
@@ -53,40 +53,62 @@ const SellerID: React.FC<
{
children?: React.ReactNode;
seller: subgraph.OfferFieldsFragment["seller"];
+ accountImageSize?: number;
offerName: string;
withProfileImage: boolean;
- } & IGrid &
- React.HTMLAttributes
-> = ({ seller, children, offerName, withProfileImage, ...rest }) => {
+ withProfileText?: boolean;
+ onClick?: null | undefined | React.MouseEventHandler;
+ } & IGrid
+> = ({
+ seller,
+ children,
+ offerName,
+ withProfileImage,
+ onClick,
+ accountImageSize,
+ withProfileText = true,
+ ...rest
+}) => {
const navigate = useKeepQueryParamsNavigate();
-
const sellerId = seller?.id;
const sellerAddress = seller?.operator;
const artist = getOfferArtist(offerName);
+ const hasCursorPointer = !!onClick || onClick === undefined;
return (
-
+
{
- e.stopPropagation();
- navigate({
- pathname: generatePath(BosonRoutes.Account, {
- [UrlParameters.accountId]: sellerAddress
- })
- });
+ if (onClick) {
+ onClick(e);
+ } else if (onClick !== null) {
+ e.stopPropagation();
+ navigate({
+ pathname: generatePath(BosonRoutes.Account, {
+ [UrlParameters.accountId]: sellerAddress
+ })
+ });
+ }
}}
+ data-seller-container
>
{withProfileImage && (
-
+
{artist.toLocaleLowerCase() === "boson protocol" ? (
) : (
-
+
)}
)}
-
- {artist ? artist : `Seller ID: ${sellerId}`}
-
+ {withProfileText && (
+
+ {artist ? artist : `Seller ID: ${sellerId}`}
+
+ )}
{children || ""}
diff --git a/src/index.tsx b/src/index.tsx
index 695baff3c..f8798588b 100644
--- a/src/index.tsx
+++ b/src/index.tsx
@@ -10,6 +10,7 @@ import WalletConnectionProvider from "./components/WalletConnectionProvider";
import { BosonRoutes, OffersRoutes } from "./lib/routing/routes";
import PrivateAccount from "./pages/account/private/PrivateAccountContainer";
import PublicOrPrivateAccount from "./pages/account/public/PublicOrPrivateAccount";
+import Chat from "./pages/chat/Chat";
import CreateOffer from "./pages/create-offer/CreateOffer";
import CustomStore from "./pages/custom-store/CustomStore";
import Exchange from "./pages/exchange/Exchange";
@@ -44,45 +45,53 @@ root.render(
- }>
- } />
- {[
- OffersRoutes.Root,
- BosonRoutes.Explore,
- BosonRoutes.ExplorePage,
- BosonRoutes.ExplorePageByIndex
- ].map((route) => (
- } />
- ))}
-
- } />
- }
- />
- } />
- }
- />
+ <>
}
- />
- }
- />
+ path={`${BosonRoutes.Chat}/*`}
+ element={ }
+ >
+ } />
+
+ }>
+ } />
+ {[
+ OffersRoutes.Root,
+ BosonRoutes.Explore,
+ BosonRoutes.ExplorePage,
+ BosonRoutes.ExplorePageByIndex
+ ].map((route) => (
+ } />
+ ))}
-
- Page not found
-
- }
- />
-
+ } />
+ }
+ />
+ } />
+ }
+ />
+ }
+ />
+ }
+ />
+
+
+ Page not found
+
+ }
+ />
+
+ >
diff --git a/src/lib/config.ts b/src/lib/config.ts
index 91723deab..1575651ea 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -11,16 +11,14 @@ export const config = getDefaultConfig({ chainId: REACT_APP_CHAIN_ID });
export const CONFIG = {
...config,
- enableSentryLogging: ["local", "testing"].includes(config.envName),
+ enableSentryLogging: false, // ["local", "testing"].includes(config.envName),
dateFormat: process.env.DATE_FORMAT || "YYYY/MM/DD",
+ shortDateFormat: process.env.SHORT_DATE_FORMAT || "MMM DD, YYYY",
fullDateFormat: process.env.FULL_DATE_FORMAT || "YYYY-MM-DDTHH:mm:ssZ[Z]",
defaultCurrency: {
ticker: process.env.DEFAULT_CURRENCY || "USD",
symbol: process.env.DEFAULT_CURRENCY_SYMBOL || "$"
},
- maxUploadSize: process.env.MAX_UPLOAD_SIZE
- ? Number(process.env.MAX_UPLOAD_SIZE)
- : 2 * 1024 * 1024,
widgetsUrl: process.env.REACT_APP_WIDGETS_URL || config.widgetsUrl,
chainId: REACT_APP_CHAIN_ID,
ipfsMetadataStorageUrl:
diff --git a/src/lib/routing/routes.tsx b/src/lib/routing/routes.tsx
index 8db5788a6..045b9ac13 100644
--- a/src/lib/routing/routes.tsx
+++ b/src/lib/routing/routes.tsx
@@ -11,7 +11,9 @@ export const BosonRoutes = {
Account: `/account/:${UrlParameters.accountId}`,
CreateStorefront: "/custom-store",
TermsOfUse: "/terms-of-use", // TODO: add page to handle this route
- LearnMore: "/learn-more" // TODO: add page to handle this route
+ LearnMore: "/learn-more", // TODO: add page to handle this route
+ Chat: "/chat",
+ ChatMessage: `/chat/:${UrlParameters.exchangeId}`
} as const;
export const OffersRoutes = {
diff --git a/src/lib/styles/GlobalStyle.tsx b/src/lib/styles/GlobalStyle.tsx
index 2ef9c81cb..07fc42b6f 100644
--- a/src/lib/styles/GlobalStyle.tsx
+++ b/src/lib/styles/GlobalStyle.tsx
@@ -120,5 +120,9 @@ const GlobalStyle = createGlobalStyle<{
img, svg, input {
user-select: none;
}
+
+ [data-rk][role=dialog] {
+ top: 0; // rainbowkit modal backdrop should fill up all height
+ }
`;
export default GlobalStyle;
diff --git a/src/lib/styles/colors.ts b/src/lib/styles/colors.ts
index be862bed4..ac2f62458 100644
--- a/src/lib/styles/colors.ts
+++ b/src/lib/styles/colors.ts
@@ -7,6 +7,7 @@ export const colors = {
darkOrange: "darkorange",
darkRed: "darkred",
cyan: "#00FFFF",
+ grey2: "#D3D5DB",
/*
TODO: Remove what's above when refactor/redesign of all pages is done
@@ -24,8 +25,9 @@ export const colors = {
// Basic
black: "#09182C",
darkGrey: "#556072",
+ darkGreyTimeStamp: "#E8EAF1",
lightGrey: "#F1F3F9",
- border: "rgba(85,96,114,0.06)",
+ border: "#5560720f",
white: "#FFFFFF",
primaryBgColor: "#FFFFFF"
};
diff --git a/src/lib/styles/zIndex.ts b/src/lib/styles/zIndex.ts
index 017911f61..67dc0d80d 100644
--- a/src/lib/styles/zIndex.ts
+++ b/src/lib/styles/zIndex.ts
@@ -1,7 +1,9 @@
export const zIndex = {
+ Default: 0,
Button: 1,
OfferCard: 2,
OfferStatus: 5,
+ ChatSeparator: 9,
Carousel: 10,
LandingTitle: 20,
Calendar: 30,
diff --git a/src/lib/types/helpers.ts b/src/lib/types/helpers.ts
new file mode 100644
index 000000000..7caaeb885
--- /dev/null
+++ b/src/lib/types/helpers.ts
@@ -0,0 +1,4 @@
+export type DeepReadonly = {
+ readonly // TODO: remove once https://github.com/microsoft/TypeScript/issues/13923 is merged
+ [P in keyof T]: DeepReadonly;
+};
diff --git a/src/lib/utils/chat/message.ts b/src/lib/utils/chat/message.ts
new file mode 100644
index 000000000..db7cff6ef
--- /dev/null
+++ b/src/lib/utils/chat/message.ts
@@ -0,0 +1,43 @@
+import {
+ MessageData,
+ MessageType,
+ ProposalContent
+} from "@bosonprotocol/chat-sdk/dist/cjs/util/definitions";
+
+import { DeepReadonly } from "../../types/helpers";
+// TODO: use yup
+// eslint-disable-next-line @typescript-eslint/no-unused-vars
+export const validateMessage = (
+ message: DeepReadonly
+): boolean => {
+ try {
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const messageContent = message.data.content;
+ const messageContentType = message.data.contentType;
+ // const isRegularMessage =
+ // typeof message.data.content.value === "string" &&
+ // messageContentType === MessageType.String;
+ // const isFileMessage = messageContentType === MessageType.File;
+ const isProposalMessage = messageContentType === MessageType.Proposal;
+ if (isProposalMessage) {
+ const proposalContent = message.data
+ .content as unknown as ProposalContent;
+ const isNumber = proposalContent.value.proposals.every((proposal) => {
+ const isNumber = isNumeric(proposal.percentageAmount);
+ return isNumber;
+ });
+ if (!isNumber) {
+ return false;
+ }
+ }
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+
+ return true; // TODO: implement
+};
+
+function isNumeric(n: string) {
+ return !isNaN(parseFloat(n)) && isFinite(n as unknown as number);
+}
diff --git a/src/lib/utils/convertPrice.ts b/src/lib/utils/convertPrice.ts
index ee7a07108..acd6e26b4 100644
--- a/src/lib/utils/convertPrice.ts
+++ b/src/lib/utils/convertPrice.ts
@@ -6,7 +6,7 @@ interface Currency {
export interface IPricePassedAsAProp {
integer: string;
fractions: string;
- converted: string;
+ converted: string | null;
currency: Currency;
}
@@ -14,18 +14,19 @@ export interface IPrice {
price: string | null;
integer?: string;
fractions?: string;
- converted?: string;
+ converted?: string | null;
currency?: Currency;
}
export const convertPrice = async (
price: string | null,
+ priceSymbol: string,
currency: Currency
): Promise => {
return new Promise((resolve) => {
// TODO: change that
fetch(
- `https://api.exchangerate.host/convert?from=ETH&to=${currency.ticker}&source=crypto`
+ `https://api.exchangerate.host/convert?from=${priceSymbol}&to=${currency.ticker}&source=crypto`
)
.then((response) => response.json())
.then((data) => {
@@ -35,7 +36,10 @@ export const convertPrice = async (
resolve({
integer,
fractions,
- converted: (Number(conversionRate) * Number(price)).toFixed(2),
+ converted:
+ conversionRate === null
+ ? null
+ : (Number(conversionRate) * Number(price)).toFixed(2),
currency
});
})
diff --git a/src/lib/utils/files.ts b/src/lib/utils/files.ts
new file mode 100644
index 000000000..e9fc37d36
--- /dev/null
+++ b/src/lib/utils/files.ts
@@ -0,0 +1,29 @@
+export type FileWithEncodedData = File & { encodedData: string };
+
+export const getFilesWithEncodedData = async (
+ files: File[]
+): Promise => {
+ const promises = [];
+ for (const file of files as FileWithEncodedData[]) {
+ promises.push(
+ new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onloadend = (e: ProgressEvent) => {
+ const encodedData = e.target?.result?.toString() || "";
+ file.encodedData = encodedData;
+ resolve(file);
+ };
+ reader.onerror = (error) => {
+ console.error(error);
+ reject(error);
+ };
+ reader.readAsDataURL(file);
+ })
+ );
+ }
+ const filesWithNullableEncodedData = await Promise.all(promises);
+ const filesWithData = filesWithNullableEncodedData.filter((file) => {
+ return !!file.encodedData;
+ });
+ return filesWithData;
+};
diff --git a/src/lib/utils/getPrices.ts b/src/lib/utils/getPrices.ts
new file mode 100644
index 000000000..dbfa70731
--- /dev/null
+++ b/src/lib/utils/getPrices.ts
@@ -0,0 +1,20 @@
+import { Offer } from "../types/offer";
+import { IPrice } from "./convertPrice";
+
+export const getBuyerCancelPenalty = (
+ offer: Offer,
+ convertedPrice: IPrice | null
+) => {
+ const priceNumber = Number(convertedPrice?.converted);
+
+ const buyerCancelationPenaltyPercentage =
+ Number(offer.buyerCancelPenalty) / Number(offer.price);
+ const buyerCancelationPenalty = buyerCancelationPenaltyPercentage * 100;
+ const convertedBuyerCancelationPenalty = (
+ buyerCancelationPenaltyPercentage * priceNumber
+ ).toFixed(2);
+ return {
+ buyerCancelationPenalty,
+ convertedBuyerCancelationPenalty
+ };
+};
diff --git a/src/lib/utils/hooks/chat/useInfiniteThread.ts b/src/lib/utils/hooks/chat/useInfiniteThread.ts
new file mode 100644
index 000000000..31d44e7b7
--- /dev/null
+++ b/src/lib/utils/hooks/chat/useInfiniteThread.ts
@@ -0,0 +1,157 @@
+import {
+ ThreadId,
+ ThreadObject
+} from "@bosonprotocol/chat-sdk/dist/cjs/util/definitions";
+import { matchThreadIds } from "@bosonprotocol/chat-sdk/dist/cjs/util/functions";
+import dayjs from "dayjs";
+import { useEffect, useState } from "react";
+
+import { useChatContext } from "../../../../pages/chat/ChatProvider/ChatContext";
+
+interface Props {
+ dateStep: "day" | "week" | "month" | "year";
+ counterParty: string;
+ threadId: ThreadId | null | undefined;
+ dateIndex: number;
+ onFinishFetching: () => void;
+}
+const genesisDate = new Date("2022-07-28"); // TODO: change
+export function useInfiniteThread({
+ dateStep,
+ dateIndex,
+ counterParty,
+ threadId,
+ onFinishFetching
+}: Props): {
+ data: ThreadObject | null;
+ isLoading: boolean;
+ isError: boolean;
+ error: Error | null;
+ isBeginningOfTimes: boolean;
+ lastData: ThreadObject | null;
+} {
+ const { bosonXmtp } = useChatContext();
+ const [areThreadsLoading, setThreadsLoading] = useState(false);
+ const [threadsXmtp, setThreadsXmtp] = useState([]);
+ const [lastThreadXmtp, setLastThreadXmtp] = useState(
+ null
+ );
+ const [error, setError] = useState(null);
+ const [isBeginningOfTimes, setIsBeginningOfTimes] = useState(false);
+ useEffect(() => {
+ if (!bosonXmtp || !threadId) {
+ return;
+ }
+ if (dateIndex > 0) {
+ return;
+ }
+ const endTime = dayjs()
+ .add(dateIndex - 1, dateStep)
+ .toDate();
+ const startTime = dayjs(endTime).add(1, dateStep).toDate();
+ const isBeginning =
+ dayjs(startTime).isBefore(genesisDate) ||
+ dayjs(startTime).isSame(genesisDate, "day");
+ setIsBeginningOfTimes(isBeginning);
+ if (isBeginning) {
+ console.log("threads reached beginning!", {
+ startTime,
+ genesisDate,
+ threadId
+ });
+ return;
+ }
+ setThreadsLoading(true);
+
+ console.log("requesting threads from", startTime, "until", endTime, {
+ threadId,
+ counterParty,
+ areThreadsLoading
+ });
+ bosonXmtp
+ .getThread(threadId, counterParty, {
+ startTime: endTime,
+ endTime: startTime,
+ pageSize: 100
+ })
+ .then((threadObject) => {
+ console.log(
+ "FINISH requesting threads from",
+ startTime,
+ "until",
+ endTime,
+ {
+ threadId,
+ counterParty,
+ threadObject: !!threadObject
+ }
+ );
+ setLastThreadXmtp(threadObject);
+ if (!threadObject) {
+ return;
+ }
+ const mergedThreads = mergeThreads(threadsXmtp, [threadObject]);
+ setThreadsXmtp(mergedThreads);
+ })
+ .catch((err) => {
+ console.error(
+ // `Error while requesting threadId: ${JSON.stringify(threadId)}`,
+ err
+ );
+ setError(err);
+ })
+ .finally(() => {
+ setThreadsLoading(false);
+ onFinishFetching();
+ });
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [bosonXmtp, dateIndex, counterParty, dateStep, threadId]);
+
+ return {
+ data: threadsXmtp[0] || null,
+ isLoading: areThreadsLoading,
+ isError: !!error,
+ error,
+ isBeginningOfTimes,
+ lastData: lastThreadXmtp || null
+ };
+}
+
+const mergeThreads = (
+ threadsA: ThreadObject[],
+ threadsB: ThreadObject[]
+): ThreadObject[] => {
+ const resultingThreads = [...threadsA];
+ if (!resultingThreads.length) {
+ return [...threadsB];
+ }
+ for (const thread of resultingThreads) {
+ const matchingThread = threadsB.find((threadB) =>
+ matchThreadIds(thread.threadId, threadB.threadId)
+ );
+ if (matchingThread) {
+ // messages in matchingThread should be all after or all before the messages in thread
+ if (thread.messages.length && matchingThread.messages.length) {
+ const afterFirst =
+ thread.messages[0].timestamp >= matchingThread.messages[0].timestamp;
+ const afterLast =
+ thread.messages[thread.messages.length - 1].timestamp >=
+ matchingThread.messages[matchingThread.messages.length - 1].timestamp;
+ if (afterFirst && afterLast) {
+ thread.messages = [...matchingThread.messages, ...thread.messages];
+ } else if (!afterFirst && !afterLast) {
+ thread.messages = [...thread.messages, ...matchingThread.messages];
+ } else {
+ throw new Error(
+ `Overlapping messages in threads with id ${JSON.stringify(
+ thread.threadId
+ )} ${JSON.stringify({ afterFirst, afterLast })}`
+ );
+ }
+ } else {
+ thread.messages = matchingThread.messages || [];
+ }
+ }
+ }
+ return resultingThreads;
+};
diff --git a/src/lib/utils/hooks/offers/__tests_/memo.test.ts b/src/lib/utils/hooks/offers/__tests_/memo.test.ts
index 8a75d3093..e27ef79cc 100644
--- a/src/lib/utils/hooks/offers/__tests_/memo.test.ts
+++ b/src/lib/utils/hooks/offers/__tests_/memo.test.ts
@@ -1,3 +1,6 @@
+/**
+ * @jest-environment ./jest.custom.env
+ */
import { getFirstNOffers } from "../../../../../../e2e-tests/utils/getFirstNOffers";
import { memoMergeAndSortOffers } from "../memo";
diff --git a/src/lib/utils/hooks/useBuyerSellerAccounts.ts b/src/lib/utils/hooks/useBuyerSellerAccounts.ts
new file mode 100644
index 000000000..1f370cd00
--- /dev/null
+++ b/src/lib/utils/hooks/useBuyerSellerAccounts.ts
@@ -0,0 +1,23 @@
+import { useBuyers } from "./useBuyers";
+import { useSellers } from "./useSellers";
+
+export const useBuyerSellerAccounts = (address: string) => {
+ const { data: sellers, ...restSellers } = useSellers({
+ admin: address
+ });
+ const { data: buyers, ...restBuyers } = useBuyers({
+ wallet: address
+ });
+ const sellerId = sellers?.[0]?.id || "";
+ const buyerId = buyers?.[0]?.id || "";
+ return {
+ seller: {
+ ...restSellers,
+ sellerId
+ },
+ buyer: {
+ ...restBuyers,
+ buyerId
+ }
+ };
+};
diff --git a/src/lib/utils/hooks/useEffectDebugger.ts b/src/lib/utils/hooks/useEffectDebugger.ts
new file mode 100644
index 000000000..15d84e5e4
--- /dev/null
+++ b/src/lib/utils/hooks/useEffectDebugger.ts
@@ -0,0 +1,41 @@
+// eslint-disable-next-line @typescript-eslint/ban-ts-comment
+// @ts-nocheck
+import { useEffect, useRef } from "react";
+
+const usePrevious = (value: unknown, initialValue: unknown) => {
+ const ref = useRef(initialValue);
+ useEffect(() => {
+ ref.current = value;
+ });
+ return ref.current;
+};
+
+export const useEffectDebugger = (
+ effectHook: unknown,
+ dependencies: unknown[],
+ dependencyNames = []
+) => {
+ const previousDeps = usePrevious(dependencies, []);
+
+ const changedDeps = dependencies.reduce((accum, dependency, index) => {
+ if (dependency !== previousDeps[index]) {
+ const keyName = dependencyNames[index] || index;
+ return {
+ ...accum,
+ [keyName]: {
+ before: previousDeps[index],
+ after: dependency
+ }
+ };
+ }
+
+ return accum;
+ }, {});
+
+ if (Object.keys(changedDeps).length) {
+ console.log("[use-effect-debugger] ", changedDeps);
+ }
+
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ useEffect(effectHook, dependencies);
+};
diff --git a/src/lib/utils/hooks/useExchanges.ts b/src/lib/utils/hooks/useExchanges.ts
index 0d9513154..75e8b4f8d 100644
--- a/src/lib/utils/hooks/useExchanges.ts
+++ b/src/lib/utils/hooks/useExchanges.ts
@@ -7,11 +7,31 @@ import { checkOfferMetadata } from "../validators";
import { offerGraphQl } from "./offers/graphql";
import { getOfferImage } from "./offers/placeholders";
+export type Exchange = {
+ id: string;
+ committedDate: string;
+ disputed: boolean;
+ expired: boolean;
+ finalizedDate: string;
+ redeemedDate: string;
+ state: string;
+ validUntilDate: string;
+ seller: {
+ id: string;
+ };
+ buyer: {
+ id: string;
+ wallet: string;
+ };
+ offer: Offer;
+};
+
interface Props {
disputed: boolean | null;
sellerId?: string;
buyerId?: string;
id?: string;
+ id_in?: string[];
orderBy?: string | null | undefined;
orderDirection?: string | null | undefined;
}
@@ -27,6 +47,7 @@ export function useExchanges(
sellerId,
buyerId,
id,
+ id_in,
orderBy = "id",
orderDirection = "desc"
} = props;
@@ -34,24 +55,7 @@ export function useExchanges(
["exchanges", props],
async () => {
const result = await fetchSubgraph<{
- exchanges: {
- id: string;
- committedDate: string;
- disputed: boolean;
- expired: boolean;
- finalizedDate: string;
- redeemedDate: string;
- state: string;
- validUntilDate: string;
- seller: {
- id: string;
- };
- buyer: {
- id: string;
- wallet: string;
- };
- offer: Offer;
- }[];
+ exchanges: Exchange[];
}>(
gql`
query GetExchanges($disputed: Boolean, $sellerId: String, $buyerId: String, $orderBy: String, $orderDirection: String) {
@@ -60,6 +64,7 @@ export function useExchanges(
${orderDirection ? `orderDirection: "${orderDirection}"` : ""}
where: {
${id ? `id: "${id}"` : ""}
+ ${id_in ? `id_in: [${id_in.join(",")}]` : ""}
${sellerId ? "seller: $sellerId" : ""}
${buyerId ? "buyer: $buyerId" : ""}
${
diff --git a/src/lib/utils/hooks/useKeepQueryParamsNavigate.ts b/src/lib/utils/hooks/useKeepQueryParamsNavigate.ts
index 525da7901..e5ba2b60e 100644
--- a/src/lib/utils/hooks/useKeepQueryParamsNavigate.ts
+++ b/src/lib/utils/hooks/useKeepQueryParamsNavigate.ts
@@ -61,9 +61,12 @@ export function useKeepQueryParamsNavigate() {
...to,
search
},
- options
+ {
+ ...options,
+ state: { prevPath: location.pathname }
+ }
);
},
- [locationRef, navigate]
+ [locationRef, navigate, location]
);
}
diff --git a/src/pages/account/Tabs.tsx b/src/pages/account/Tabs.tsx
index 3c3849c29..5c5ea282b 100644
--- a/src/pages/account/Tabs.tsx
+++ b/src/pages/account/Tabs.tsx
@@ -4,8 +4,7 @@ import styled from "styled-components";
import { AccountQueryParameters } from "../../lib/routing/parameters";
import { useQueryParameter } from "../../lib/routing/useQueryParameter";
import { colors } from "../../lib/styles/colors";
-import { useBuyers } from "../../lib/utils/hooks/useBuyers";
-import { useSellers } from "../../lib/utils/hooks/useSellers";
+import { useBuyerSellerAccounts } from "../../lib/utils/hooks/useBuyerSellerAccounts";
import Disputes from "./Disputes";
import Exchanges from "./Exchanges";
import Funds from "./funds/Funds";
@@ -75,14 +74,11 @@ export default function Tabs({
address,
children: SellerBuyerToggle
}: Props) {
- const { data: sellers, isError: isErrorSellers } = useSellers({
- admin: address
- });
- const { data: buyers, isError: isErrorBuyers } = useBuyers({
- wallet: address
- });
- const sellerId = sellers?.[0]?.id || "";
- const buyerId = buyers?.[0]?.id || "";
+ const {
+ seller: { sellerId, isError: isErrorSellers },
+ buyer: { buyerId, isError: isErrorBuyers }
+ } = useBuyerSellerAccounts(address);
+
const tabsData = useMemo(() => {
const tabsData: TabsData[] = [
{
diff --git a/src/pages/account/funds/Funds.tsx b/src/pages/account/funds/Funds.tsx
index ee57f9f76..434f3df72 100644
--- a/src/pages/account/funds/Funds.tsx
+++ b/src/pages/account/funds/Funds.tsx
@@ -113,7 +113,7 @@ export default function Funds({ sellerId, buyerId }: Props) {
availableAmount: "0",
id: "",
token: {
- id: newTokenAddress,
+ id: "",
__typename: "ExchangeToken",
address: newTokenAddress,
name: name,
diff --git a/src/pages/account/private/PrivateAccount.tsx b/src/pages/account/private/PrivateAccount.tsx
index da532c12c..54d71dab7 100644
--- a/src/pages/account/private/PrivateAccount.tsx
+++ b/src/pages/account/private/PrivateAccount.tsx
@@ -15,6 +15,7 @@ const BasicInfo = styled.section`
flex-direction: column;
justify-content: center;
align-items: center;
+ margin-top: 2rem;
`;
const EnsName = styled.div`
diff --git a/src/pages/account/public/PublicAccount.tsx b/src/pages/account/public/PublicAccount.tsx
index 97e79122e..ad1faf4df 100644
--- a/src/pages/account/public/PublicAccount.tsx
+++ b/src/pages/account/public/PublicAccount.tsx
@@ -13,6 +13,7 @@ const BasicInfo = styled.section`
flex-direction: column;
justify-content: center;
align-items: center;
+ margin-top: 2rem;
`;
const EnsName = styled.div`
diff --git a/src/pages/chat/Chat.tsx b/src/pages/chat/Chat.tsx
new file mode 100644
index 000000000..29ea88a74
--- /dev/null
+++ b/src/pages/chat/Chat.tsx
@@ -0,0 +1,415 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */ // TODO: remove
+import { useEffect, useMemo, useState } from "react";
+import { Route, Routes, useLocation, useParams } from "react-router-dom";
+import styled, { createGlobalStyle } from "styled-components";
+import { useAccount } from "wagmi";
+
+import { useModal } from "../../components/modal/useModal";
+import { UrlParameters } from "../../lib/routing/parameters";
+import { BosonRoutes } from "../../lib/routing/routes";
+import { breakpoint } from "../../lib/styles/breakpoint";
+import { colors } from "../../lib/styles/colors";
+import { useBreakpoints } from "../../lib/utils/hooks/useBreakpoints";
+import { Exchange, useExchanges } from "../../lib/utils/hooks/useExchanges";
+import { useKeepQueryParamsNavigate } from "../../lib/utils/hooks/useKeepQueryParamsNavigate";
+import { useChatContext } from "./ChatProvider/ChatContext";
+import ChatConversation from "./components/ChatConversation";
+import MessageList from "./components/MessageList";
+
+const dennisAddress = "0xE16955e95D088bd30746c7fb7d76cDA436b86F63";
+const albertAddress = "0x9c2925a41d6FB1c6C8f53351634446B0b2E65eE8";
+const jonasAddress = "0x00c5D17c55940783961352E6f83ea18167841Bca";
+const dennisId = "1";
+const albertId = "2";
+
+const GlobalStyle = createGlobalStyle`
+ html, body, #root, [data-rk] {
+ height: 100%;
+ }
+`;
+
+const Container = styled.div`
+ display: flex;
+ height: 100%;
+`;
+// :
+const getExchanges = ({
+ address,
+ id_in,
+ disputed
+}: {
+ address: string | undefined;
+ id_in: string[];
+ disputed: null;
+}): ReturnType => {
+ const r = {
+ data: [
+ {
+ id: "0",
+ buyer: {
+ id: albertId,
+ wallet: "0x"
+ },
+ committedDate: new Date().toString(),
+ disputed: true,
+ expired: true,
+ finalizedDate: new Date().toString(),
+ redeemedDate: new Date().toString(),
+ state: "REDEEMED",
+ validUntilDate: new Date().toString(),
+ seller: { id: dennisId },
+ offer: {
+ id: "1",
+ buyerCancelPenalty: "",
+ createdAt: "",
+ disputeResolverId: "",
+ exchangeToken: {
+ address: "0x1000000000000000000000000000000000000000",
+ decimals: "18",
+ name: "PepitoName",
+ symbol: "pepito",
+ __typename: "ExchangeToken"
+ },
+ fulfillmentPeriodDuration: "",
+ metadataHash: "",
+ metadataUri: "",
+ price: "10001230000000000000",
+ protocolFee: "",
+ quantityAvailable: "",
+ quantityInitial: "",
+ resolutionPeriodDuration: "",
+ seller: {
+ active: true,
+ admin: dennisAddress,
+ clerk: dennisAddress,
+ __typename: "Seller",
+ id: dennisId,
+ operator: address === dennisAddress ? albertAddress : dennisAddress,
+ treasury: dennisAddress
+ },
+ sellerDeposit: "",
+ validFromDate: "",
+ validUntilDate: "",
+ voucherRedeemableFromDate: "",
+ voucherRedeemableUntilDate: "",
+ voucherValidDuration: "",
+ __typename: "Offer",
+ isValid: true,
+ voidedAt: "",
+ metadata: {
+ imageUrl:
+ "https://bsn-portal-development-image-upload-storage.s3.amazonaws.com/boson-sweatshirt-FINAL.gif",
+ type: "BASE",
+ name: "boson sweatshirt (dennis<->albert)"
+ }
+ }
+ },
+ {
+ id: "1",
+ buyer: {
+ id: dennisId,
+ wallet: "0x"
+ },
+ committedDate: new Date().toString(),
+ disputed: true,
+ expired: true,
+ finalizedDate: new Date().toString(),
+ redeemedDate: new Date().toString(),
+ state: "REDEEMED",
+ validUntilDate: new Date().toString(),
+ seller: { id: albertId },
+ offer: {
+ id: "1",
+ buyerCancelPenalty: "",
+ createdAt: "",
+ disputeResolverId: "",
+ exchangeToken: {
+ address: "0x2000000000000000000000000000000000000000",
+ decimals: "18",
+ name: "PepitoName",
+ symbol: "pepito",
+ __typename: "ExchangeToken"
+ },
+ fulfillmentPeriodDuration: "",
+ metadataHash: "",
+ metadataUri: "",
+ price: "10001230000000000000",
+ protocolFee: "",
+ quantityAvailable: "",
+ quantityInitial: "",
+ resolutionPeriodDuration: "",
+ seller: {
+ active: true,
+ admin: albertAddress,
+ clerk: albertAddress,
+ __typename: "Seller",
+ id: albertId,
+ operator: address === dennisAddress ? albertAddress : dennisAddress,
+ treasury: albertAddress
+ },
+ sellerDeposit: "",
+ validFromDate: "",
+ validUntilDate: "",
+ voucherRedeemableFromDate: "",
+ voucherRedeemableUntilDate: "",
+ voucherValidDuration: "",
+ __typename: "Offer",
+ isValid: true,
+ voidedAt: "",
+ metadata: {
+ imageUrl:
+ "https://bsn-portal-development-image-upload-storage.s3.amazonaws.com/boson-sweatshirt-FINAL.gif",
+ type: "BASE",
+ name: "boson sweatshirt (albert<->dennis)"
+ }
+ }
+ },
+ {
+ id: "2",
+ buyer: {
+ id: dennisId,
+ wallet: "0x"
+ },
+ committedDate: new Date().toString(),
+ disputed: true,
+ expired: true,
+ finalizedDate: new Date().toString(),
+ redeemedDate: new Date().toString(),
+ state: "REDEEMED",
+ validUntilDate: new Date().toString(),
+ seller: { id: albertId },
+ offer: {
+ id: "1",
+ buyerCancelPenalty: "",
+ createdAt: "",
+ disputeResolverId: "",
+ exchangeToken: {
+ address: "0x2000000000000000000000000000000000000000",
+ decimals: "18",
+ name: "PepitoName",
+ symbol: "pepito",
+ __typename: "ExchangeToken"
+ },
+ fulfillmentPeriodDuration: "",
+ metadataHash: "",
+ metadataUri: "",
+ price: "10001230000000000000",
+ protocolFee: "",
+ quantityAvailable: "",
+ quantityInitial: "",
+ resolutionPeriodDuration: "",
+ seller: {
+ active: true,
+ admin: albertAddress,
+ clerk: albertAddress,
+ __typename: "Seller",
+ id: albertId,
+ operator: address === jonasAddress ? dennisAddress : jonasAddress,
+ treasury: albertAddress
+ },
+ sellerDeposit: "",
+ validFromDate: "",
+ validUntilDate: "",
+ voucherRedeemableFromDate: "",
+ voucherRedeemableUntilDate: "",
+ voucherValidDuration: "",
+ __typename: "Offer",
+ isValid: true,
+ voidedAt: "",
+ metadata: {
+ imageUrl:
+ "https://bsn-portal-development-image-upload-storage.s3.amazonaws.com/boson-sweatshirt-FINAL.gif",
+ type: "BASE",
+ name: "boson sweatshirt (jonas<->dennis)"
+ }
+ }
+ }
+ ]
+ };
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ return r as any;
+};
+
+const SelectMessageContainer = styled.div`
+ display: flex;
+ flex: 0 1 75%;
+ flex-direction: column;
+ position: relative;
+ width: 100%;
+ display: none;
+ ${breakpoint.m} {
+ display: block;
+ }
+`;
+
+const SimpleMessage = styled.p`
+ all: unset;
+ display: block;
+ height: 100%;
+ padding: 1rem;
+ background: ${colors.lightGrey};
+`;
+
+const getIsSameThread = (
+ exchangeId: string | undefined,
+ textAreaValue: {
+ exchangeId: string;
+ value: string;
+ }
+) => {
+ return textAreaValue.exchangeId === exchangeId;
+};
+
+export default function Chat() {
+ const { bosonXmtp } = useChatContext();
+ const { address } = useAccount();
+ const { showModal, hideModal } = useModal();
+ useEffect(() => {
+ if (bosonXmtp && address) {
+ hideModal();
+ } else {
+ showModal(
+ "INITIALIZE_CHAT",
+ {
+ title: "Initialize Chat",
+ closable: false
+ },
+ "s"
+ );
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [bosonXmtp, address]);
+ const { data: exchanges = [] } = useMemo(
+ () =>
+ getExchanges({
+ address,
+ // TODO: remove
+ id_in: new Array(116).fill(0).map((v, idx) => "" + idx),
+ disputed: null
+ }),
+ [address]
+ );
+ // TODO: comment out
+ // const { data: exchanges } = useExchanges({
+ // id_in: threads.map((message) => message.threadId.exchangeId),
+ // disputed: null
+ // });
+
+ const textAreaValueByThread = useMemo(
+ () =>
+ exchanges.map((exchange) => {
+ return {
+ exchangeId: exchange.id,
+ value: ""
+ };
+ }),
+ [exchanges]
+ );
+ const [selectedExchange, selectExchange] = useState();
+ const [chatListOpen, setChatListOpen] = useState(false);
+ const [exchangeIdNotOwned, setExchangeIdNotOwned] = useState(false);
+ const params = useParams();
+ const location = useLocation();
+ const exchangeId = params["*"];
+ const { state } = location;
+ const prevPath = (state as { prevPath: string })?.prevPath;
+ const [previousPath, setPreviousPath] = useState("");
+ const navigate = useKeepQueryParamsNavigate();
+ const { isXXS, isXS, isS } = useBreakpoints();
+
+ // select thread based on url /exchangeId
+ useEffect(() => {
+ if (exchanges && exchangeId) {
+ const foundExchange = exchanges.find((exchange) => {
+ return exchange.id === exchangeId;
+ });
+ if (!foundExchange) {
+ setExchangeIdNotOwned(true);
+ return;
+ }
+ selectExchange(foundExchange);
+ }
+ }, [exchangeId, exchanges]);
+
+ const [textAreasValues, setTextAreasValues] = useState(textAreaValueByThread);
+ useEffect(() => {
+ setTextAreasValues(textAreaValueByThread);
+ }, [textAreaValueByThread]);
+ const onTextAreaChange = (textAreaTargetValue: string) => {
+ const updatedData = textAreasValues.map((textAreaValue) =>
+ getIsSameThread(exchangeId, textAreaValue)
+ ? { ...textAreaValue, value: textAreaTargetValue }
+ : textAreaValue
+ );
+ setTextAreasValues(updatedData);
+ };
+
+ const parseInputValue = useMemo(
+ () =>
+ textAreasValues.find((textAreaValue) =>
+ getIsSameThread(exchangeId, textAreaValue)
+ )?.value,
+ [exchangeId, textAreasValues]
+ );
+ useEffect(() => {
+ setPreviousPath(prevPath);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return (
+ <>
+
+
+
+ {
+ if (isXXS || isXS || isS) {
+ setChatListOpen(!chatListOpen);
+ }
+ selectExchange(exchange);
+ navigate({
+ pathname: `${BosonRoutes.Chat}/${exchange.id}`
+ });
+ }}
+ chatListOpen={chatListOpen}
+ setChatListOpen={setChatListOpen}
+ currentExchange={selectedExchange}
+ />
+
+
+
+ }
+ />
+
+
+ {(location.pathname === `${BosonRoutes.Chat}/` ||
+ location.pathname === `${BosonRoutes.Chat}`) && (
+
+
+ {exchangeIdNotOwned
+ ? "You don't have this exchange"
+ : "Select a message"}
+
+
+ )}
+
+ >
+ );
+}
diff --git a/src/pages/chat/ChatProvider/ChatContext.ts b/src/pages/chat/ChatProvider/ChatContext.ts
new file mode 100644
index 000000000..a2ef57802
--- /dev/null
+++ b/src/pages/chat/ChatProvider/ChatContext.ts
@@ -0,0 +1,12 @@
+import { BosonXmtpClient } from "@bosonprotocol/chat-sdk";
+import { createContext, Dispatch, SetStateAction, useContext } from "react";
+
+export const Context = createContext<{
+ bosonXmtp: BosonXmtpClient | undefined;
+ initialize: Dispatch>;
+}>({
+ bosonXmtp: undefined,
+ initialize: () => null
+});
+
+export const useChatContext = () => useContext(Context);
diff --git a/src/pages/chat/ChatProvider/ChatProvider.tsx b/src/pages/chat/ChatProvider/ChatProvider.tsx
new file mode 100644
index 000000000..038d0e10c
--- /dev/null
+++ b/src/pages/chat/ChatProvider/ChatProvider.tsx
@@ -0,0 +1,36 @@
+import { BosonXmtpClient } from "@bosonprotocol/chat-sdk";
+import { ReactNode, useEffect, useState } from "react";
+import { useSigner } from "wagmi";
+
+import { Context } from "./ChatContext";
+
+interface Props {
+ children: ReactNode;
+}
+
+const envName = "local-df"; // TODO: change
+export default function ChatProvider({ children }: Props) {
+ const { data: signer } = useSigner();
+ const [initialize, setInitialized] = useState(0);
+ const [bosonXmtp, setBosonXmtp] = useState();
+ useEffect(() => {
+ if (signer && initialize && !bosonXmtp) {
+ BosonXmtpClient.initialise(signer, envName)
+ .then((bosonClient) => {
+ setBosonXmtp(bosonClient);
+ })
+ .catch(console.error);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [signer, initialize]);
+ return (
+ setInitialized((prev) => prev + 1)
+ }}
+ >
+ {children}
+
+ );
+}
diff --git a/src/pages/chat/components/ButtonProposal/ButtonProposal.tsx b/src/pages/chat/components/ButtonProposal/ButtonProposal.tsx
new file mode 100644
index 000000000..51dc02527
--- /dev/null
+++ b/src/pages/chat/components/ButtonProposal/ButtonProposal.tsx
@@ -0,0 +1,115 @@
+import { Plus } from "phosphor-react";
+import { useEffect, useMemo, useState } from "react";
+import styled from "styled-components";
+
+import { useModal } from "../../../../components/modal/useModal";
+import MultiSteps from "../../../../components/step/MultiSteps";
+import Grid from "../../../../components/ui/Grid";
+import { breakpoint } from "../../../../lib/styles/breakpoint";
+import { colors } from "../../../../lib/styles/colors";
+import { FileWithEncodedData } from "../../../../lib/utils/files";
+import { useChatContext } from "../../ChatProvider/ChatContext";
+import { NewProposal, Thread } from "../../types";
+
+const StyledButton = styled.button`
+ border: 3px solid ${colors.secondary};
+ padding-left: 1.2rem;
+ padding-right: 2.5rem;
+ font-size: 0.875rem;
+ margin-right: 0.875rem;
+ height: 46px;
+ font-weight: 600;
+ color: ${colors.secondary};
+ background-color: transparent;
+ position: relative;
+ cursor: pointer;
+ svg {
+ position: absolute;
+ top: 50%;
+ right: 0.6rem;
+ transform: translateY(-50%) scale(0.7);
+ ${breakpoint.m} {
+ transform: translateY(-50%);
+ }
+ }
+ :disabled {
+ cursor: not-allowed;
+ }
+`;
+
+const StyledMultiSteps = styled(MultiSteps)`
+ width: 100%;
+`;
+
+interface Props {
+ exchange: NonNullable;
+ onSendProposal: (
+ proposal: NewProposal,
+ proposalFiles: FileWithEncodedData[]
+ ) => void;
+ disabled?: boolean;
+}
+
+export default function ButtonProposal({
+ exchange,
+ onSendProposal,
+ disabled
+}: Props) {
+ const { bosonXmtp } = useChatContext();
+ const { showModal, updateProps, store } = useModal();
+ const [activeStep, setActiveStep] = useState(0);
+
+ const headerComponent = useMemo(
+ () => (
+
+ {
+ setActiveStep(step);
+ }}
+ active={activeStep}
+ disableInactiveSteps
+ />
+
+ ),
+ [activeStep]
+ );
+
+ useEffect(() => {
+ if (bosonXmtp && store.modalType === "MAKE_PROPOSAL") {
+ updateProps<"MAKE_PROPOSAL">({
+ ...store,
+ modalProps: {
+ ...store.modalProps,
+ headerComponent,
+ activeStep
+ }
+ });
+ }
+ }, [activeStep, headerComponent, activeStep]); // eslint-disable-line
+
+ return (
+
+ showModal(
+ "MAKE_PROPOSAL",
+ {
+ headerComponent,
+ exchange,
+ activeStep: 0,
+ setActiveStep,
+ sendProposal: onSendProposal
+ },
+ "m"
+ )
+ }
+ >
+ Proposal
+
+ );
+}
diff --git a/src/pages/chat/components/ChatConversation.tsx b/src/pages/chat/components/ChatConversation.tsx
new file mode 100644
index 000000000..aecaaee62
--- /dev/null
+++ b/src/pages/chat/components/ChatConversation.tsx
@@ -0,0 +1,746 @@
+/* eslint-disable @typescript-eslint/no-unused-vars */ // TODO: remove
+
+import {
+ FileContent,
+ MessageData,
+ MessageType,
+ ProposalContent,
+ SupportedFileMimeTypes,
+ ThreadId,
+ ThreadObject
+} from "@bosonprotocol/chat-sdk/dist/cjs/util/definitions";
+import dayjs from "dayjs";
+import { CircleNotch } from "phosphor-react";
+import { ArrowLeft, UploadSimple } from "phosphor-react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import InfiniteScroll from "react-infinite-scroll-component";
+import styled from "styled-components";
+import { useAccount } from "wagmi";
+
+import { useModal } from "../../../components/modal/useModal";
+import Grid from "../../../components/ui/Grid";
+import SellerID from "../../../components/ui/SellerID";
+import { BosonRoutes } from "../../../lib/routing/routes";
+import { breakpoint } from "../../../lib/styles/breakpoint";
+import { colors } from "../../../lib/styles/colors";
+import { zIndex } from "../../../lib/styles/zIndex";
+import { FileWithEncodedData } from "../../../lib/utils/files";
+import { useInfiniteThread } from "../../../lib/utils/hooks/chat/useInfiniteThread";
+import { useBreakpoints } from "../../../lib/utils/hooks/useBreakpoints";
+import { useBuyerSellerAccounts } from "../../../lib/utils/hooks/useBuyerSellerAccounts";
+import { Exchange } from "../../../lib/utils/hooks/useExchanges";
+import { useKeepQueryParamsNavigate } from "../../../lib/utils/hooks/useKeepQueryParamsNavigate";
+import { useChatContext } from "../ChatProvider/ChatContext";
+import ButtonProposal from "./ButtonProposal/ButtonProposal";
+import ExchangeSidePreview from "./ExchangeSidePreview";
+import Message from "./Message";
+import MessageSeparator from "./MessageSeparator";
+
+const Container = styled.div`
+ display: flex;
+ flex: 0 1 100%;
+ flex-direction: row;
+ position: relative;
+ width: 100%;
+
+ ${breakpoint.m} {
+ flex: 0 1 75%;
+ }
+`;
+
+const ConversationContainer = styled.div`
+ display: flex;
+ flex-direction: column;
+ width: 100%;
+`;
+
+const Header = styled.div`
+ [data-testid="seller-info"] {
+ font-weight: 600;
+ font-size: 1rem;
+ color: initial;
+ ${breakpoint.xs} {
+ font-size: 1.5rem;
+ }
+ }
+ img {
+ width: 1.5rem;
+ height: 1.5rem;
+ }
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ svg:nth-of-type(1) {
+ margin-right: 0.5rem;
+ }
+ svg {
+ ${breakpoint.m} {
+ display: none;
+ }
+ }
+
+ text-align: center;
+ ${breakpoint.m} {
+ text-align: left;
+ }
+ div {
+ display: block;
+ margin: 0 auto;
+ ${breakpoint.m} {
+ display: unset;
+ margin: unset;
+ }
+ }
+
+ > div {
+ div {
+ display: flex;
+ text-align: center;
+ align-items: center;
+ justify-content: center;
+ width: fit-content;
+ }
+ ${breakpoint.m} {
+ position: relative;
+ top: unset;
+ width: unset;
+ justify-content: unset;
+ display: block;
+ align-items: unset;
+ }
+ }
+
+ ${breakpoint.l} {
+ justify-content: unset;
+ }
+`;
+const Spinner = styled(CircleNotch)`
+ animation: spin 2s infinite linear;
+ @keyframes spin {
+ 0% {
+ -webkit-transform: rotate(0deg);
+ transform: rotate(0deg);
+ }
+ 100% {
+ -webkit-transform: rotate(359deg);
+ transform: rotate(359deg);
+ }
+ }
+`;
+const Loading = styled.div`
+ display: flex;
+ background-color: ${colors.lightGrey};
+ justify-content: center;
+`;
+const Messages = styled.div<{ $overflow: string }>`
+ background-color: ${colors.lightGrey};
+ overflow: ${({ $overflow }) => $overflow};
+ display: flex;
+ flex-direction: column-reverse;
+ flex-grow: 1;
+`;
+const Conversation = styled.div<{ $alignStart: boolean }>`
+ display: flex;
+ flex-direction: column;
+ align-items: ${({ $alignStart }) =>
+ $alignStart ? "flex-start" : "flex-end"};
+ flex-grow: 1;
+ background-color: ${colors.lightGrey};
+ padding-bottom: 1.875rem;
+ position: relative;
+`;
+
+const TypeMessage = styled.div`
+ height: max-content;
+ width: 100%;
+ display: flex;
+ align-items: center;
+ padding: 1.5rem 1rem 1.5rem 1rem;
+`;
+
+const Input = styled.div`
+ width: 100%;
+ font-size: 1rem;
+ background: ${colors.lightGrey};
+ height: max-content;
+ font-family: "Plus Jakarta Sans";
+ font-style: normal;
+ font-size: 1rem;
+ font-weight: 500;
+ line-height: 1.5rem;
+ padding: 0.75rem 1rem 0.75rem 1rem;
+ max-width: calc(100vw - 10.9375rem);
+ &:focus {
+ outline: none;
+ }
+ textarea {
+ width: 100%;
+ height: 1.3125rem;
+ max-width: calc(100% - 2.1875rem);
+ border: none;
+ display: block;
+ max-height: 16.875rem;
+ overflow-y: auto;
+ overflow-wrap: break-word;
+ border: none;
+ background: none;
+ resize: none;
+ padding-right: 0.625rem;
+ &:focus {
+ outline: none;
+ }
+ }
+`;
+
+const TextArea = styled.textarea`
+ font-family: Plus Jakarta Sans;
+ font-size: 1rem;
+ font-weight: 400;
+ line-height: 1.5rem;
+ letter-spacing: 0;
+ text-align: left;
+ cursor: text;
+ :disabled {
+ cursor: not-allowed;
+ }
+`;
+
+const SimpleMessage = styled.p`
+ all: unset;
+ display: block;
+ height: 100%;
+ padding: 1rem;
+ background: ${colors.lightGrey};
+`;
+
+const GridHeader = styled.div`
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ min-height: 84px;
+ > * {
+ flex-basis: 100%;
+ }
+ ${breakpoint.m} {
+ }
+`;
+
+const HeaderButton = styled.button`
+ font-size: 0.75rem;
+ font-weight: 600;
+ background: none;
+ padding: none;
+ border: none;
+ color: ${colors.secondary};
+ z-index: ${zIndex.LandingTitle};
+`;
+
+const NavigationMobile = styled.div`
+ display: flex;
+ /* min-height: 3.125rem; */
+ /* width: 100%; */
+ align-items: flex-end;
+ justify-content: space-between;
+ svg {
+ color: ${colors.secondary};
+ margin-right: 0.438rem;
+ margin-bottom: -0.1875rem;
+ }
+ ${breakpoint.l} {
+ display: none;
+ }
+`;
+
+const InputWrapper = styled.div`
+ display: flex;
+ position: relative;
+ width: 100%;
+`;
+
+const UploadButtonWrapper = styled.button`
+ all: unset;
+ cursor: pointer;
+ position: absolute;
+ right: 0;
+ top: 0;
+ transform: translate(0, 25%);
+ margin: 0 1rem;
+ :disabled {
+ cursor: not-allowed;
+ }
+`;
+
+const SellerComponent = ({
+ size,
+ withProfileText,
+ exchange
+}: {
+ size: number;
+ withProfileText?: boolean;
+ exchange: Exchange | undefined;
+}) => {
+ if (!exchange) {
+ return null;
+ }
+ return (
+
+ );
+};
+
+const ErrorMessage = () => (
+
+ There has been an error, try again or refresh
+
+);
+
+const getWasItSentByMe = (myAddress: string | undefined, sender: string) => {
+ return myAddress === sender;
+};
+
+interface Props {
+ exchange: Exchange | undefined;
+ chatListOpen: boolean;
+ setChatListOpen: (p: boolean) => void;
+ exchangeIdNotOwned: boolean;
+ prevPath: string;
+ onTextAreaChange: (textAreaTargetValue: string) => void;
+ textAreaValue: string | undefined;
+}
+const ChatConversation = ({
+ exchange,
+ chatListOpen,
+ setChatListOpen,
+ exchangeIdNotOwned,
+ prevPath,
+ onTextAreaChange,
+ textAreaValue
+}: Props) => {
+ const { bosonXmtp } = useChatContext();
+ const [dateIndex, setDateIndex] = useState(0);
+ const onFinishFetching = () => {
+ if (bosonXmtp && !isBeginningOfTimes && !areThreadsLoading && !lastThread) {
+ loadMoreMessages();
+ }
+ };
+ const threadId = useMemo(() => {
+ if (!exchange) {
+ return null;
+ }
+ return {
+ exchangeId: exchange.id,
+ buyerId: exchange.buyer.id,
+ sellerId: exchange.seller.id
+ };
+ }, [exchange]);
+ console.log("my threadId", threadId);
+ const {
+ data: thread,
+ isLoading: areThreadsLoading,
+ isBeginningOfTimes,
+ isError: isErrorThread,
+ lastData: lastThread
+ } = useInfiniteThread({
+ threadId,
+ dateIndex,
+ dateStep: "week",
+ counterParty: exchange?.offer.seller.operator || "",
+ onFinishFetching
+ });
+ const loadMoreMessages = useCallback(
+ (forceDateIndex?: number) => {
+ if (!areThreadsLoading) {
+ if (forceDateIndex !== undefined) {
+ setDateIndex(forceDateIndex);
+ } else {
+ setDateIndex(dateIndex - 1);
+ }
+ }
+ },
+ [dateIndex, areThreadsLoading]
+ );
+
+ const addMessage = useCallback(
+ (
+ thread: ThreadObject | null,
+ newMessageOrList: MessageData | MessageData[]
+ ) => {
+ const newMessages = Array.isArray(newMessageOrList)
+ ? newMessageOrList
+ : [newMessageOrList];
+ if (thread) {
+ thread.messages = [...thread.messages, ...newMessages];
+ } else {
+ loadMoreMessages(0); // trigger getting the thread
+ }
+ },
+ [loadMoreMessages]
+ );
+ const previousThreadMessagesRef = useRef(
+ thread?.messages || []
+ );
+
+ const hasMoreMessages = !isBeginningOfTimes;
+
+ const dataMessagesRef = useRef(null);
+ const lastMessageRef = useRef(null);
+ const scrollToBottom = useCallback(
+ (scrollOptions: ScrollIntoViewOptions) =>
+ lastMessageRef.current?.scrollIntoView(scrollOptions),
+ []
+ );
+ useEffect(() => {
+ if (
+ thread &&
+ thread?.messages.length !== previousThreadMessagesRef.current.length
+ ) {
+ if (previousThreadMessagesRef.current.length) {
+ const isLoadingHistoryMessages =
+ (thread?.messages[0].timestamp || 0) <
+ previousThreadMessagesRef.current[0].timestamp;
+ if (!isLoadingHistoryMessages) {
+ scrollToBottom({
+ behavior: "smooth"
+ }); // every time we send/receive a message
+ }
+ }
+
+ previousThreadMessagesRef.current = thread?.messages || [];
+ }
+ }, [thread, lastThread, scrollToBottom, thread?.messages]);
+ const [isExchangePreviewOpen, setExchangePreviewOpen] =
+ useState(false);
+ const textareaRef = useRef(null);
+ const navigate = useKeepQueryParamsNavigate();
+ const { address } = useAccount();
+ const destinationAddress = exchange?.offer.seller.operator || "";
+ useEffect(() => {
+ if (!bosonXmtp || !thread?.threadId || !destinationAddress) {
+ return;
+ }
+ const monitor = async () => {
+ for await (const incomingMessage of await bosonXmtp.monitorThread(
+ thread.threadId,
+ destinationAddress
+ )) {
+ addMessage(thread, incomingMessage);
+ }
+ };
+ monitor().catch((error) => {
+ console.error(error);
+ });
+ }, [bosonXmtp, destinationAddress, thread, addMessage, address]);
+ const sendFilesToChat = useCallback(
+ async (files: FileWithEncodedData[]) => {
+ if (!bosonXmtp || !threadId) {
+ return;
+ }
+ for (const file of files) {
+ const imageContent: FileContent = {
+ value: {
+ encodedContent: file.encodedData,
+ fileName: file.name,
+ fileSize: file.size,
+ fileType: file.type as SupportedFileMimeTypes
+ }
+ };
+ const newMessage = {
+ threadId,
+ content: imageContent,
+ contentType: MessageType.File,
+ version: "1"
+ };
+ const messageData = await bosonXmtp.encodeAndSendMessage(
+ newMessage,
+ destinationAddress
+ );
+ addMessage(thread, messageData);
+ }
+ },
+ [addMessage, bosonXmtp, destinationAddress, threadId, thread]
+ );
+ const { isLteS, isLteM, isXXS, isS, isM, isL, isXL } = useBreakpoints();
+ const {
+ seller: {
+ sellerId: _sellerId,
+ isError: isErrorSellers,
+ isLoading: isLoadingSeller
+ },
+ buyer: { buyerId, isError: isErrorBuyers, isLoading: isLoadingBuyer }
+ } = useBuyerSellerAccounts(address || "");
+ const sellerId = _sellerId || "";
+ const { showModal } = useModal();
+
+ useEffect(() => {
+ setChatListOpen(false);
+ }, [setChatListOpen]);
+
+ useEffect(() => {
+ if (textareaRef && textareaRef.current) {
+ textareaRef.current.style.height = "0px";
+ const scrollHeight = textareaRef.current.scrollHeight;
+ textareaRef.current.style.height = scrollHeight + "px";
+ }
+ }, [prevPath, textAreaValue]);
+
+ const detailsButton = useMemo(() => {
+ if (chatListOpen && (isXXS || isS || isM)) {
+ return (
+ {
+ setExchangePreviewOpen(!isExchangePreviewOpen);
+ }}
+ >
+
+
+ );
+ }
+
+ return (
+ {
+ setExchangePreviewOpen(!isExchangePreviewOpen);
+ }}
+ >
+ {isExchangePreviewOpen ? "Hide Details" : "Details"}
+
+ );
+ }, [chatListOpen, isExchangePreviewOpen, isXXS, isS, isM]);
+ // TODO: comment out
+ // if (
+ // !isLoadingSeller &&
+ // !isLoadingBuyer &&
+ // (isErrorSellers || isErrorBuyers || (!sellerId && !buyerId))
+ // ) {
+ // return ;
+ // }
+
+ const isConversationBeingLoaded = !thread && areThreadsLoading;
+ const disableInputs = isErrorThread || isConversationBeingLoaded;
+ if (!exchange || !address) {
+ return (
+
+
+ {exchangeIdNotOwned
+ ? "You don't have this exchange"
+ : "Select a message"}
+
+
+ );
+ }
+ return (
+
+
+
+
+ {
+ if (isM && !prevPath) {
+ setChatListOpen(!chatListOpen);
+ } else if (isM && prevPath) {
+ navigate({ pathname: prevPath });
+ } else {
+ navigate({ pathname: BosonRoutes.Chat });
+ }
+ }}
+ >
+ {(isM || isL || isXL) &&
+ prevPath &&
+ !prevPath.includes(`${BosonRoutes.Chat}/`) && (
+
+
+ Back
+
+ )}
+ {isLteS && !chatListOpen && (
+
+
+ Back
+
+ )}
+
+
+
+ {isLteM && {detailsButton} }
+
+
+
+
+
+
+ >}
+ dataLength={thread?.messages.length || 0}
+ scrollableTarget="messages"
+ scrollThreshold="200px"
+ >
+ <>
+ {thread?.messages.map((message, index) => {
+ const isFirstMessage = index === 0;
+ const isPreviousMessageInADifferentDay = isFirstMessage
+ ? false
+ : dayjs(message.timestamp)
+ .startOf("day")
+ .diff(
+ dayjs(thread.messages[index - 1].timestamp).startOf(
+ "day"
+ )
+ ) > 0;
+ const showMessageSeparator =
+ isFirstMessage || isPreviousMessageInADifferentDay;
+ const isLastMessage = index === thread.messages.length - 1;
+ const leftAligned = !getWasItSentByMe(address, message.sender);
+ const ref = isLastMessage ? lastMessageRef : null;
+ return (
+
+ <>
+ {showMessageSeparator && (
+
+ )}
+
+
+
+ >
+
+ );
+ })}
+ >
+
+
+
+
+ {exchange.disputed && (
+
+ {
+ if (!threadId || !bosonXmtp) {
+ return;
+ }
+ const proposalContent: ProposalContent = {
+ value: {
+ title: proposal.title,
+ description: proposal.description,
+ proposals: proposal.proposals,
+ disputeContext: proposal.disputeContext
+ }
+ };
+ const newMessage = {
+ threadId,
+ content: proposalContent,
+ contentType: MessageType.Proposal,
+ version: "1"
+ };
+ const messageData = await bosonXmtp.encodeAndSendMessage(
+ newMessage,
+ destinationAddress
+ );
+ addMessage(thread, messageData);
+ if (proposalFiles.length) {
+ await sendFilesToChat(proposalFiles);
+ }
+ }}
+ />
+
+ )}
+
+
+
+
+
+ showModal("UPLOAD_MODAL", {
+ title: "Upload documents",
+ withEncodedData: true,
+ onUploadedFilesWithData: async (files) => {
+ await sendFilesToChat(files);
+ }
+ })
+ }
+ >
+
+
+
+
+
+
+
+ );
+};
+export default ChatConversation;
diff --git a/src/pages/chat/components/ExchangeSidePreview.tsx b/src/pages/chat/components/ExchangeSidePreview.tsx
new file mode 100644
index 000000000..8cd02ee65
--- /dev/null
+++ b/src/pages/chat/components/ExchangeSidePreview.tsx
@@ -0,0 +1,315 @@
+import { subgraph } from "@bosonprotocol/core-sdk";
+import dayjs from "dayjs";
+import { useMemo } from "react";
+import styled from "styled-components";
+
+import DetailTable from "../../../components/detail/DetailTable";
+import { DetailDisputeResolver } from "../../../components/detail/DetailWidget/DetailDisputeResolver";
+import { DetailSellerDeposit } from "../../../components/detail/DetailWidget/DetailSellerDeposit";
+import { useModal } from "../../../components/modal/useModal";
+import Price from "../../../components/price";
+import MultiSteps from "../../../components/step/MultiSteps";
+import Timeline from "../../../components/timeline/Timeline";
+import Button from "../../../components/ui/Button";
+import Typography from "../../../components/ui/Typography";
+import { CONFIG } from "../../../lib/config";
+import { breakpoint } from "../../../lib/styles/breakpoint";
+import { colors } from "../../../lib/styles/colors";
+import { Offer } from "../../../lib/types/offer";
+import { Exchange } from "../../../lib/utils/hooks/useExchanges";
+
+const Container = styled.div<{ $disputeOpen: boolean }>`
+ display: flex;
+ flex-direction: column;
+ height: 91vh;
+ overflow-y: auto;
+ min-width: max-content;
+ position: absolute;
+ margin-top: 7.05rem;
+ right: ${({ $disputeOpen }) => ($disputeOpen ? "0" : "-160vw")};
+ transition: 400ms;
+ width: ${({ $disputeOpen }) => $disputeOpen && "100vw"};
+ background: ${colors.lightGrey};
+ padding-top: 1.875rem;
+ padding-bottom: 20%;
+ ${breakpoint.m} {
+ width: 100%;
+ min-width: 60%;
+ }
+ ${breakpoint.l} {
+ position: relative;
+ background: transparent;
+ right: unset;
+ margin-top: 0;
+ width: unset;
+ padding-top: none;
+ min-width: max-content;
+ }
+ > img {
+ width: 100%;
+ max-height: 400px;
+ object-fit: contain;
+ width: 23.25rem;
+ display: block;
+ margin: 0 auto;
+ ${breakpoint.l} {
+ max-width: 23.25rem;
+ max-height: unset;
+ object-fit: cover;
+ }
+ }
+ > div {
+ padding-left: 1.25rem;
+ padding-right: 1.25rem;
+ ${breakpoint.xs} {
+ padding-left: 4.375rem;
+ padding-right: 4.375rem;
+ }
+ ${breakpoint.s} {
+ padding-left: 7.5rem;
+ padding-right: 7.5rem;
+ }
+ ${breakpoint.m} {
+ padding-left: 6.875rem;
+ padding-right: 6.875rem;
+ }
+ ${breakpoint.l} {
+ padding: 1.625rem;
+ }
+ }
+`;
+
+const sectionStyles = `
+border: 2px solid ${colors.border};
+border-top: none;
+padding: 1.625rem;
+${breakpoint.l} {
+ background: ${colors.white};
+}
+background: ${colors.lightGrey};
+`;
+const Section = styled.div`
+ ${sectionStyles};
+`;
+
+const InfoMessage = styled.div`
+ border-left: 2px solid ${colors.border};
+ color: ${colors.darkGrey};
+ padding: 0.5rem 1.25rem 0.5rem 1.5rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-align: center;
+ ${breakpoint.l} {
+ text-align: left;
+ background: ${colors.lightGrey};
+ }
+`;
+
+const ExchangeInfo = styled(Section)`
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ flex-direction: column;
+ row-gap: 0.875rem;
+ small {
+ font-size: 1rem;
+ font-weight: 400;
+ }
+`;
+
+const StyledPrice = styled(Price)`
+ h3 {
+ display: flex;
+ align-items: center;
+
+ svg {
+ all: unset;
+ }
+ }
+ small {
+ position: relative;
+ bottom: 0.3125rem;
+ }
+`;
+
+const Name = styled(Typography)`
+ all: unset;
+ font-size: 1.5rem;
+ font-weight: 600;
+`;
+
+const StyledMultiSteps = styled(MultiSteps)`
+ gap: 0;
+ ${breakpoint.m} {
+ padding-left: 6.25rem;
+ padding-right: 6.25rem;
+ }
+ ${breakpoint.l} {
+ padding-left: 0;
+ padding-right: 0;
+ }
+`;
+
+const CTASection = styled(Section)`
+ display: flex;
+ justify-content: space-between;
+`;
+
+const HistorySection = styled(Section)`
+ padding-bottom: 3rem;
+ height: 100%;
+ border-bottom: none;
+`;
+
+const formatShortDate = (date: string) => {
+ return date
+ ? dayjs(new Date(Number(date) * 1000)).format(CONFIG.shortDateFormat)
+ : "";
+};
+
+const getOfferDetailData = (offer: Offer) => {
+ return [
+ {
+ name: DetailSellerDeposit.name,
+ info: DetailSellerDeposit.info,
+ value:
+ },
+ {
+ name: "Exchange policy",
+ info: (
+ <>
+
+ Exchange policy
+
+
+ The Exchange policy ensures that the terms of sale are set in a fair
+ way to protect both buyers and sellers.
+
+ >
+ ),
+ value: "Fair exchange policy"
+ },
+ {
+ name: DetailDisputeResolver.name,
+ info: DetailDisputeResolver.info,
+ value:
+ }
+ ];
+};
+
+interface Props {
+ exchange: Exchange | undefined;
+ disputeOpen: boolean;
+}
+export default function ExchangeSidePreview({ exchange, disputeOpen }: Props) {
+ const offer = exchange?.offer;
+ const { showModal } = useModal();
+ const OFFER_DETAIL_DATA = useMemo(
+ () => offer && getOfferDetailData(offer),
+ [offer]
+ );
+ const timesteps = useMemo(() => {
+ if (!exchange) {
+ return [];
+ }
+ const { committedDate, redeemedDate } = exchange;
+ const timesteps = [];
+ if (committedDate) {
+ timesteps.push({
+ text: "Committed",
+ date: formatShortDate(committedDate)
+ });
+ }
+ if (redeemedDate) {
+ timesteps.push({ text: "Redeemed", date: formatShortDate(redeemedDate) });
+ }
+ return timesteps;
+ }, [exchange]);
+ if (!exchange || !offer) {
+ return null;
+ }
+ const isInRedeemed = subgraph.ExchangeState.Redeemed === exchange.state;
+ const isInDispute = exchange.disputed;
+ const isResolved = false; // TODO: change
+ const isEscalated = false; // TODO: change
+ // TODO: change these values
+ const deadlineToResolveDispute = new Date(
+ new Date().getTime() + 1000000000
+ ).getTime();
+ const raisedDisputeAt = new Date(new Date().getTime() - 100000000).getTime(); // yesterday
+ const totalDaysToResolveDispute = dayjs(deadlineToResolveDispute).diff(
+ raisedDisputeAt,
+ "day"
+ );
+ const daysLeftToResolveDispute = dayjs(deadlineToResolveDispute).diff(
+ new Date().getTime(),
+ "day"
+ );
+ return (
+
+
+ {isInRedeemed && (
+ {`${daysLeftToResolveDispute} / ${totalDaysToResolveDispute} days left to resolve dispute`}
+ )}
+
+ {exchange.offer.metadata.name}
+
+
+ {(isInDispute || isResolved || isEscalated) && (
+
+ )}
+
+ {isInDispute && (
+
+
+ showModal(
+ "CANCEL_EXCHANGE",
+ {
+ title: "Cancel exchange",
+ exchange
+ },
+ "s"
+ )
+ }
+ >
+ Retract
+
+ {
+ // TODO: implement
+ console.log("escalate");
+ }}
+ >
+ Escalate
+
+
+ )}
+
+ History
+
+
+
+ );
+}
diff --git a/src/pages/chat/components/Message.tsx b/src/pages/chat/components/Message.tsx
new file mode 100644
index 000000000..91cd4c7ea
--- /dev/null
+++ b/src/pages/chat/components/Message.tsx
@@ -0,0 +1,372 @@
+import {
+ FileContent,
+ MessageType,
+ ProposalContent
+} from "@bosonprotocol/chat-sdk/dist/cjs/util/definitions";
+import { Image as AccountImage } from "@davatar/react";
+import { BigNumber, utils } from "ethers";
+import { ArrowRight, Check } from "phosphor-react";
+import React, { forwardRef, ReactNode, useCallback } from "react";
+import styled from "styled-components";
+
+import UploadedFile from "../../../components/form/Upload/UploadedFile";
+import ProposalTypeSummary from "../../../components/modal/components/Chat/components/ProposalTypeSummary";
+import { useModal } from "../../../components/modal/useModal";
+import Grid from "../../../components/ui/Grid";
+import Typography from "../../../components/ui/Typography";
+import { breakpoint } from "../../../lib/styles/breakpoint";
+import { colors } from "../../../lib/styles/colors";
+import { DeepReadonly } from "../../../lib/types/helpers";
+import { validateMessage } from "../../../lib/utils/chat/message";
+import { Exchange } from "../../../lib/utils/hooks/useExchanges";
+import { Thread } from "../types";
+
+const width = "31.625rem";
+type StyledContentProps = { $isLeftAligned: boolean };
+const StyledContent = styled.div`
+ position: relative;
+ background-color: ${({ $isLeftAligned }) =>
+ $isLeftAligned ? colors.black : colors.white};
+ color: ${({ $isLeftAligned }) =>
+ $isLeftAligned ? colors.white : colors.black};
+ display: flex;
+ flex-direction: column;
+ padding: 1rem;
+ border: 0.063rem solid ${colors.border};
+ margin-right: 2.5rem;
+ margin-left: 2.5rem;
+ margin-top: 3rem;
+ padding: 1.5rem 1rem 2.75rem 1rem;
+ min-width: 6.25rem;
+ max-width: ${width};
+ &:after {
+ position: absolute;
+ content: "";
+ border-top: 16px solid
+ ${({ $isLeftAligned }) => ($isLeftAligned ? colors.black : colors.white)};
+ border-right: 16px solid transparent;
+ border-right: ${({ $isLeftAligned }) =>
+ $isLeftAligned ? "none" : "16px solid transparent"};
+ border-left: ${({ $isLeftAligned }) =>
+ $isLeftAligned ? "16px solid transparent" : "none"};
+ top: -1px;
+ ${breakpoint.m} {
+ top: -0.063rem;
+ }
+ right: ${({ $isLeftAligned }) => ($isLeftAligned ? "auto" : "-1rem")};
+ left: ${({ $isLeftAligned }) => ($isLeftAligned ? "-1rem" : "auto")};
+ }
+ h4 {
+ font-weight: 600;
+ font-size: 1.25rem;
+ }
+ h5 {
+ font-size: 1rem;
+ font-weight: 600;
+ }
+ img {
+ max-width: 15.625rem;
+ object-fit: contain;
+ }
+ div:nth-of-type(2) {
+ font-size: 1.1875rem;
+ ${breakpoint.m} {
+ font-size: 1rem;
+ }
+ }
+`;
+
+const Avatar = styled.div`
+ position: absolute;
+ top: -1.25rem;
+ left: 1rem;
+ img {
+ width: 34px;
+ height: 34px;
+ }
+`;
+
+const DateStamp = styled.div<{ $isLeftAligned: boolean }>`
+ position: absolute;
+ bottom: 1rem;
+ right: ${({ $isLeftAligned }) => ($isLeftAligned ? "auto" : "1rem")};
+ left: ${({ $isLeftAligned }) => ($isLeftAligned ? "1rem" : "auto")};
+ font-size: 0.75rem;
+ color: ${({ $isLeftAligned }) =>
+ $isLeftAligned ? colors.lightGrey : colors.darkGrey};
+`;
+
+const StyledGrid = styled(Grid)`
+ cursor: pointer;
+ :hover * {
+ color: ${colors.secondary};
+ stroke: ${colors.secondary};
+ }
+`;
+
+const SellerAvatar = ({
+ isLeftAligned,
+ children,
+ exchange
+}: {
+ isLeftAligned: Props["isLeftAligned"];
+ children: Props["children"];
+ exchange: Props["exchange"];
+}) => {
+ return isLeftAligned ? (
+ {children}
+ ) : (
+
+
+
+ );
+};
+
+const BottomDateStamp = ({
+ isLeftAligned,
+ message
+}: {
+ isLeftAligned: Props["isLeftAligned"];
+ message: Props["message"];
+}) => {
+ const sentDate = new Date(message.timestamp);
+ return (
+
+ {sentDate.getHours().toString().padStart(2, "0")}:
+ {sentDate.getMinutes().toString().padStart(2, "0")}
+
+ );
+};
+
+interface Props {
+ exchange: Exchange;
+ message: DeepReadonly;
+ children: ReactNode;
+ isLeftAligned: boolean;
+}
+
+const Message = forwardRef(
+ (
+ { message, children, isLeftAligned, exchange }: Props,
+ ref: React.ForwardedRef
+ ) => {
+ const Content = useCallback(
+ ({
+ children,
+ ...props
+ }: { children: ReactNode } & StyledContentProps &
+ React.HTMLAttributes) => {
+ return (
+
+ {children}
+
+ );
+ },
+ [ref]
+ );
+ const { showModal } = useModal();
+ const messageContent = message.data.content;
+ const messageContentType = message.data.contentType;
+ const isRegularMessage =
+ typeof message.data.content.value === "string" &&
+ messageContentType === MessageType.String;
+ const isFileMessage = messageContentType === MessageType.File;
+ const isProposalMessage = messageContentType === MessageType.Proposal;
+
+ const isValid = validateMessage(message);
+ if (!isValid) {
+ return (
+
+
+ {children}
+
+
+ {isFileMessage
+ ? "Corrupt image."
+ : isProposalMessage
+ ? "Corrupt proposal"
+ : "Corrupt message"}
+ Please re-send in a new message
+
+
+
+ );
+ }
+ if (isRegularMessage) {
+ return (
+
+
+ {children}
+
+
+ {message.data.content.value}
+
+
+
+ );
+ }
+
+ if (isFileMessage) {
+ const imageValue = messageContent as unknown as FileContent;
+ return (
+
+
+ {children}
+
+
+
+
+ );
+ }
+
+ if (isProposalMessage) {
+ if (!exchange) {
+ return (
+
+
+ {children}
+
+
+ We couldn't retrieve your exchange to show the proposals, please
+ try again
+
+
+ );
+ }
+ const proposalContent = message.data
+ .content as unknown as ProposalContent;
+ const messageContent = proposalContent.value;
+ const isRaisingADispute = !!messageContent.disputeContext?.length;
+ return (
+
+
+ {children}
+
+
+ {messageContent.title}
+
+ {isRaisingADispute && (
+ <>
+
+ Dispute Category
+
+ {messageContent.disputeContext.map((reason) => {
+ return (
+
+
+ {reason}
+
+ );
+ })}
+
+ Additional information
+
+ >
+ )}
+
+ {messageContent.description}
+
+
+
+ {isLeftAligned ? (
+ <>
+
+ {messageContent.proposals.length === 1
+ ? "Proposal"
+ : "Proposals"}
+
+ {messageContent.proposals.map((proposal) => {
+ const { offer } = exchange;
+ const refundAmount = BigNumber.from(offer.price)
+ .div(BigNumber.from(100))
+ .mul(BigNumber.from(proposal.percentageAmount));
+
+ const formattedRefundAmount = utils.formatUnits(
+ refundAmount,
+ Number(offer.exchangeToken.decimals)
+ );
+ return (
+
+ {proposal.type}
+
+ showModal("RESOLVE_DISPUTE", {
+ title: "Resolve dispute",
+ exchange,
+ proposal
+ })
+ }
+ >
+
+ Proposed refund amount: {formattedRefundAmount}{" "}
+ {offer.exchangeToken.symbol} (
+ {proposal.percentageAmount}
+ %)
+
+
+
+
+ );
+ })}
+ >
+ ) : (
+ <>
+
+ Resolution Proposal
+
+ {messageContent.proposals.map((proposal) => (
+
+ ))}
+ >
+ )}
+
+
+
+ );
+ }
+
+ return (
+
+
+ {children}
+
+ Unsupported message
+
+
+ );
+ }
+);
+
+export default Message;
diff --git a/src/pages/chat/components/MessageList.tsx b/src/pages/chat/components/MessageList.tsx
new file mode 100644
index 000000000..66f430c5a
--- /dev/null
+++ b/src/pages/chat/components/MessageList.tsx
@@ -0,0 +1,176 @@
+import { useEffect, useState } from "react";
+import styled from "styled-components";
+
+import SellerID from "../../../components/ui/SellerID";
+import { breakpoint } from "../../../lib/styles/breakpoint";
+import { colors } from "../../../lib/styles/colors";
+import { zIndex } from "../../../lib/styles/zIndex";
+import { useBreakpoints } from "../../../lib/utils/hooks/useBreakpoints";
+import { Exchange } from "../../../lib/utils/hooks/useExchanges";
+
+const messageItemPadding = "1.5rem";
+
+const Container = styled.div<{
+ $chatListOpen?: boolean;
+ $isConversationOpened?: boolean;
+}>`
+ display: flex;
+ flex-direction: column;
+ flex: 1 1 25%;
+ border: 1px solid ${colors.border};
+ position: absolute;
+ z-index: ${zIndex.ChatSeparator};
+ background: white;
+ height: 100vh;
+ transition: 400ms;
+ width: 100vw;
+ width: ${({ $isConversationOpened }) =>
+ $isConversationOpened ? "100vw" : "auto"};
+ position: ${({ $isConversationOpened }) =>
+ $isConversationOpened ? "absolute" : "relative"};
+ left: ${({ $chatListOpen }) => ($chatListOpen ? "0" : "-100vw")};
+ left: ${({ $isConversationOpened, $chatListOpen }) =>
+ $isConversationOpened && !$chatListOpen ? "-100vw" : "0"};
+ ${breakpoint.m} {
+ left: unset;
+ position: relative;
+ height: auto;
+ z-index: ${zIndex.Default};
+ width: auto;
+ }
+`;
+
+const Header = styled.div`
+ display: flex;
+ align-items: center;
+ padding: 26px 32px;
+ font-weight: 600;
+ font-size: 1.5rem;
+ border-bottom: 1px solid ${colors.border};
+
+ ${breakpoint.l} {
+ padding: 1.5rem;
+ }
+`;
+
+const ExchangesThreads = styled.div`
+ overflow: auto;
+ padding-bottom: 20%;
+`;
+
+const MessageItem = styled.div<{ $active?: boolean }>`
+ border-bottom: 1px solid ${colors.border};
+ ${({ $active }) =>
+ $active &&
+ `
+ background-color: ${colors.border};
+ `};
+ :hover {
+ background-color: ${colors.border};
+ }
+`;
+
+const MessageContent = styled.div`
+ padding: ${messageItemPadding};
+ display: flex;
+ align-items: center;
+ column-gap: 1rem;
+`;
+
+const ExchangeName = styled.div`
+ font-weight: 600;
+`;
+
+const MessageInfo = styled.div`
+ display: flex;
+ flex-direction: column;
+ row-gap: 0.5rem;
+ div {
+ ${breakpoint.xxs} {
+ font-size: 0.8875rem;
+ }
+ ${breakpoint.l} {
+ font-size: 0.8rem;
+ }
+ ${breakpoint.xl} {
+ font-size: 1rem;
+ }
+ }
+`;
+
+interface Props {
+ exchanges: Exchange[];
+ onChangeConversation: (exchange: Exchange) => void;
+ chatListOpen: boolean;
+ currentExchange?: Exchange;
+ isConversationOpened: boolean;
+ setChatListOpen: (p: boolean) => void;
+}
+
+const getMessageItemKey = (exchange: Exchange) => exchange.id;
+
+export default function MessageList({
+ exchanges,
+ onChangeConversation,
+ chatListOpen,
+ currentExchange,
+ isConversationOpened,
+ setChatListOpen
+}: Props) {
+ const [activeMessageKey, setActiveMessageKey] = useState(
+ currentExchange ? getMessageItemKey(currentExchange) : ""
+ );
+ const { isS } = useBreakpoints();
+ useEffect(() => {
+ if (currentExchange) {
+ setActiveMessageKey(getMessageItemKey(currentExchange));
+ }
+ }, [currentExchange]);
+
+ return (
+
+
+
+ {exchanges
+ .filter((exchange) => exchange)
+ .map((exchange) => {
+ const messageKey = getMessageItemKey(exchange);
+ return (
+ {
+ onChangeConversation(exchange);
+ setActiveMessageKey(messageKey);
+ if (isS) {
+ setChatListOpen(!chatListOpen);
+ }
+ }}
+ key={messageKey}
+ >
+
+
+
+ {exchange?.offer.metadata.name}
+
+
+
+
+ );
+ })}
+
+
+ );
+}
diff --git a/src/pages/chat/components/MessageSeparator.tsx b/src/pages/chat/components/MessageSeparator.tsx
new file mode 100644
index 000000000..221a03830
--- /dev/null
+++ b/src/pages/chat/components/MessageSeparator.tsx
@@ -0,0 +1,90 @@
+import dayjs from "dayjs";
+import { useMemo } from "react";
+import styled from "styled-components";
+
+import Grid from "../../../components/ui/Grid";
+import { CONFIG } from "../../../lib/config";
+import { colors } from "../../../lib/styles/colors";
+import { zIndex } from "../../../lib/styles/zIndex";
+import { Thread } from "../types";
+
+const Separator = styled.div`
+ width: 100%;
+ position: relative;
+ z-index: ${zIndex.Default};
+ div:nth-of-type(1) {
+ width: max-content;
+ background-color: ${colors.darkGreyTimeStamp};
+ padding: 0.25rem 1rem 0.25rem 1rem;
+ display: block;
+ margin: 0 auto;
+ z-index: ${zIndex.ChatSeparator};
+ position: relative;
+ font-weight: 600;
+ font-size: 0.75rem;
+ &:before {
+ position: absolute;
+ background-color: ${colors.lightGrey};
+ left: -0.625rem;
+ height: 100%;
+ width: 0.625rem;
+ content: "";
+ }
+ &:after {
+ position: absolute;
+ background-color: ${colors.lightGrey};
+ right: -0.625rem;
+ height: 100%;
+ width: 0.625rem;
+ content: "";
+ }
+ }
+ div:nth-of-type(2) {
+ width: calc(100% - 2.5rem);
+ height: 0.125rem;
+ background-color: ${colors.darkGreyTimeStamp};
+ z-index: ${zIndex.Default};
+ position: relative;
+ margin-left: auto;
+ margin-right: auto;
+ margin-top: -0.875rem;
+ }
+`;
+interface Props {
+ message: Readonly;
+}
+
+export default function MessageSeparator({ message }: Props) {
+ const { diffInDays, sentDate } = useMemo(() => {
+ const currentDate = dayjs().startOf("day");
+ const sentDate = dayjs(message.timestamp).startOf("day");
+
+ return { diffInDays: currentDate.diff(sentDate, "day"), sentDate };
+ }, [message]);
+
+ const separator = useMemo(() => {
+ if (diffInDays === 0) {
+ return (
+
+ {`Today`}
+
+
+ );
+ } else if (diffInDays === 1) {
+ return (
+
+ {`Yesterday`}
+
+
+ );
+ } else {
+ return (
+
+ {sentDate.format(CONFIG.dateFormat)}
+
+
+ );
+ }
+ }, [sentDate, diffInDays]);
+ return {separator} ;
+}
diff --git a/src/pages/chat/components/UploadForm/UploadForm.tsx b/src/pages/chat/components/UploadForm/UploadForm.tsx
new file mode 100644
index 000000000..75ae1986a
--- /dev/null
+++ b/src/pages/chat/components/UploadForm/UploadForm.tsx
@@ -0,0 +1,39 @@
+import { useField } from "formik";
+import { Info, UploadSimple } from "phosphor-react";
+
+import Upload from "../../../../components/form/Upload/Upload";
+import { FormModel } from "../../../../components/modal/components/Chat/MakeProposal/MakeProposalFormModel";
+import Grid from "../../../../components/ui/Grid";
+import Typography from "../../../../components/ui/Typography";
+import { colors } from "../../../../lib/styles/colors";
+import { MAX_FILE_SIZE, SUPPORTED_FILE_FORMATS } from "./const";
+
+export default function UploadForm() {
+ const [uploadField] = useField(FormModel.formFields.upload.name);
+ return (
+ <>
+
+ Upload documents
+
+
+
+
+ File format: PDF, PNG, JPG
+ Max. file size: {MAX_FILE_SIZE / (1024 * 1024)}MB
+
+
+
+ Upload file
+ >
+ }
+ files={uploadField.value || []}
+ wrapperProps={{ style: { width: "100%" } }}
+ />
+ >
+ );
+}
diff --git a/src/pages/chat/components/UploadForm/const.ts b/src/pages/chat/components/UploadForm/const.ts
new file mode 100644
index 000000000..ef5a5f20c
--- /dev/null
+++ b/src/pages/chat/components/UploadForm/const.ts
@@ -0,0 +1,32 @@
+import * as Yup from "yup";
+
+import bytesToSize from "../../../../lib/utils/bytesToSize";
+
+export const SUPPORTED_FILE_FORMATS = [
+ "image/jpg",
+ "image/jpeg",
+ "image/png",
+ "application/pdf"
+];
+
+export const MAX_FILE_SIZE = 1 * 1024 * 1024; // 1 MB
+
+export const validationOfFile = ({ isOptional }: { isOptional: boolean }) =>
+ Yup.mixed()
+ .nullable(isOptional ? true : undefined)
+ .test(
+ "fileSize",
+ `File size cannot exceed ${bytesToSize(MAX_FILE_SIZE)} (for each file)`,
+ (files: File[]) => {
+ return files.every((file) => file.size <= MAX_FILE_SIZE);
+ }
+ )
+ .test(
+ "FILE_FORMAT",
+ "Uploaded files have unsupported format",
+ (files: File[]) => {
+ return files.every((file) =>
+ SUPPORTED_FILE_FORMATS.includes(file.type)
+ );
+ }
+ );
diff --git a/src/pages/chat/types.ts b/src/pages/chat/types.ts
new file mode 100644
index 000000000..08f1d212f
--- /dev/null
+++ b/src/pages/chat/types.ts
@@ -0,0 +1,78 @@
+import {
+ ProposalContent,
+ ThreadObject
+} from "@bosonprotocol/chat-sdk/dist/cjs/util/definitions";
+
+import { Exchange } from "../../lib/utils/hooks/useExchanges";
+
+export type Thread = ThreadObject & {
+ exchange:
+ | (Exchange & {
+ offer: Exchange["offer"] & { metadata: { imageUrl: string } };
+ })
+ | undefined;
+};
+
+export type NewProposal = ProposalContent["value"];
+
+export type ProposalItem = ProposalContent["value"]["proposals"][number];
+
+// export type Message = {
+// authorityId: string;
+// sender: string;
+// recipient: string;
+// timestamp: number;
+// data
+// content: RegularMessage | ImageWithMetadata | ProposalMessage;
+// };
+
+// export type RegularMessage = {
+// threadId: {
+// exchangeId: string;
+// sellerId: string;
+// buyerId: string;
+// };
+// contentType: "string";
+// version: "1";
+// value: string;
+// };
+
+// export type ImageWithMetadata = {
+// threadId: {
+// exchangeId: string;
+// sellerId: string;
+// buyerId: string;
+// };
+// contentType: "image";
+// version: "1";
+// value: {
+// name: string;
+// size: number;
+// encodedContent: string;
+// type: "image/png" | "image/jpg" | "image/gif";
+// };
+// };
+
+// export type ProposalMessage = {
+// threadId: {
+// exchangeId: string;
+// sellerId: string;
+// buyerId: string;
+// };
+// contentType: "proposal";
+// version: "1";
+// value: {
+// title: string; // 'ID: x made a proposal' for now
+// description: string;
+// proposals: Proposal[];
+// disputeContext: string[];
+// };
+// };
+
+// export type NewProposal = ProposalMessage["value"];
+
+// export type Proposal = {
+// type: string;
+// percentageAmount: string;
+// signature: string;
+// };
diff --git a/src/pages/create-offer/CreateOffer.test.tsx b/src/pages/create-offer/CreateOffer.test.tsx
index aa3e53385..4b2a2127d 100644
--- a/src/pages/create-offer/CreateOffer.test.tsx
+++ b/src/pages/create-offer/CreateOffer.test.tsx
@@ -1,3 +1,6 @@
+/**
+ * @jest-environment ./jest.custom.env
+ */
import { act, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
diff --git a/src/pages/exchange/Exchange.tsx b/src/pages/exchange/Exchange.tsx
index 115b16ff7..79e29b925 100644
--- a/src/pages/exchange/Exchange.tsx
+++ b/src/pages/exchange/Exchange.tsx
@@ -23,7 +23,7 @@ import DetailShare from "../../components/detail/DetailShare";
import DetailSlider from "../../components/detail/DetailSlider";
import DetailTable from "../../components/detail/DetailTable";
import DetailTransactions from "../../components/detail/DetailTransactions";
-import DetailWidget from "../../components/detail/DetailWidget";
+import DetailWidget from "../../components/detail/DetailWidget/DetailWidget";
// DETAILS COMPONENTS ABOVE
import Image from "../../components/ui/Image";
import SellerID from "../../components/ui/SellerID";
diff --git a/src/pages/offers/OfferDetail.tsx b/src/pages/offers/OfferDetail.tsx
index 4cdc6eef4..360269229 100644
--- a/src/pages/offers/OfferDetail.tsx
+++ b/src/pages/offers/OfferDetail.tsx
@@ -21,7 +21,7 @@ import DetailChart from "../../components/detail/DetailChart";
import DetailShare from "../../components/detail/DetailShare";
import DetailSlider from "../../components/detail/DetailSlider";
import DetailTable from "../../components/detail/DetailTable";
-import DetailWidget from "../../components/detail/DetailWidget";
+import DetailWidget from "../../components/detail/DetailWidget/DetailWidget";
import Image from "../../components/ui/Image";
import SellerID from "../../components/ui/SellerID";
import Typography from "../../components/ui/Typography";