Repository: interledger/web-monetization-extension
Pull request #99 (2024-02-14 23:43:55)

feat: settings page - logic, ui
Solves #46 

raducristianpopa: <!-- __WM_EXTENSION_BUILD_PREVIEWS__ -->
<h2>Extension builds preview</h2>

<table role="table">
<thead>
  <tr>
    <th>Name</th>
    <th>Link</th>
  </tr>
</thead>
<tbody>
  <tr>
    <td align="center">Latest commit</td>
    <td align="center">d8c933ada04fcebd9c88ab5ac4de7120f36ecef5</td>
  </tr>
  <tr>
    <td align="center">Latest job logs</td>
    <td align="center"><a href="https://github.com/interledger/web-monetization-extension/actions/runs/7908862839">Run #7908862839</a></td>
  </tr>
  <tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Chrome (148.98KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/20768480072/artifacts/1246425343">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Firefox (149.05KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/20768480072/artifacts/1246425345">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Opera (148.94KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/20768480072/artifacts/1246425346">Download</a></td></tr><tr><td align="center"><img src="https://img.shields.io/badge/success-3fb950?style=for-the-badge&label=Edge (148.94KB)" alt="Badge" /></td><td align="center"><a href="https://github.com/interledger/web-monetization-extension/suites/20768480072/artifacts/1246425344">Download</a></td></tr>
</tbody>
</table>

Patch:
diff --git a/src/components/icons.tsx b/src/components/icons.tsx
index 351d5e1..7964b5d 100644
--- a/src/components/icons.tsx
+++ b/src/components/icons.tsx
@@ -94,6 +94,35 @@ export const DollarSign = (props: React.SVGProps<SVGSVGElement>) => {
   )
 }
 
+export const WarningSign = (props: React.SVGProps<SVGSVGElement>) => {
+  return (
+    <svg
+      width="24"
+      height="24"
+      viewBox="0 0 24 24"
+      fill="none"
+      xmlns="http://www.w3.org/2000/svg"
+      {...props}>
+      <mask
+        id="mask0_140_3633"
+        style={{ maskType: 'alpha' }}
+        maskUnits="userSpaceOnUse"
+        x="0"
+        y="0"
+        width="24"
+        height="24">
+        <rect width="24" height="24" fill="#C4C4C4" />
+      </mask>
+      <g mask="url(#mask0_140_3633)">
+        <path
+          d="M11 13H13V7H11V13ZM12 17C12.2833 17 12.521 16.904 12.713 16.712C12.9043 16.5207 13 16.2833 13 16C13 15.7167 12.9043 15.479 12.713 15.287C12.521 15.0957 12.2833 15 12 15C11.7167 15 11.4793 15.0957 11.288 15.287C11.096 15.479 11 15.7167 11 16C11 16.2833 11.096 16.5207 11.288 16.712C11.4793 16.904 11.7167 17 12 17ZM12 22C10.6167 22 9.31667 21.7373 8.1 21.212C6.88333 20.6873 5.825 19.975 4.925 19.075C4.025 18.175 3.31267 17.1167 2.788 15.9C2.26267 14.6833 2 13.3833 2 12C2 10.6167 2.26267 9.31667 2.788 8.1C3.31267 6.88333 4.025 5.825 4.925 4.925C5.825 4.025 6.88333 3.31233 8.1 2.787C9.31667 2.26233 10.6167 2 12 2C13.3833 2 14.6833 2.26233 15.9 2.787C17.1167 3.31233 18.175 4.025 19.075 4.925C19.975 5.825 20.6873 6.88333 21.212 8.1C21.7373 9.31667 22 10.6167 22 12C22 13.3833 21.7373 14.6833 21.212 15.9C20.6873 17.1167 19.975 18.175 19.075 19.075C18.175 19.975 17.1167 20.6873 15.9 21.212C14.6833 21.7373 13.3833 22 12 22ZM12 20C14.2167 20 16.1043 19.221 17.663 17.663C19.221 16.1043 20 14.2167 20 12C20 9.78333 19.221 7.89567 17.663 6.337C16.1043 4.779 14.2167 4 12 4C9.78333 4 7.896 4.779 6.338 6.337C4.77933 7.89567 4 9.78333 4 12C4 14.2167 4.77933 16.1043 6.338 17.663C7.896 19.221 9.78333 20 12 20Z"
+          fill="#EF4444"
+        />
+      </g>
+    </svg>
+  )
+}
+
 export const ClipboardIcon = (props: React.SVGProps<SVGSVGElement>) => {
   return (
     <svg
@@ -124,4 +153,4 @@ export const CheckIcon = (props: React.SVGProps<SVGSVGElement>) => {
       <path strokeLinecap="round" strokeLinejoin="round" d="m4.5 12.75 6 6 9-13.5" />
     </svg>
   )
-}
+}
\ No newline at end of file
diff --git a/src/components/layout/header.tsx b/src/components/layout/header.tsx
index 77b84fb..cd37c4f 100644
--- a/src/components/layout/header.tsx
+++ b/src/components/layout/header.tsx
@@ -4,6 +4,8 @@ import { runtime } from 'webextension-polyfill'
 
 import { ArrowBack, Settings } from '../icons'
 import { ROUTES } from '../router-provider'
+import { usePopup } from '@/providers/popup.state'
+import { Switch } from '../switch'
 
 const Logo = runtime.getURL('assets/images/logo.svg')
 
@@ -29,6 +31,15 @@ const NavigationButton = () => {
 }
 
 export const Header = () => {
+  const {
+    data: { wmEnabled },
+    setData,
+  } = usePopup()
+
+  const switchWmEnabled = () => {
+    setData(prevState => ({ ...prevState, wmEnabled: !prevState.wmEnabled }))
+  }
+
   return (
     <div className="flex flex-row items-center justify-between py-8">
       <div className="flex flex-row items-center">
@@ -37,6 +48,7 @@ export const Header = () => {
       </div>
       <div className="flex flex-row items-center">
         <NavigationButton />
+        <Switch checked={wmEnabled} onChange={switchWmEnabled} className="ml-2" />
       </div>
     </div>
   )
diff --git a/src/components/radio-group.tsx b/src/components/radio-group.tsx
index 862cc0a..4f1cb44 100644
--- a/src/components/radio-group.tsx
+++ b/src/components/radio-group.tsx
@@ -86,6 +86,7 @@ export interface RadioGroupProps
   disabled?: boolean
   items: Omit<RadioProps, 'name'>[]
   name: string
+  handleChange?: (value: string) => void
 }
 
 export const RadioGroup = ({
@@ -95,8 +96,13 @@ export const RadioGroup = ({
   fullWidth,
   disabled,
   className,
+  handleChange,
+  value,
 }: RadioGroupProps) => {
-  const checkedItem = useMemo(() => items.findIndex(item => item.checked), [items])
+  const checkedItem = useMemo(
+    () => items.findIndex(item => item.checked || item.value === value),
+    [items, value],
+  )
   const [selected, setSelected] = useState(checkedItem)
 
   const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
@@ -140,7 +146,10 @@ export const RadioGroup = ({
           disabled={disabled}
           checked={selected === index}
           noSelected={selected === -1 && index === 0}
-          onChange={() => setSelected(index)}
+          onChange={() => {
+            setSelected(index)
+            if (handleChange) handleChange(item.value)
+          }}
         />
       ))}
     </div>
diff --git a/src/popup/Popup.scss b/src/popup/Popup.scss
index da855dc..62e29e0 100644
--- a/src/popup/Popup.scss
+++ b/src/popup/Popup.scss
@@ -33,7 +33,6 @@ body {
   flex-direction: column;
   align-items: center;
   justify-content: center;
-  min-height: 192px;
   flex-basis: auto;
   padding: 0 20px 10px;
 
diff --git a/src/popup/Popup.tsx b/src/popup/Popup.tsx
index 82d733f..f0cfab5 100644
--- a/src/popup/Popup.tsx
+++ b/src/popup/Popup.tsx
@@ -3,7 +3,7 @@ import './Popup.scss'
 import React from 'react'
 
 import { RouterProvider } from '@/components/router-provider'
-import { PopupProvider } from '@/providers/popup-context'
+import { PopupProvider } from '@/providers/popup.provider'
 
 const Popup = () => {
   return (
diff --git a/src/popup/index.css b/src/popup/index.css
index 8bd1f96..a2c5f51 100644
--- a/src/popup/index.css
+++ b/src/popup/index.css
@@ -30,6 +30,6 @@
 
     /* Popup */
     --popup-width: 448px;
-    --popup-height: 559px;
+    --popup-height: 600px;
   }
 }
diff --git a/src/popup/pages/Settings.tsx b/src/popup/pages/Settings.tsx
index eacdd66..86a0b54 100644
--- a/src/popup/pages/Settings.tsx
+++ b/src/popup/pages/Settings.tsx
@@ -1,5 +1,160 @@
-import React from 'react'
+import React, { useState } from 'react'
+
+import { Input } from '@/components/input'
+import { Label } from '@/components/label'
+import { RadioGroup } from '@/components/radio-group'
+import { Button } from '@/components/button'
+import { DollarSign, WarningSign } from '@/components/icons'
+
+import { usePopup } from '@/providers/popup.state'
 
 export const Settings = () => {
-  return <>Settings</>
+  const {
+    data: {
+      connected,
+      wmEnabled,
+      wallet,
+      amount,
+      amountType: { recurring },
+    },
+    setData,
+  } = usePopup()
+
+  const [_wallet, setWallet] = useState<string>(wallet)
+  const [_walletError, setWalletError] = useState<string>('')
+  const [_amount, setAmount] = useState<string>(`${amount}`)
+  const [_amountError, setAmountError] = useState<string>('')
+  const [_recurring, setRecurring] = useState<string>(`${recurring}`)
+  const [loading, setLoading] = useState<boolean>(false)
+
+  const handleChangeWallet = (event: any) => {
+    setWallet(event.target.value)
+  }
+
+  const handleChangeAmount = (event: any) => {
+    setAmount(event.target.value)
+  }
+
+  const handleChangeRecurring = (value: string) => {
+    setRecurring(value)
+  }
+
+  const handleConnect = () => {
+    if (loading) return
+
+    setWalletError('')
+    setAmountError('')
+
+    setLoading(true)
+    let errors = false
+
+    if (!_wallet) {
+      errors = true
+      setWalletError('Please fill in wallet address')
+    }
+    if (!_amount) {
+      errors = true
+      setAmountError('Please fill in the amount')
+    } else if (!parseFloat(_amount)) {
+      errors = true
+      setAmountError('Amount must be a number bigger than 0')
+    }
+
+    if (errors) {
+      setLoading(false)
+      return
+    }
+
+    setWalletError('')
+    setAmountError('')
+
+    setData(prevState => ({
+      ...prevState,
+      amount: parseFloat(_amount),
+      wallet: _wallet,
+      amountType: { recurring: _recurring === 'true' },
+      connected: true,
+    }))
+
+    setLoading(false)
+  }
+
+  const handleDisconnect = () => {
+    setData(prevState => ({
+      ...prevState,
+      connected: false,
+    }))
+  }
+
+  const switchConnect = () => {
+    if (!connected) handleConnect()
+    else handleDisconnect()
+  }
+
+  if (!wmEnabled)
+    return (
+      <div className="flex items-center">
+        <WarningSign />
+        <p className="text-medium text-medium pl-2">Web Monetization has been turned off.</p>
+      </div>
+    )
+
+  return (
+    <>
+      <Label className="text-base font-medium	text-medium mb-4">Connect your wallet</Label>
+      <div className="mb-4 pl-2">
+        <p className="text-xs font-medium	text-medium inline">
+          Get a wallet address from a provider before connecting it below. Please find a list of
+          available wallets
+        </p>
+        <a
+          href="https://webmonetization.org/docs/resources/op-wallets/"
+          className="text-xs font-medium text-primary inline pl-1"
+          target="_blank"
+          rel="noreferrer">
+          here.
+        </a>
+      </div>
+      <Input
+        value={_wallet}
+        id="wallet"
+        name="wallet"
+        onChange={handleChangeWallet}
+        errorMessage={_walletError}
+        disabled={connected}
+        placeholder="Enter your wallet address"
+      />
+      <Label className="text-base font-medium	text-medium mb-4 mt-8">Amount</Label>
+      <Input
+        value={_amount}
+        type="number"
+        id="amount"
+        name="amount"
+        onChange={handleChangeAmount}
+        disabled={connected}
+        errorMessage={_amountError}
+        icon={<DollarSign />}
+      />
+      <RadioGroup
+        handleChange={handleChangeRecurring}
+        items={[
+          { label: 'Recurring monthly amount', value: 'true' },
+          { label: 'Single-use amount', value: 'false' },
+        ]}
+        name="recurring"
+        id="recurring"
+        value={_recurring}
+        className="mb-8 pl-2 mt-5"
+        disabled={connected}
+      />
+      <Button
+        className="w-full"
+        onClick={switchConnect}
+        disabled={loading}
+        loading={loading}
+        aria-label="connect">
+        {connected ? 'Disconnect' : 'Connect'}
+      </Button>
+    </>
+  )
 }
diff --git a/src/providers/index.ts b/src/providers/index.ts
index 35b996f..f6e55cc 100644
--- a/src/providers/index.ts
+++ b/src/providers/index.ts
@@ -1,3 +1,3 @@
-import { PopupProvider } from './popup-context'
+import { PopupProvider } from './popup.provider'
 
 export { PopupProvider }
diff --git a/src/providers/popup-context.tsx b/src/providers/popup.provider.tsx
similarity index 95%
rename from src/providers/popup-context.tsx
rename to src/providers/popup.provider.tsx
index 392f2c7..db7634d 100644
--- a/src/providers/popup-context.tsx
+++ b/src/providers/popup.provider.tsx
@@ -3,6 +3,8 @@ import React, { createContext, useState } from 'react'
 import { PopupContextValue, TPopupContext } from './providers.interface'
 
 export const defaultData = {
+  connected: false,
+  wallet: '',
   amount: 0,
   amountType: {
     recurring: true,
diff --git a/src/providers/popup.state.tsx b/src/providers/popup.state.tsx
new file mode 100644
index 0000000..9671d9e
--- /dev/null
+++ b/src/providers/popup.state.tsx
@@ -0,0 +1,11 @@
+import { useContext } from 'react'
+
+import { PopupContext } from './popup.provider'
+
+export const usePopup = () => {
+  const context = useContext(PopupContext)
+  if (context === undefined) {
+    throw new Error('usePopup must be used with a PopupContext')
+  }
+  return context
+}
diff --git a/src/providers/providers.interface.ts b/src/providers/providers.interface.ts
index 9be002f..1460316 100644
--- a/src/providers/providers.interface.ts
+++ b/src/providers/providers.interface.ts
@@ -6,6 +6,8 @@ export interface PopupContextValue {
 }
 
 export type TPopupContext = {
+  connected: boolean
+  wallet: string
   amount: number
   amountType: {
     recurring: boolean


Test patch:
diff --git a/src/providers/__tests__/popup-context.test.tsx b/src/providers/__tests__/popup-context.test.tsx
index ed841a6..cb6996b 100644
--- a/src/providers/__tests__/popup-context.test.tsx
+++ b/src/providers/__tests__/popup-context.test.tsx
@@ -1,7 +1,7 @@
 import { act, render, screen } from '@testing-library/react'
 import React, { useContext } from 'react'
 
-import { defaultData, PopupContext, PopupProvider } from '../popup-context'
+import { defaultData, PopupContext, PopupProvider } from '../popup.provider'
 
 const TestComponent = () => {
   const { data, setData } = useContext(PopupContext)