mirror of
https://github.com/kremalicious/blog.git
synced 2024-12-22 17:23:50 +01:00
new send/preview flow
This commit is contained in:
parent
52dba93d71
commit
498e5a1730
@ -8,7 +8,7 @@ const year = new Date().getFullYear()
|
||||
const { name, url, github } = config.author
|
||||
---
|
||||
|
||||
<footer role="contentinfo" class={styles.footer}>
|
||||
<footer role="contentinfo" class={styles.footer} id="footer">
|
||||
<Vcard />
|
||||
<section class={styles.copyright}>
|
||||
<p>
|
||||
|
@ -6,7 +6,7 @@ import { Logo } from '@images/components'
|
||||
import styles from './index.module.css'
|
||||
---
|
||||
|
||||
<header class={styles.header} aria-label="Header">
|
||||
<header class={styles.header} aria-label="Header" id="header">
|
||||
<div class={styles.headerContent}>
|
||||
<a href="/" class={styles.title}>
|
||||
<Logo class={styles.logo} viewBox="0 0 191 36" /> kremalicious
|
||||
|
16
src/components/Loader/Loader.module.css
Normal file
16
src/components/Loader/Loader.module.css
Normal file
@ -0,0 +1,16 @@
|
||||
.loader {
|
||||
will-change: transform;
|
||||
animation: spin 1s linear infinite;
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(359deg);
|
||||
}
|
||||
}
|
8
src/components/Loader/Loader.tsx
Normal file
8
src/components/Loader/Loader.tsx
Normal file
@ -0,0 +1,8 @@
|
||||
import styles from './Loader.module.css'
|
||||
import { Loader as LoaderIcon } from '@images/components/react'
|
||||
|
||||
export function Loader() {
|
||||
// TODO: fix React props for generated SVG components for class/className
|
||||
//@ts-expect-error-next-line
|
||||
return <LoaderIcon className={styles.loader} />
|
||||
}
|
1
src/components/Loader/index.tsx
Normal file
1
src/components/Loader/index.tsx
Normal file
@ -0,0 +1 @@
|
||||
export * from './Loader'
|
@ -1,11 +1,9 @@
|
||||
.web3 {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
margin: calc(var(--spacer) / 2) auto calc(var(--spacer) / 4) auto;
|
||||
max-width: 25rem;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
min-height: 165px;
|
||||
margin-top: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
.rainbowkit button > div {
|
||||
@ -56,11 +54,18 @@
|
||||
}
|
||||
|
||||
.disclaimer {
|
||||
color: var(--text-color-light);
|
||||
font-size: var(--font-size-small);
|
||||
margin-top: calc(var(--spacer) / 3);
|
||||
margin-top: calc(var(--spacer) / 2);
|
||||
margin-bottom: calc(var(--spacer) / 6);
|
||||
}
|
||||
|
||||
.disclaimer code {
|
||||
background: none;
|
||||
color: var(--text-color);
|
||||
padding-left: 2px;
|
||||
}
|
||||
|
||||
.message {
|
||||
font-size: var(--font-size-small);
|
||||
position: relative;
|
||||
|
@ -5,34 +5,35 @@ import { ConnectButton } from '@rainbow-me/rainbowkit'
|
||||
import { InputGroup } from '../Input'
|
||||
import styles from './index.module.css'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken } from '@features/Web3/stores/selectedToken'
|
||||
import { $selectedToken, $isInitSend } from '@features/Web3/stores'
|
||||
import siteConfig from '@config/blog.config'
|
||||
import { Send } from '../Send/Send'
|
||||
import { Send } from '../Send'
|
||||
|
||||
export default function Web3Form(): ReactElement {
|
||||
const { address: account } = useAccount()
|
||||
const selectedToken = useStore($selectedToken)
|
||||
const isInitSend = useStore($isInitSend)
|
||||
|
||||
const [amount, setAmount] = useState('')
|
||||
const [debouncedAmount] = useDebounce(amount, 500)
|
||||
const [initSend, setInitSend] = useState(false)
|
||||
|
||||
const isDisabled = !account
|
||||
|
||||
// reset amount whenever token changes
|
||||
useEffect(() => {
|
||||
if (!selectedToken) return
|
||||
setAmount('')
|
||||
}, [selectedToken])
|
||||
|
||||
return initSend ? (
|
||||
<Send amount={debouncedAmount} setInitSend={setInitSend} />
|
||||
return isInitSend ? (
|
||||
<Send amount={debouncedAmount} />
|
||||
) : (
|
||||
<form
|
||||
className={styles.web3}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
if (debouncedAmount !== '' || debouncedAmount === '0') return
|
||||
setInitSend(true)
|
||||
if (debouncedAmount === '' || debouncedAmount === '0') return
|
||||
$isInitSend.set(true)
|
||||
}}
|
||||
>
|
||||
<>
|
||||
@ -42,7 +43,6 @@ export default function Web3Form(): ReactElement {
|
||||
<InputGroup
|
||||
amount={amount}
|
||||
setAmount={setAmount}
|
||||
setInitSend={setInitSend}
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
<div className={styles.disclaimer}>
|
||||
|
@ -64,12 +64,12 @@
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
border-color: var(--link-color-hover);
|
||||
padding: 0 calc(var(--spacer) / 4);
|
||||
padding: 0 calc(var(--spacer) / 2);
|
||||
}
|
||||
|
||||
@media (min-width: 40rem) {
|
||||
.submit {
|
||||
width: fit-content;
|
||||
width: 115px;
|
||||
border-top-right-radius: var(--border-radius);
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
|
@ -3,17 +3,16 @@ import Input from '@components/Input'
|
||||
import { Conversion } from '../Conversion'
|
||||
import styles from './InputGroup.module.css'
|
||||
import { TokenSelect } from '../TokenSelect'
|
||||
import { $isInitSend } from '@features/Web3/stores'
|
||||
|
||||
export function InputGroup({
|
||||
amount,
|
||||
isDisabled,
|
||||
setAmount,
|
||||
setInitSend
|
||||
setAmount
|
||||
}: {
|
||||
amount: string
|
||||
isDisabled: boolean
|
||||
setAmount: React.Dispatch<React.SetStateAction<string>>
|
||||
setInitSend: React.Dispatch<React.SetStateAction<boolean>>
|
||||
}): ReactElement {
|
||||
return (
|
||||
<>
|
||||
@ -36,9 +35,9 @@ export function InputGroup({
|
||||
<button
|
||||
className={`${styles.submit} btn btn-primary`}
|
||||
disabled={isDisabled || !amount}
|
||||
onClick={() => setInitSend(true)}
|
||||
onClick={() => $isInitSend.set(true)}
|
||||
>
|
||||
Make it rain
|
||||
Preview
|
||||
</button>
|
||||
</div>
|
||||
<Conversion amount={amount} />
|
||||
|
24
src/features/Web3/components/Send/Send.module.css
Normal file
24
src/features/Web3/components/Send/Send.module.css
Normal file
@ -0,0 +1,24 @@
|
||||
.send {
|
||||
margin-top: calc(var(--spacer) / 2);
|
||||
}
|
||||
|
||||
.title {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.actions button {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.actions button:first-child {
|
||||
margin-right: var(--spacer);
|
||||
width: 115px;
|
||||
height: 50px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0;
|
||||
}
|
@ -1,27 +1,27 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken } from '@features/Web3/stores/selectedToken'
|
||||
import { useNetwork, useEnsAddress } from 'wagmi'
|
||||
import siteConfig from '@config/blog.config'
|
||||
import { useState, type FormEvent, useEffect } from 'react'
|
||||
import { prepareTransaction } from './prepareTransaction'
|
||||
import { sendTransaction } from './sendTransaction'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useNetwork, useEnsAddress, useEnsName } from 'wagmi'
|
||||
import type {
|
||||
SendTransactionArgs,
|
||||
WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import { formatEther } from 'viem'
|
||||
import { $selectedToken, $isInitSend } from '@features/Web3/stores'
|
||||
import siteConfig from '@config/blog.config'
|
||||
import { prepareTransaction, sendTransaction } from './actions'
|
||||
import styles from './Send.module.css'
|
||||
import { SendTable } from './SendTable'
|
||||
import { Loader } from '@components/Loader'
|
||||
|
||||
export function Send({
|
||||
amount,
|
||||
setInitSend
|
||||
}: {
|
||||
amount: string
|
||||
setInitSend: (initSend: boolean) => void
|
||||
}) {
|
||||
export function Send({ amount }: { amount: string }) {
|
||||
const { ens } = siteConfig.author.ether
|
||||
const { chain } = useNetwork()
|
||||
const selectedToken = useStore($selectedToken)
|
||||
const { data: to } = useEnsAddress({
|
||||
name: siteConfig.author.ether.ens,
|
||||
|
||||
// Always resolve to address from ENS name and vice versa
|
||||
// so nobody has to trust my config values.
|
||||
const { data: to } = useEnsAddress({ name: ens, chainId: 1 })
|
||||
const { data: ensResolved } = useEnsName({
|
||||
address: to as `0x${string}` | undefined,
|
||||
chainId: 1
|
||||
})
|
||||
|
||||
@ -29,6 +29,7 @@ export function Send({
|
||||
SendTransactionArgs | WriteContractPreparedArgs
|
||||
>()
|
||||
const [txHash, setTxHash] = useState<string>()
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
@ -47,27 +48,47 @@ export function Send({
|
||||
|
||||
async function handleSend(event: FormEvent<HTMLButtonElement>) {
|
||||
event?.preventDefault()
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const result = await sendTransaction(selectedToken, txConfig)
|
||||
setTxHash(result?.hash)
|
||||
setIsLoading(false)
|
||||
} catch (error: unknown) {
|
||||
console.error((error as Error).message)
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const value =
|
||||
(txConfig as SendTransactionArgs)?.value ||
|
||||
(txConfig as WriteContractPreparedArgs)?.request?.args[1] ||
|
||||
'0'
|
||||
const displayAmountFromConfig = formatEther(value)
|
||||
console.log(txHash)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<p>You are about to send</p>
|
||||
<p>
|
||||
{displayAmountFromConfig} {selectedToken?.symbol} to <code>{to}</code>{' '}
|
||||
on {chain?.name}
|
||||
</p>
|
||||
<button onClick={(e) => handleSend(e)}>Confirm</button>
|
||||
<button onClick={() => setInitSend(false)}>Cancel</button>
|
||||
<div className={styles.send}>
|
||||
{/* <h5 className={styles.title}>You are sending</h5> */}
|
||||
|
||||
<SendTable
|
||||
to={to}
|
||||
ensResolved={ensResolved}
|
||||
txConfig={txConfig}
|
||||
isDisabled={isLoading}
|
||||
/>
|
||||
|
||||
<footer className={styles.actions}>
|
||||
<button
|
||||
onClick={(e) => handleSend(e)}
|
||||
className="btn btn-primary"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? <Loader /> : 'Make it rain'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => $isInitSend.set(false)}
|
||||
className="link"
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
40
src/features/Web3/components/Send/SendTable.module.css
Normal file
40
src/features/Web3/components/Send/SendTable.module.css
Normal file
@ -0,0 +1,40 @@
|
||||
.amount,
|
||||
.network,
|
||||
.to {
|
||||
/* font-weight: var(--font-weight-bold); */
|
||||
}
|
||||
|
||||
.to {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
background: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.to:last-child {
|
||||
padding-left: 14px;
|
||||
font-size: var(--font-size-mini);
|
||||
}
|
||||
|
||||
.to:last-child::first-letter {
|
||||
margin-left: -14px;
|
||||
}
|
||||
|
||||
.table {
|
||||
/* max-width: 386px; */
|
||||
margin-bottom: calc(var(--spacer) / 1.5);
|
||||
}
|
||||
|
||||
table[aria-disabled='true'] {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.table td {
|
||||
padding: calc(var(--spacer) / 4);
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--text-color-light);
|
||||
vertical-align: top;
|
||||
}
|
66
src/features/Web3/components/Send/SendTable.tsx
Normal file
66
src/features/Web3/components/Send/SendTable.tsx
Normal file
@ -0,0 +1,66 @@
|
||||
import { formatEther } from 'viem'
|
||||
import { useNetwork } from 'wagmi'
|
||||
import type {
|
||||
SendTransactionArgs,
|
||||
WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import styles from './SendTable.module.css'
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { $selectedToken } from '@features/Web3/stores'
|
||||
|
||||
export function SendTable({
|
||||
to,
|
||||
ensResolved,
|
||||
txConfig,
|
||||
isDisabled
|
||||
}: {
|
||||
to: `0x${string}` | null | undefined
|
||||
ensResolved: string | null | undefined
|
||||
txConfig: SendTransactionArgs | WriteContractPreparedArgs | undefined
|
||||
isDisabled: boolean
|
||||
}) {
|
||||
const { chain } = useNetwork()
|
||||
const selectedToken = useStore($selectedToken)
|
||||
|
||||
// Derive display values in preview from actual tx config
|
||||
// instead from our form stores
|
||||
const value =
|
||||
(txConfig as SendTransactionArgs)?.value ||
|
||||
(txConfig as WriteContractPreparedArgs)?.request?.args?.[1] ||
|
||||
'0'
|
||||
const displayAmountFromConfig = formatEther(value as bigint)
|
||||
|
||||
return (
|
||||
<table className={styles.table} aria-disabled={isDisabled}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className={styles.label}>Sending</td>
|
||||
<td>
|
||||
<span className={styles.amount}>
|
||||
{displayAmountFromConfig} {selectedToken?.symbol}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className={styles.label}>on</td>
|
||||
<td>
|
||||
<span className={styles.network}>{chain?.name}</span>
|
||||
</td>
|
||||
</tr>
|
||||
{/* <tr>
|
||||
<td>From</td>
|
||||
<td>
|
||||
<code className={styles.from}>{from}</code>
|
||||
</td>
|
||||
</tr> */}
|
||||
<tr>
|
||||
<td className={styles.label}>to</td>
|
||||
<td title={`${ensResolved} successfully resolved to ${to}`}>
|
||||
<code className={styles.to}>{ensResolved}</code>
|
||||
<code className={styles.to}>{`→ ${to}`}</code>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
2
src/features/Web3/components/Send/actions/index.ts
Normal file
2
src/features/Web3/components/Send/actions/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './prepareTransaction'
|
||||
export * from './sendTransaction'
|
@ -0,0 +1,34 @@
|
||||
import type { GetToken } from '@features/Web3/stores/tokens'
|
||||
import { parseEther, parseUnits } from 'viem'
|
||||
import {
|
||||
prepareSendTransaction,
|
||||
prepareWriteContract,
|
||||
type SendTransactionArgs,
|
||||
type WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import { abiErc20Transfer } from '../abiErc20Transfer'
|
||||
|
||||
export async function prepareTransaction(
|
||||
selectedToken: GetToken | undefined,
|
||||
amount: string | undefined,
|
||||
to: `0x${string}` | null | undefined,
|
||||
chainId: number | undefined
|
||||
) {
|
||||
if (!chainId || !to || !amount || !selectedToken || !selectedToken?.address)
|
||||
return
|
||||
|
||||
const isNative = selectedToken.address === '0x0'
|
||||
const setupNative = { chainId, to, value: parseEther(amount) }
|
||||
const setupErc20 = {
|
||||
address: selectedToken.address,
|
||||
abi: abiErc20Transfer,
|
||||
functionName: 'transfer',
|
||||
args: [to, parseUnits(amount, selectedToken.decimals || 18)]
|
||||
}
|
||||
|
||||
const config = isNative
|
||||
? ((await prepareSendTransaction(setupNative)) as SendTransactionArgs)
|
||||
: ((await prepareWriteContract(setupErc20)) as WriteContractPreparedArgs)
|
||||
|
||||
return config
|
||||
}
|
@ -1,35 +0,0 @@
|
||||
import type { GetToken } from '@features/Web3/stores/tokens'
|
||||
import { parseEther, parseUnits } from 'viem'
|
||||
import {
|
||||
prepareSendTransaction,
|
||||
prepareWriteContract,
|
||||
type SendTransactionArgs,
|
||||
type WriteContractPreparedArgs
|
||||
} from 'wagmi/actions'
|
||||
import { abiErc20Transfer } from './abiErc20Transfer'
|
||||
|
||||
export async function prepareTransaction(
|
||||
selectedToken: GetToken | undefined,
|
||||
amount: string | undefined,
|
||||
to: `0x${string}` | null | undefined,
|
||||
chainId: number | undefined
|
||||
) {
|
||||
if (!chainId || !to || !amount || !selectedToken) return
|
||||
|
||||
const isNative = selectedToken?.address === '0x0'
|
||||
|
||||
const config = isNative
|
||||
? ((await prepareSendTransaction({
|
||||
chainId,
|
||||
to,
|
||||
value: parseEther(amount)
|
||||
})) as SendTransactionArgs)
|
||||
: ((await prepareWriteContract({
|
||||
address: selectedToken?.address,
|
||||
abi: abiErc20Transfer,
|
||||
functionName: 'transfer',
|
||||
args: [to, parseUnits(amount, selectedToken?.decimals || 18)]
|
||||
})) as WriteContractPreparedArgs)
|
||||
|
||||
return config
|
||||
}
|
@ -1,8 +0,0 @@
|
||||
export type SendFormData = {
|
||||
data: { hash: `0x${string}` }
|
||||
send: () => Promise<void>
|
||||
isLoading: boolean
|
||||
isSuccess: boolean
|
||||
isError: boolean
|
||||
error: Error | null
|
||||
}
|
3
src/features/Web3/stores/index.ts
Normal file
3
src/features/Web3/stores/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './tokens'
|
||||
export * from './selectedToken'
|
||||
export * from './isInitSend'
|
3
src/features/Web3/stores/isInitSend.ts
Normal file
3
src/features/Web3/stores/isInitSend.ts
Normal file
@ -0,0 +1,3 @@
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
export const $isInitSend = atom<boolean>(false)
|
@ -67,6 +67,35 @@ import CodeCopy from '@components/CopyCode.astro'
|
||||
}
|
||||
</style>
|
||||
|
||||
<style is:global>
|
||||
html.isInitSend #header,
|
||||
html.isInitSend #footer,
|
||||
html.isInitSend section:not(.isInitSend),
|
||||
html.isInitSend h2,
|
||||
html.isInitSend h3 {
|
||||
opacity: 0.1;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.2s ease-out;
|
||||
}
|
||||
|
||||
html.isInitSend #web3 {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
</style>
|
||||
|
||||
<script>
|
||||
import { $isInitSend } from '@features/Web3/stores'
|
||||
|
||||
$isInitSend.subscribe((value) => {
|
||||
const html = document.querySelector('html')
|
||||
|
||||
value
|
||||
? html?.classList.add('isInitSend')
|
||||
: html?.classList.remove('isInitSend')
|
||||
})
|
||||
</script>
|
||||
|
||||
<LayoutBase title="Say Thanks" pageTitle="Say Thanks">
|
||||
<!-- <BackButton /> -->
|
||||
<div class="grid">
|
||||
@ -78,7 +107,7 @@ import CodeCopy from '@components/CopyCode.astro'
|
||||
can send me some Ether, ERC-20 token, or Bitcoin.
|
||||
</section>
|
||||
|
||||
<section class="section highlight">
|
||||
<section class="section highlight" id="web3">
|
||||
<h4 class="titleCoin"><Wallet /> Web3 Wallet</h4>
|
||||
<Web3 client:load />
|
||||
</section>
|
||||
@ -91,12 +120,27 @@ import CodeCopy from '@components/CopyCode.astro'
|
||||
|
||||
<div>
|
||||
<h3 class="subTitle">Sponsor</h3>
|
||||
<section class="section">You can also sponsor me on GitHub.</section>
|
||||
<section class="section">
|
||||
<p>You can also sponsor me on GitHub.</p>
|
||||
<a href="https://github.com/sponsors/kremalicious/">
|
||||
<img
|
||||
src="https://img.shields.io/static/v1?label=Sponsor%20On%20GitHub&labelColor=%2343a699&message=%E2%9D%A4&logo=GitHub&color=%23fe8e86&style=for-the-badge"
|
||||
/>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<h3 class="subTitle">Hire Me</h3>
|
||||
<section class="section">
|
||||
<p>
|
||||
Available for contract work to solve your design, front-end, and web3
|
||||
problems.
|
||||
</p>
|
||||
<p>
|
||||
Get in touch on <a href="https://matthiaskretschmann.com"
|
||||
>my portfolio</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</LayoutBase>
|
||||
|
@ -33,9 +33,11 @@ a.btn {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* // Disabled State */
|
||||
/* Disabled State */
|
||||
.btn.disabled,
|
||||
.btn[disabled] {
|
||||
.btn[disabled],
|
||||
button:disabled,
|
||||
.btn:disabled {
|
||||
/* TODO: cursor & pointer values can't be used together */
|
||||
cursor: not-allowed;
|
||||
pointer-events: none;
|
||||
|
@ -319,7 +319,6 @@ table {
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
th,
|
||||
@ -331,6 +330,10 @@ td {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
tr:last-child td {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Selection
|
||||
///////////////////////////////////// */
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user