feat(gui): Add a Introduction for new users (#287)

* feat(gui): add IntroductionModal component

* feat(gui): add interactivity to IntroductionModal

* feat(gui): create SlideTemplate component for IntroductionModal Slides

* feat(gui): add generic slides to IntroductionModal with images and content

* feat(gui): add Slide with SwapStatusAlert to IntroductionModal

* feat(gui): show the introduction only on the first app start

* feat(gui): make external links functional

* fix(gui): update github link to link to active repo

* feat(gui): replace old images with new mockups and update Slide05 content

* feat(gui): add CardSelectionGroup and CardSelectionOption components for improved card selection UI

* feat(gui): add FiatPricePreference slide to IntroductionModal

* feat(gui): save user preference regarding fiat prices

I set the initial store configuration for fetching fiat prices to false to avoid any calls to coingecko without user consent

* refactor(gui): remove old Slide05 component for improved codebase maintenance

* fix(gui): add UnstoppableSwap logo to FiatPricePreference slide

* refactor(gui): update image imports and improve slide content for introduction modal

* fix(gui): introduce ExternalLink component and update Slide05 to use it for external navigation

* fix(gui): replace webp images for introduction with svg mockups for improved quality

* fix(gui): change order of introduction slides, to asking for fiat price preference at the end

* refactor(gui): implement CardSelectionContext for managing card selection state

* refactor: texts in intro modakl

* fix(gui): update currency fetching SVG for improved design and clarity

* feat(gui): added changelog entry for introduction

---------

Co-authored-by: Binarybaron <binarybaron@protonmail.com>
This commit is contained in:
b-enedict 2025-05-07 12:44:29 +02:00 committed by GitHub
parent 66313ad91f
commit 31e68b2671
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 869 additions and 3 deletions

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 57 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 46 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

View file

@ -15,6 +15,7 @@ import { useEffect } from "react";
import { setupBackgroundTasks } from "renderer/background";
import "@fontsource/roboto";
import FeedbackPage from "./pages/feedback/FeedbackPage";
import IntroductionModal from "./modal/introduction/IntroductionModal";
const useStyles = makeStyles((theme) => ({
innerContent: {
@ -36,6 +37,7 @@ export default function App() {
<ThemeProvider theme={themes[theme]}>
<GlobalSnackbarProvider>
<CssBaseline />
<IntroductionModal/>
<Router>
<Navigation />
<InnerContent />

View file

@ -0,0 +1,39 @@
import { createContext, useContext, useState, ReactNode } from 'react'
interface CardSelectionContextType {
selectedValue: string
setSelectedValue: (value: string) => void
}
const CardSelectionContext = createContext<CardSelectionContextType | undefined>(undefined)
export function CardSelectionProvider({
children,
initialValue,
onChange
}: {
children: ReactNode
initialValue: string
onChange?: (value: string) => void
}) {
const [selectedValue, setSelectedValue] = useState(initialValue)
const handleValueChange = (value: string) => {
setSelectedValue(value)
onChange?.(value)
}
return (
<CardSelectionContext.Provider value={{ selectedValue, setSelectedValue: handleValueChange }}>
{children}
</CardSelectionContext.Provider>
)
}
export function useCardSelection() {
const context = useContext(CardSelectionContext)
if (context === undefined) {
throw new Error('useCardSelection must be used within a CardSelectionProvider')
}
return context
}

View file

@ -0,0 +1,23 @@
import { Box } from '@material-ui/core'
import CheckIcon from '@material-ui/icons/Check'
import { CardSelectionProvider } from './CardSelectionContext'
interface CardSelectionGroupProps {
children: React.ReactElement<{ value: string }>[]
value: string
onChange: (value: string) => void
}
export default function CardSelectionGroup({
children,
value,
onChange,
}: CardSelectionGroupProps) {
return (
<CardSelectionProvider initialValue={value} onChange={onChange}>
<Box style={{ display: 'flex', flexDirection: 'column', gap: 12, marginTop: 12 }}>
{children}
</Box>
</CardSelectionProvider>
)
}

View file

@ -0,0 +1,53 @@
import { Box } from "@material-ui/core";
import CheckIcon from '@material-ui/icons/Check'
import { useCardSelection } from './CardSelectionContext'
// The value prop is used by the parent CardSelectionGroup to determine which option is selected
export default function CardSelectionOption({children, value}: {children: React.ReactNode, value: string}) {
const { selectedValue, setSelectedValue } = useCardSelection()
const selected = value === selectedValue
return (
<Box
onClick={() => setSelectedValue(value)}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 16,
border: selected ? '2px solid #FF5C1B' : '2px solid #555',
borderRadius: 16,
padding: '1em',
cursor: 'pointer',
transition: 'all 0.2s ease-in-out',
}}
>
<Box
style={{
border: selected ? '2px solid #FF5C1B' : '2px solid #555',
borderRadius: 99999,
width: 28,
height: 28,
background: selected ? '#FF5C1B' : 'transparent',
overflow: 'hidden',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.2s ease-in-out',
transform: selected ? 'scale(1.1)' : 'scale(1)',
flexShrink: 0,
}}
>
{selected ? (
<CheckIcon
style={{
transition: 'all 0.2s ease-in-out',
transform: 'scale(1)',
animation: 'checkIn 0.2s ease-in-out'
}}
/>
) : null}
</Box>
<Box pt={0.5}>{children}</Box>
</Box>
)
}

View file

@ -0,0 +1,114 @@
import { makeStyles, Modal } from '@material-ui/core'
import { useState } from 'react'
import Slide01_GettingStarted from './slides/Slide01_GettingStarted'
import Slide02_ChooseAMaker from './slides/Slide02_ChooseAMaker'
import Slide03_PrepareSwap from './slides/Slide03_PrepareSwap'
import Slide04_ExecuteSwap from './slides/Slide04_ExecuteSwap'
import Slide05_KeepAnEyeOnYourSwaps from './slides/Slide05_KeepAnEyeOnYourSwaps'
import Slide06_FiatPricePreference from './slides/Slide06_FiatPricePreference'
import Slide07_ReachOut from './slides/Slide07_ReachOut'
import {
setFetchFiatPrices,
setUserHasSeenIntroduction,
} from 'store/features/settingsSlice'
import { useAppDispatch, useSettings } from 'store/hooks'
const useStyles = makeStyles({
modal: {
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
},
paper: {
width: '80%',
display: 'flex',
justifyContent: 'space-between',
},
})
export default function IntroductionModal() {
const userHasSeenIntroduction = useSettings(
(s) => s.userHasSeenIntroduction
)
const dispatch = useAppDispatch()
// Handle Display State
const [open, setOpen] = useState<boolean>(!userHasSeenIntroduction)
const [showFiat, setShowFiat] = useState<boolean>(true)
const handleClose = () => {
setOpen(false)
}
// Handle Slide Index
const [currentSlideIndex, setCurrentSlideIndex] = useState(0)
const handleContinue = () => {
if (currentSlideIndex == slideComponents.length - 1) {
handleClose()
dispatch(setUserHasSeenIntroduction(true))
dispatch(setFetchFiatPrices(showFiat))
return
}
setCurrentSlideIndex((i) => i + 1)
}
const handlePrevious = () => {
if (currentSlideIndex == 0) {
return
}
setCurrentSlideIndex((i) => i - 1)
}
const slideComponents = [
<Slide01_GettingStarted
handleContinue={handleContinue}
handlePrevious={handlePrevious}
hidePreviousButton
/>,
<Slide02_ChooseAMaker
handleContinue={handleContinue}
handlePrevious={handlePrevious}
/>,
<Slide03_PrepareSwap
handleContinue={handleContinue}
handlePrevious={handlePrevious}
/>,
<Slide04_ExecuteSwap
handleContinue={handleContinue}
handlePrevious={handlePrevious}
/>,
<Slide05_KeepAnEyeOnYourSwaps
handleContinue={handleContinue}
handlePrevious={handlePrevious}
/>,
<Slide06_FiatPricePreference
handleContinue={handleContinue}
handlePrevious={handlePrevious}
showFiat={showFiat}
onChange={(showFiatSetting: string) =>
setShowFiat(showFiatSetting === 'show')
}
/>,
<Slide07_ReachOut
handleContinue={handleContinue}
handlePrevious={handlePrevious}
/>,
]
const classes = useStyles()
return (
<Modal
open={open}
onClose={handleClose}
className={classes.modal}
disableAutoFocus
closeAfterTransition
>
{slideComponents[currentSlideIndex]}
</Modal>
)
}

View file

@ -0,0 +1,23 @@
import { Typography } from '@material-ui/core'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/walletWithBitcoinAndMonero.png'
export default function Slide01_GettingStarted(props: slideProps) {
return (
<SlideTemplate
title="Getting Started"
{...props}
imagePath={imagePath}
>
<Typography variant="subtitle1">
To start swapping, you'll need:
</Typography>
<Typography>
<ul>
<li>A Bitcoin wallet with funds to swap</li>
<li>A Monero wallet to receive your Monero</li>
</ul>
</Typography>
</SlideTemplate>
)
}

View file

@ -0,0 +1,18 @@
import { Typography } from '@material-ui/core'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/mockMakerSelection.svg'
export default function Slide02_ChooseAMaker(props: slideProps) {
return (
<SlideTemplate
title="Choose a Maker"
stepLabel="Step 1"
{...props}
imagePath={imagePath}
>
<Typography variant="subtitle1">
To start a swap, choose a maker. Each maker offers different exchange rates and limits.
</Typography>
</SlideTemplate>
)
}

View file

@ -0,0 +1,13 @@
import { Typography } from '@material-ui/core'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/mockConfigureSwap.svg'
export default function Slide02_ChooseAMaker(props: slideProps) {
return (
<SlideTemplate title="Prepare Swap" stepLabel="Step 2" {...props} imagePath={imagePath}>
<Typography variant="subtitle1">
To initiate a swap, provide a Monero address and optionally a Bitcoin refund address.
</Typography>
</SlideTemplate>
)
}

View file

@ -0,0 +1,26 @@
import { Typography } from '@material-ui/core'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/simpleSwapFlowDiagram.svg'
export default function Slide02_ChooseAMaker(props: slideProps) {
return (
<SlideTemplate
title="Execute Swap"
stepLabel="Step 3"
{...props}
imagePath={imagePath}
>
<Typography variant="subtitle1">
After confirming:
</Typography>
<Typography>
<ol>
<li>Your Bitcoin are locked</li>
<li>Maker locks the Monero</li>
<li>Maker reedems the Bitcoin</li>
<li>Monero is sent to your address</li>
</ol>
</Typography>
</SlideTemplate>
)
}

View file

@ -0,0 +1,24 @@
import { Link, Typography } from '@material-ui/core'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/mockHistoryPage.svg'
import ExternalLink from 'renderer/components/other/ExternalLink'
export default function Slide05_KeepAnEyeOnYourSwaps(props: slideProps) {
return (
<SlideTemplate
title="Monitor Your Swaps"
stepLabel="Step 3"
{...props}
imagePath={imagePath}
>
<Typography>
Monitor active swaps to ensure everything proceeds smoothly.
</Typography>
<Typography>
<ExternalLink href='https://docs.unstoppableswap.net/usage/first_swap'>
Learn more about atomic swaps
</ExternalLink>
</Typography>
</SlideTemplate>
)
}

View file

@ -0,0 +1,53 @@
import { Box, Typography, Paper, Button, Slide } from '@material-ui/core'
import CardSelectionGroup from 'renderer/components/inputs/CardSelection/CardSelectionGroup'
import CardSelectionOption from 'renderer/components/inputs/CardSelection/CardSelectionOption'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/currencyFetching.svg'
const FiatPricePreferenceSlide = ({
handleContinue,
handlePrevious,
showFiat,
onChange,
}: slideProps & {
showFiat: boolean
onChange: (value: string) => void
}) => {
return (
<SlideTemplate handleContinue={handleContinue} handlePrevious={handlePrevious} title="Fiat Prices" imagePath={imagePath}>
<Typography variant="subtitle1" color="textSecondary">
Do you want to show fiat prices?
</Typography>
<CardSelectionGroup
value={showFiat ? 'show' : 'hide'}
onChange={onChange}
>
<CardSelectionOption value="show">
<Typography>Show fiat prices</Typography>
<Typography
variant="caption"
color="textSecondary"
paragraph
style={{ marginBottom: 4 }}
>
We connect to CoinGecko to provide realtime currency
prices.
</Typography>
</CardSelectionOption>
<CardSelectionOption value="hide">
<Typography>Don't show fiat prices</Typography>
</CardSelectionOption>
</CardSelectionGroup>
<Box style={{ marginTop: "0.5rem" }}>
<Typography
variant="caption"
color="textSecondary"
>
You can change your preference later in the settings
</Typography>
</Box>
</SlideTemplate>
)
}
export default FiatPricePreferenceSlide

View file

@ -0,0 +1,25 @@
import { Box, Typography } from '@material-ui/core'
import SlideTemplate from './SlideTemplate'
import imagePath from 'assets/groupWithChatbubbles.png'
import GitHubIcon from "@material-ui/icons/GitHub"
import MatrixIcon from 'renderer/components/icons/MatrixIcon'
import LinkIconButton from 'renderer/components/icons/LinkIconButton'
export default function Slide02_ChooseAMaker(props: slideProps) {
return (
<SlideTemplate title="Reach out" {...props} imagePath={imagePath} customContinueButtonText="Get Started">
<Typography variant="subtitle1">
We would love to hear about your experience with Unstoppable
Swap and invite you to join our community.
</Typography>
<Box mt={3}>
<LinkIconButton url="https://github.com/UnstoppableSwap/core">
<GitHubIcon/>
</LinkIconButton>
<LinkIconButton url="https://matrix.to/#/#unstoppableswap:matrix.org">
<MatrixIcon/>
</LinkIconButton>
</Box>
</SlideTemplate>
)
}

View file

@ -0,0 +1,94 @@
import { makeStyles, Paper, Box, Typography, Button } from '@material-ui/core'
type slideTemplateProps = {
handleContinue: () => void
handlePrevious: () => void
hidePreviousButton?: boolean
stepLabel?: String
title: String
children?: React.ReactNode
imagePath?: string
imagePadded?: boolean
customContinueButtonText?: String
}
const useStyles = makeStyles({
paper: {
height: "80%",
width: "80%",
display: 'flex',
justifyContent: 'space-between',
},
stepLabel: {
textTransform: 'uppercase',
},
splitImage: {
height: '100%',
width: '100%',
objectFit: 'contain'
}
})
export default function SlideTemplate({
handleContinue,
handlePrevious,
hidePreviousButton,
stepLabel,
title,
children,
imagePath,
imagePadded,
customContinueButtonText
}: slideTemplateProps) {
const classes = useStyles()
return (
<Paper className={classes.paper}>
<Box m={3} flex alignContent="center" position="relative" width="50%" flexGrow={1}>
<Box>
{stepLabel && (
<Typography
variant="overline"
className={classes.stepLabel}
>
{stepLabel}
</Typography>
)}
<Typography variant="h4" style={{ marginBottom: 16 }}>{title}</Typography>
{children}
</Box>
<Box
position="absolute"
bottom={0}
width="100%"
display="flex"
justifyContent={
hidePreviousButton ? 'flex-end' : 'space-between'
}
>
{!hidePreviousButton && (
<Button onClick={handlePrevious}>Back</Button>
)}
<Button
onClick={handleContinue}
variant="contained"
color="primary"
>
{customContinueButtonText ? customContinueButtonText : 'Next' }
</Button>
</Box>
</Box>
{imagePath && (
<Box
bgcolor="#212121"
width="50%"
display="flex"
justifyContent="center"
p={imagePadded ? "1.5em" : 0}
>
<img src={imagePath} className={classes.splitImage} />
</Box>
)}
</Paper>
)
}

View file

@ -0,0 +1,5 @@
type slideProps = {
handleContinue: () => void
handlePrevious: () => void
hidePreviousButton?: boolean
}

View file

@ -35,7 +35,7 @@ export default function NavigationFooter() {
<Box className={classes.linksOuter}>
<Tooltip title="Check out the GitHub repository">
<span>
<LinkIconButton url="https://github.com/UnstoppableSwap/unstoppableswap-gui">
<LinkIconButton url="https://github.com/UnstoppableSwap/core">
<GitHubIcon />
</LinkIconButton>
</span>

View file

@ -0,0 +1,10 @@
import Link from "@material-ui/core/Link";
import { open } from "@tauri-apps/plugin-shell";
export default function ExternalLink({children, href}: {children: React.ReactNode, href: string}) {
return (
<Link style={{cursor: 'pointer'}} onClick={() => open(href)}>
{children}
</Link>
)
}

View file

@ -11,6 +11,7 @@ export interface SettingsState {
fiatCurrency: FiatCurrency;
/// Whether to enable Tor for p2p connections
enableTor: boolean
userHasSeenIntroduction: boolean;
}
export enum FiatCurrency {
@ -98,9 +99,10 @@ const initialState: SettingsState = {
}
},
theme: Theme.Darker,
fetchFiatPrices: true,
fetchFiatPrices: false,
fiatCurrency: FiatCurrency.Usd,
enableTor: true
enableTor: true,
userHasSeenIntroduction: false
};
const alertsSlice = createSlice({
@ -135,6 +137,9 @@ const alertsSlice = createSlice({
removeNode(slice, action: PayloadAction<{ network: Network, type: Blockchain, node: string }>) {
slice.nodes[action.payload.network][action.payload.type] = slice.nodes[action.payload.network][action.payload.type].filter(node => node !== action.payload.node);
},
setUserHasSeenIntroduction(slice, action: PayloadAction<boolean>) {
slice.userHasSeenIntroduction = action.payload
},
resetSettings(_) {
return initialState;
},
@ -153,6 +158,7 @@ export const {
setFetchFiatPrices,
setFiatCurrency,
setTorEnabled,
setUserHasSeenIntroduction,
} = alertsSlice.actions;
export default alertsSlice.reducer;