Skip to content

Commit 443d7e3

Browse files
committed
SD-91: Add activity tab
1 parent 420ac48 commit 443d7e3

File tree

11 files changed

+518
-37
lines changed

11 files changed

+518
-37
lines changed

package-lock.json

Lines changed: 27 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"react-dom": "^19.0.0",
4444
"react-merge-refs": "^2.1.1",
4545
"rooks": "^8.0.0",
46+
"simplebar-react": "^3.3.1",
4647
"styled-components": "^6.1.15",
4748
"styled-reset": "^4.5.2",
4849
"ts-pattern": "^5.6.2",

src/domains/misc/components/ScrollShadow.tsx

Lines changed: 35 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,37 @@
1-
import { useMotionValueEvent, useScroll } from 'motion/react';
1+
import { useMotionValueEvent, useScroll } from 'framer-motion';
22
import { ReactNode, useEffect, useRef, useState } from 'react';
3+
import SimpleBar from 'simplebar-react';
34
import styled, { css } from 'styled-components';
45

6+
import vars from 'src/domains/styling/utils/vars';
7+
import 'simplebar-react/dist/simplebar.min.css';
8+
59
type Props = {
610
children: ReactNode,
711
className?: string,
12+
fadePosition?: 'top' | 'bottom' | 'both',
13+
maxHeight?: `${string}px` | `${string}%`,
814
};
915

10-
const ScrollShadow = ({ children, className }: Props) => {
16+
const ScrollShadow = ({ children, className, fadePosition = 'both', maxHeight }: Props) => {
1117
const ref = useRef<HTMLDivElement | null>(null);
1218
const { scrollY } = useScroll({ container: ref });
1319
const [isScrolledTop, setIsScrolledTop] = useState(false);
1420
const [isScrolledBottom, setIsScrolledBottom] = useState(false);
1521

22+
const isTopFadeVisible = (fadePosition === 'top' || fadePosition === 'both') && isScrolledTop;
23+
const isBottomFadeVisible = (fadePosition === 'bottom' || fadePosition === 'both') && isScrolledBottom;
24+
1625
const updateScrollState = () => {
1726
const el = ref.current;
1827
if (!el) return;
1928

2029
const { clientHeight, scrollHeight, scrollTop } = el;
30+
const isTop = scrollTop > 0;
31+
const isBottom = scrollHeight > clientHeight && scrollTop < scrollHeight - clientHeight;
2132

22-
setIsScrolledTop(scrollTop > 0);
23-
setIsScrolledBottom(scrollHeight > clientHeight && scrollTop < (scrollHeight - clientHeight));
33+
setIsScrolledTop(isTop);
34+
setIsScrolledBottom(isBottom);
2435
};
2536

2637
useEffect(() => {
@@ -31,10 +42,11 @@ const ScrollShadow = ({ children, className }: Props) => {
3142

3243
return (
3344
<Container
34-
$isScrolledTop={isScrolledTop}
35-
$isScrolledBottom={isScrolledBottom}
36-
ref={ref}
45+
scrollableNodeProps={{ ref }}
3746
className={className}
47+
$isScrolledTop={isTopFadeVisible}
48+
$isScrolledBottom={isBottomFadeVisible}
49+
style={{ maxHeight }}
3850
>
3951
{children}
4052
</Container>
@@ -43,8 +55,8 @@ const ScrollShadow = ({ children, className }: Props) => {
4355

4456
export default ScrollShadow;
4557

46-
const Container = styled.div<{ $isScrolledTop: boolean, $isScrolledBottom: boolean }>`
47-
width: min(100vw, 440px);
58+
/* eslint-disable-next-line @typescript-eslint/no-unsafe-assignment */
59+
const Container = styled(SimpleBar)<{ $isScrolledTop: boolean, $isScrolledBottom: boolean }>`
4860
overflow-y: auto;
4961
5062
mask-composite: intersect;
@@ -69,4 +81,18 @@ const Container = styled.div<{ $isScrolledTop: boolean, $isScrolledBottom: boole
6981
`;
7082
}
7183
}}
84+
85+
.simplebar-scrollbar::before {
86+
background-color: ${vars('--color-neutral-background-scrollbar-overlay-rest')};
87+
opacity: 1;
88+
}
89+
90+
.simplebar-track {
91+
width: ${vars('--spacing-s')};
92+
background: transparent;
93+
}
94+
95+
&:hover .simplebar-scrollbar::before {
96+
background-color: ${vars('--color-neutral-background-scrollbar-overlay-rest')};
97+
}
7298
`;
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import dayjs from 'dayjs';
2+
import { AnimatePresence } from 'framer-motion';
3+
import styled from 'styled-components';
4+
import { objectEntries } from 'tsafe';
5+
6+
import Badge from 'src/domains/misc/components/Badge';
7+
import ScrollShadow from 'src/domains/misc/components/ScrollShadow';
8+
import ActivityPlaceholder from 'src/domains/shielder/components/Activity/ActivityPlaceholder';
9+
import { Transactions } from 'src/domains/shielder/stores/getShielderIndexedDB';
10+
import useTransactionsHistory from 'src/domains/shielder/utils/useTransactionsHistory';
11+
import vars from 'src/domains/styling/utils/vars';
12+
13+
import ActivityItem from './ActivityItem';
14+
import Empty from './Empty';
15+
16+
const Activity = () => {
17+
const { data, isLoading } = useTransactionsHistory();
18+
19+
const grouped = data?.reduce<Record<string, Transactions>>((acc, tx) => {
20+
const date = dayjs(tx.timestamp).format('YYYY-MM-DD');
21+
const newTx = tx.type === 'NewAccount' ? [ { ...tx, type: 'Deposit' as const }, { ...tx }] : [tx];
22+
return {
23+
...acc,
24+
[date]: [...newTx, ...(acc[date] ?? [])],
25+
};
26+
}, {});
27+
28+
// eslint-disable-next-line no-restricted-syntax
29+
const sorted = grouped ? objectEntries(grouped)
30+
.sort(([a], [b]) => dayjs(b).valueOf() - dayjs(a).valueOf()) : [];
31+
32+
return (
33+
<Container>
34+
<AnimatePresence>
35+
{!data && !isLoading && <Empty />}
36+
</AnimatePresence>
37+
<ScrollShadow maxHeight="300px">
38+
{sorted.map(([date, transactions]) => (
39+
<Wrapper>
40+
<Group key={date}>
41+
<Badge
42+
design="tint"
43+
variant="subtle"
44+
size="medium"
45+
text={dayjs(date).format('MMM D')}
46+
/>
47+
{transactions.map(transaction => (
48+
<ActivityItem transaction={transaction} key={transaction.txHash} />
49+
))}
50+
</Group>
51+
</Wrapper>
52+
))}
53+
</ScrollShadow>
54+
<PlaceholderWrapper>
55+
{Array.from({ length: 10 }).map((_, i) => (
56+
<ActivityPlaceholder key={i} />
57+
))}
58+
</PlaceholderWrapper>
59+
</Container>
60+
);
61+
};
62+
63+
export default Activity;
64+
65+
const Container = styled.div`
66+
display: flex;
67+
flex-direction: column;
68+
position: relative;
69+
height: 300px;
70+
overflow: hidden;
71+
`;
72+
73+
const Wrapper = styled.div`
74+
display: flex;
75+
76+
position: relative;
77+
78+
flex-direction: column;
79+
gap: ${vars('--spacing-m')};
80+
81+
width: 100%;
82+
padding-right: 8px;
83+
padding-bottom: ${vars('--spacing-l')};
84+
85+
overflow-x: hidden;
86+
overflow-y: auto;
87+
`;
88+
89+
const Group = styled.div`
90+
display: flex;
91+
flex-direction: column;
92+
align-items: start;
93+
gap: ${vars('--spacing-m')};
94+
`;
95+
96+
const PlaceholderWrapper = styled.div`
97+
display: flex;
98+
flex-direction: column;
99+
gap: ${vars('--spacing-m')};
100+
flex: 1;
101+
overflow: hidden;
102+
mask-image: linear-gradient(to top, transparent, black 100px);
103+
`;
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import styled, { css } from 'styled-components';
2+
3+
import CIcon, { IconName } from 'src/domains/misc/components/CIcon';
4+
import { Transactions } from 'src/domains/shielder/stores/getShielderIndexedDB';
5+
import vars from 'src/domains/styling/utils/vars';
6+
7+
const ICONS_BY_TYPE: Record<Transactions[number]['type'], IconName> = {
8+
Deposit: 'ShieldedFilled',
9+
Withdraw: 'ArrowUpRight',
10+
NewAccount: 'AddCircle',
11+
} as const;
12+
13+
type Props = {
14+
type: keyof typeof ICONS_BY_TYPE,
15+
size: number,
16+
className?: string,
17+
};
18+
19+
const AccountTypeIcon = ({ type, size, className }: Props) => (
20+
<Wrapper>
21+
<IconWrapper className={className} $size={size}>
22+
<CIcon
23+
icon={ICONS_BY_TYPE[type]}
24+
size={size / 1.6}
25+
/>
26+
</IconWrapper>
27+
{type === 'Withdraw' && (
28+
<AdditionalIconWrapper className={className}>
29+
<CIcon icon="ShieldedFilled" size={size / 2.4} />
30+
</AdditionalIconWrapper>
31+
)}
32+
</Wrapper>
33+
);
34+
35+
export default styled(AccountTypeIcon)``;
36+
37+
const IconWrapper = styled.div<{ $size: number, $withBorder?: boolean }>`
38+
display: grid;
39+
40+
position: relative;
41+
42+
place-items: center;
43+
44+
height: ${({ $size }) => `${$size}px`};
45+
width: ${({ $size }) => `${$size}px`};
46+
47+
48+
color: ${vars('--color-neutral-foreground-1-rest')};
49+
50+
51+
52+
border-radius: ${vars('--border-radius-circular')};
53+
background: ${vars('--color-neutral-background-4a-rest')};
54+
55+
56+
57+
${({ $withBorder }) => $withBorder && css`
58+
border: 1px solid ${vars('--color-neutral-stroke-subtle-rest')};
59+
`}
60+
`;
61+
62+
const AdditionalIconWrapper = styled.div`
63+
display: flex;
64+
65+
position: absolute;
66+
top: calc(${vars('--spacing-xxs')} * -1);
67+
left: calc(${vars('--spacing-xxs')} * -1);
68+
69+
padding: ${vars('--spacing-xxs-nudge')};
70+
border: ${vars('--spacing-xxs')} solid ${vars('--color-neutral-background-2-rest')};
71+
72+
color: ${vars('--color-brand-background-1-rest')};
73+
74+
border-radius: ${vars('--border-radius-circular')};
75+
background: ${vars('--color-neutral-background-4a-rest')};
76+
77+
aspect-ratio: 1/1;
78+
79+
`;
80+
81+
const Wrapper = styled.div`
82+
position: relative;
83+
background: inherit;
84+
`;

0 commit comments

Comments
 (0)