From 29174cbf95b20a9867d94f89b9751385d2a50a5f Mon Sep 17 00:00:00 2001 From: siandreev Date: Tue, 16 Jan 2024 17:26:43 +0100 Subject: [PATCH 01/16] chore: web serving config updated --- apps/web/index.html | 4 ++-- apps/web/tsconfig.json | 3 ++- apps/web/tsconfig.node.json | 2 +- apps/web/vite.config.mts | 6 ++++++ apps/web/vite.config.ts | 17 ----------------- 5 files changed, 11 insertions(+), 21 deletions(-) create mode 100644 apps/web/vite.config.mts delete mode 100644 apps/web/vite.config.ts diff --git a/apps/web/index.html b/apps/web/index.html index 2d3b13a83..34107fabe 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -39,12 +39,12 @@ window.Buffer = Buffer; import process from "process"; - window.process = process; + window.process = process;
- + diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2b6de22f6..8f9d0bf15 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -7,7 +7,8 @@ "skipLibCheck": true, /* Bundler mode */ - "moduleResolution": "bundler", + "allowSyntheticDefaultImports": true, + "moduleResolution": "node", "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, diff --git a/apps/web/tsconfig.node.json b/apps/web/tsconfig.node.json index 26063d857..af7c06e30 100644 --- a/apps/web/tsconfig.node.json +++ b/apps/web/tsconfig.node.json @@ -3,7 +3,7 @@ "composite": true, "skipLibCheck": true, "module": "ESNext", - "moduleResolution": "bundler", + "moduleResolution": "node", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts new file mode 100644 index 000000000..2bc946fc1 --- /dev/null +++ b/apps/web/vite.config.mts @@ -0,0 +1,6 @@ +import react from '@vitejs/plugin-react'; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [react()] +}); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts deleted file mode 100644 index 2c4348fc5..000000000 --- a/apps/web/vite.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -import react from '@vitejs/plugin-react'; -import * as path from 'path'; -import { defineConfig } from 'vite'; - -export default defineConfig({ - plugins: [react()], - resolve: { - alias: { - react: path.resolve(__dirname, './node_modules/react'), - 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), - 'react-router-dom': path.resolve(__dirname, './node_modules/react-router-dom'), - 'styled-components': path.resolve(__dirname, './node_modules/styled-components'), - 'react-i18next': path.resolve(__dirname, './node_modules/react-i18next'), - '@tanstack/react-query': path.resolve(__dirname, './node_modules/@tanstack/react-query') - } - } -}); From dc765fce06436e09b96b30bc823404e1296544a1 Mon Sep 17 00:00:00 2001 From: siandreev Date: Tue, 16 Jan 2024 17:36:09 +0100 Subject: [PATCH 02/16] chore: jsx replaced with react-jsx for uikit module --- packages/uikit/tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/uikit/tsconfig.json b/packages/uikit/tsconfig.json index d1efb6706..0425dedbc 100644 --- a/packages/uikit/tsconfig.json +++ b/packages/uikit/tsconfig.json @@ -1,6 +1,6 @@ { "compilerOptions": { - "jsx": "react", + "jsx": "react-jsx", "target": "esnext", "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, @@ -16,7 +16,7 @@ "isolatedModules": true, "noEmit": false, "outDir": "dist", - "declaration": true + "declaration": true, }, "include": ["src"], "exclude": ["node_modules", "**/stories"] From a43191ea6eb97f3201f89fcddf9680e51b3bc2d7 Mon Sep 17 00:00:00 2001 From: siandreev Date: Thu, 18 Jan 2024 13:16:59 +0100 Subject: [PATCH 03/16] feat: simple carousel added --- apps/web/src/App.tsx | 9 + packages/uikit/src/components/Footer.tsx | 40 +++++ .../src/components/shared/carousel/index.tsx | 166 ++++++++++++++++++ packages/uikit/src/components/shared/index.ts | 1 + packages/uikit/src/libs/routes.ts | 1 + packages/uikit/src/pages/browser/index.tsx | 27 +++ 6 files changed, 244 insertions(+) create mode 100644 packages/uikit/src/components/shared/carousel/index.tsx create mode 100644 packages/uikit/src/components/shared/index.ts create mode 100644 packages/uikit/src/pages/browser/index.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index f0bae78f2..1c59a6974 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -51,6 +51,7 @@ import { useAnalytics, useAppHeight, useAppWidth } from './libs/hooks'; const ImportRouter = React.lazy(() => import('@tonkeeper/uikit/dist/pages/import')); const Settings = React.lazy(() => import('@tonkeeper/uikit/dist/pages/settings')); +const Browser = React.lazy(() => import('@tonkeeper/uikit/dist/pages/browser')); const Activity = React.lazy(() => import('@tonkeeper/uikit/dist/pages/activity/Activity')); const Home = React.lazy(() => import('@tonkeeper/uikit/dist/pages/home/Home')); const Coin = React.lazy(() => import('@tonkeeper/uikit/dist/pages/coin/Coin')); @@ -276,6 +277,14 @@ export const Content: FC<{ } /> + }> + + + } + /> { ); }; +const BrowserIcon = () => { + return ( + + + + + + ); +}; + const SettingsIcon = () => { return ( = ({ standal if (location.pathname.includes(AppRoute.settings)) { return AppRoute.settings; } + if (location.pathname.includes(AppRoute.browser)) { + return AppRoute.browser; + } return AppRoute.home; }, [location.pathname]); @@ -177,6 +210,13 @@ export const Footer: FC<{ standalone?: boolean; sticky?: boolean }> = ({ standal {t('activity_screen_title')} +
+ + 1 + 2 + 3 + +
+ ); +}; + +export default BrowserPage; From e7fead5656d15c4c611dd1ce655a07a62a36b118 Mon Sep 17 00:00:00 2001 From: siandreev Date: Thu, 18 Jan 2024 18:28:22 +0100 Subject: [PATCH 04/16] fix: carousel replaced with react-slide library. Country selection added --- packages/uikit/package.json | 4 + packages/uikit/src/components/Skeleton.tsx | 9 +- .../src/components/shared/carousel/index.tsx | 185 ++++++------------ .../src/hooks/browser/useRecommendations.ts | 41 ++++ packages/uikit/src/libs/queryKey.ts | 1 + packages/uikit/src/pages/browser/index.tsx | 111 ++++++++++- yarn.lock | 100 ++++++++++ 7 files changed, 312 insertions(+), 139 deletions(-) create mode 100644 packages/uikit/src/hooks/browser/useRecommendations.ts diff --git a/packages/uikit/package.json b/packages/uikit/package.json index a97be750b..31d3721b1 100644 --- a/packages/uikit/package.json +++ b/packages/uikit/package.json @@ -28,7 +28,9 @@ "react-lottie": "^1.2.4", "react-qrcode-logo": "^2.9.0", "react-router-dom": "^6.4.5", + "react-slick": "^0.29.0", "react-transition-group": "^4.4.5", + "slick-carousel": "^1.8.1", "styled-components": "^6.1.1", "ton": "^13.4.1", "ton-core": "^0.49.0", @@ -50,7 +52,9 @@ "@types/react-beautiful-dnd": "^13.1.3", "@types/react-dom": "^18.0.9", "@types/react-lottie": "^1.2.6", + "@types/react-slick": "^0", "@types/react-transition-group": "^4.4.5", + "@types/slick-carousel": "^1", "@types/styled-components": "^5.1.26", "@types/uuid": "^9.0.1", "babel-loader": "^8.3.0", diff --git a/packages/uikit/src/components/Skeleton.tsx b/packages/uikit/src/components/Skeleton.tsx index bcb49324c..c7cbc7288 100644 --- a/packages/uikit/src/components/Skeleton.tsx +++ b/packages/uikit/src/components/Skeleton.tsx @@ -69,11 +69,10 @@ const Block = styled(Base)<{ size?: string; width?: string }>` }} `; -export const SkeletonText: FC<{ size?: 'large' | 'small'; width?: string }> = React.memo( - ({ size, width }) => { - return ; - } -); +export const SkeletonText: FC<{ size?: 'large' | 'small'; width?: string; className?: string }> = + React.memo(({ size, width, className }) => { + return ; + }); const Image = styled(Base)<{ width?: string }>` border-radius: ${props => props.theme.cornerFull}; diff --git a/packages/uikit/src/components/shared/carousel/index.tsx b/packages/uikit/src/components/shared/carousel/index.tsx index 31e9fc4c0..826f3d070 100644 --- a/packages/uikit/src/components/shared/carousel/index.tsx +++ b/packages/uikit/src/components/shared/carousel/index.tsx @@ -1,24 +1,9 @@ -import { cloneElement, FC, PropsWithChildren, useLayoutEffect, useMemo, useRef } from 'react'; +import { FC, PropsWithChildren, useRef, WheelEvent } from 'react'; import styled from 'styled-components'; +import Slider from 'react-slick'; import { ChevronLeftIcon, ChevronRightIcon } from '../../Icon'; - -const Container = styled.div<{ gap: string }>` - position: relative; - display: flex; - gap: ${props => props.gap}; - overflow-x: auto; - - -ms-overflow-style: none; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - - > * { - flex-shrink: 0; - } -`; +import 'slick-carousel/slick/slick.css'; +import 'slick-carousel/slick/slick-theme.css'; const SwipeButton = styled.button<{ position: 'left' | 'right' }>` width: 40px; @@ -30,137 +15,87 @@ const SwipeButton = styled.button<{ position: 'left' | 'right' }>` display: flex; justify-content: center; align-items: center; - position: sticky; + position: absolute; + z-index: 2; border: none; cursor: pointer; top: calc(50% - 20px); ${props => (props.position === 'left' ? 'left: 12px;' : 'right: 12px;')}; + transition: opacity 0.2s ease-in-out; + + &:hover { + opacity: 0.8; + } +`; + +const SliderStyled = styled(Slider)<{ gap: string }>` + .slick-list { + margin: 0 -${props => parseFloat(props.gap) / 2}px; + } + .slick-slide > div { + margin: 0 ${props => parseFloat(props.gap) / 2}px; + } +`; + +const CarouselWrapper = styled.div` + overflow: hidden; + position: relative; `; export interface CarouselProps { gap: string; - itemWidth: number | string; } -export const Carousel: FC = ({ - children, - gap, - itemWidth: itemW -}) => { - const gapPx = parseFloat(gap); - const moveButtonWidthPx = 40; - const itemWidth = parseFloat(itemW.toString()); - const blockSize = itemWidth + gapPx; - - const containerRef = useRef(null); - - const childPrevPrevRef = useRef(null); - const childNextNextRef = useRef(null); - - const [childPrev, childPrevPrev, childNext, childNextNext, childrenLength] = useMemo(() => { - if (children && Array.isArray(children) && children.length > 2) { - const _childPrev = cloneElement(children[children.length - 1]); - const _childPrevPrev = cloneElement(children[children.length - 2], { - ref: childPrevPrevRef - }); - const _childNext = cloneElement(children[0]); - const _childNextNext = cloneElement(children[1], { ref: childNextNextRef }); - - return [_childPrev, _childPrevPrev, _childNext, _childNextNext, children.length]; - } else { - return [null, null, null, null, 0]; - } - }, [children]); - - const getScrollLeft = (blocksNumber: number) => { - const container = containerRef.current; - if (!container) { - return 0; - } - const shift = (container.offsetWidth - itemWidth) / 2 - gapPx; - return blockSize * blocksNumber - shift + moveButtonWidthPx; +export const Carousel: FC = ({ children, gap }) => { + const sliderRef = useRef(null); + const isSwiping = useRef(false); + const settings = { + infinite: true, + speed: 500, + slidesToShow: 1, + slidesToScroll: 1, + centerMode: true }; - useLayoutEffect(() => { - const container = containerRef.current; - const prevPrev = childPrevPrevRef.current; - const nextNext = childNextNextRef.current; - - if (container && prevPrev && nextNext && childrenLength > 2) { - container.scrollLeft = getScrollLeft(2); + const onWheel = (e: WheelEvent) => { + if (!isSwiping.current) { + isSwiping.current = true; - const options = { - root: container, - rootMargin: '0px', - threshold: 0 - }; + if (e.deltaX > 0) { + return sliderRef.current?.slickNext(); + } - const callback: IntersectionObserverCallback = entries => { - entries.forEach(entry => { - if (entry.isIntersecting) { - if (entry.target === prevPrev) { - requestAnimationFrame(() => - container.scrollTo({ - left: container.scrollLeft + childrenLength * itemWidth - 4 - }) - ); - } else { - requestAnimationFrame(() => - container.scrollTo({ - left: container.scrollLeft - childrenLength * itemWidth + 4 - }) - ); - } - } - }); - }; - - const observer = new IntersectionObserver(callback, options); - observer.observe(nextNext); - observer.observe(prevPrev); - - return () => { - observer.unobserve(nextNext); - observer.unobserve(prevPrev); - }; + if (e.deltaX < 0) { + return sliderRef.current?.slickPrev(); + } } - }, []); - - const move = (direction: 'left' | 'right') => { - const nextBlock = Math.ceil( - (containerRef.current!.scrollLeft + containerRef.current!.offsetWidth) / blockSize - ); - const prevBlock = Math.floor( - (containerRef.current!.scrollLeft + moveButtonWidthPx) / blockSize - ); - const blocksNumber = direction === 'left' ? prevBlock : nextBlock; - containerRef.current?.scrollTo({ - left: getScrollLeft(blocksNumber - 1), - behavior: 'smooth' - }); }; - const moveLeft = () => { - move('left'); + const blockSwipe = () => { + isSwiping.current = true; }; - const moveRight = () => { - move('right'); + const unblockSwipe = () => { + isSwiping.current = false; }; return ( - - + + sliderRef.current?.slickPrev()}> - {childPrevPrev} - {childPrev} - {children} - {childNext} - {childNextNext} - + + {children} + + sliderRef.current?.slickNext()}> - + ); }; diff --git a/packages/uikit/src/hooks/browser/useRecommendations.ts b/packages/uikit/src/hooks/browser/useRecommendations.ts new file mode 100644 index 000000000..ee0e325da --- /dev/null +++ b/packages/uikit/src/hooks/browser/useRecommendations.ts @@ -0,0 +1,41 @@ +import { useUserCountry } from '../../state/country'; +import { useQuery } from '@tanstack/react-query'; +import { QueryKey } from '../../libs/queryKey'; + +export interface CarouselApp extends PromotedApp { + poster: string; +} + +export interface PromotedApp { + name: string; + description: string; + icon: string; + url: string; + textColor?: string; +} + +export interface PromotionCategory { + id: string; + title: string; + apps: PromotedApp; +} + +export interface Recommendations { + categories: PromotionCategory[]; + apps: CarouselApp[]; +} + +export function useRecommendations() { + const country = useUserCountry(); + const lang = country.data || 'en'; + + return useQuery([QueryKey.featuredRecommendations, lang], async () => { + const result = await ( + await fetch(`https://api.tonkeeper.com/apps/popular?lang=${lang}`) + ).json(); + if (!result.success) { + throw new Error('Fetch recommendations api error: success false'); + } + return result.data; + }); +} diff --git a/packages/uikit/src/libs/queryKey.ts b/packages/uikit/src/libs/queryKey.ts index 941c99811..30cc883a3 100644 --- a/packages/uikit/src/libs/queryKey.ts +++ b/packages/uikit/src/libs/queryKey.ts @@ -19,6 +19,7 @@ export enum QueryKey { connection = 'connection', subscribed = 'subscribed', + featuredRecommendations = 'recommendations', tron = 'tron', rate = 'rate', diff --git a/packages/uikit/src/pages/browser/index.tsx b/packages/uikit/src/pages/browser/index.tsx index b237eb515..747547e54 100644 --- a/packages/uikit/src/pages/browser/index.tsx +++ b/packages/uikit/src/pages/browser/index.tsx @@ -1,25 +1,118 @@ import { FC } from 'react'; import { Carousel } from '../../components/shared'; import styled from 'styled-components'; +import { useRecommendations } from '../../hooks/browser/useRecommendations'; +import { Body3, H1, Label2 } from '../../components/Text'; +import { AppRoute, SettingsRoute } from '../../libs/routes'; +import { Link } from 'react-router-dom'; +import { useUserCountry } from '../../state/country'; +import { SkeletonText } from '../../components/Skeleton'; -const CarouselCard = styled.div` +const Heading = styled.div` + position: fixed; + top: 0; + width: var(--app-width); + max-width: 548px; + box-sizing: border-box; + padding: 12px 1rem; + display: flex; + align-items: center; +`; +const SkeletonCountry = styled(SkeletonText)` + position: absolute; + right: 16px; + top: 24px; +`; + +const CountryButton = styled.button` + position: absolute; + right: 16px; + top: 16px; + color: ${props => props.theme.buttonSecondaryForeground}; + background: ${props => props.theme.buttonSecondaryBackground}; + border-radius: ${props => props.theme.cornerSmall}; + border: none; + padding: 6px 12px; + cursor: pointer; + + &:hover { + background-color: ${props => props.theme.backgroundContentTint}; + } + + transition: background-color 0.1s ease; +`; + +const CarouselCard = styled.div<{ img: string }>` width: 448px; height: 224px; - background: aquamarine; + + background-image: ${props => `url(${props.img})`}; + background-size: cover; + border-radius: ${props => props.theme.cornerSmall}; + + display: inline-flex !important; + align-items: flex-end; + justify-content: flex-start; +`; + +const CardFooter = styled.div` + height: 76px; display: flex; align-items: center; - justify-content: center; - color: red; + padding-left: 16px; +`; + +const CardFooterImage = styled.img` + height: 44px; + width: 44px; + border-radius: ${props => props.theme.cornerExtraSmall}; +`; + +const CardFooterText = styled.div<{ color?: string }>` + display: flex; + flex-direction: column; + padding: 11px 12px 13px; + word-break: break-word; + color: ${props => props.color || props.theme.textPrimary}; +`; + +const Body3Styled = styled(Body3)` + opacity: 0.73; `; const BrowserPage: FC = () => { + const { data, isLoading, error } = useRecommendations(); + const { data: country, isLoading: isCountryLoading } = useUserCountry(); + return (
- - 1 - 2 - 3 - + +

Discover

+ {isCountryLoading ? ( + + ) : ( + + + {country || '🌎'} + + + )} +
+ {!!data && ( + + {data.apps.map(item => ( + + + + + {item.name} + {item.description} + + + + ))} + + )}
); }; diff --git a/yarn.lock b/yarn.lock index 69ee60997..627446253 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6213,7 +6213,9 @@ __metadata: "@types/react-beautiful-dnd": "npm:^13.1.3" "@types/react-dom": "npm:^18.0.9" "@types/react-lottie": "npm:^1.2.6" + "@types/react-slick": "npm:^0" "@types/react-transition-group": "npm:^4.4.5" + "@types/slick-carousel": "npm:^1" "@types/styled-components": "npm:^5.1.26" "@types/uuid": "npm:^9.0.1" babel-loader: "npm:^8.3.0" @@ -6228,8 +6230,10 @@ __metadata: react-lottie: "npm:^1.2.4" react-qrcode-logo: "npm:^2.9.0" react-router-dom: "npm:^6.4.5" + react-slick: "npm:^0.29.0" react-transition-group: "npm:^4.4.5" require-from-string: "npm:^2.0.2" + slick-carousel: "npm:^1.8.1" storybook-addon-turbo-build: "npm:^1.1.0" styled-components: "npm:^6.1.1" ton: "npm:^13.4.1" @@ -6766,6 +6770,15 @@ __metadata: languageName: node linkType: hard +"@types/jquery@npm:*": + version: 3.5.29 + resolution: "@types/jquery@npm:3.5.29" + dependencies: + "@types/sizzle": "npm:*" + checksum: 364facf2cb0bc935cb4b7f4af0b12c8e901b02b08a85af213172485166123b10aad8563f8e51e363f65a778c4105a59a64dedd0b0fd73f96557219d46477af56 + languageName: node + linkType: hard + "@types/json-schema@npm:*, @types/json-schema@npm:^7.0.4, @types/json-schema@npm:^7.0.5, @types/json-schema@npm:^7.0.8, @types/json-schema@npm:^7.0.9": version: 7.0.15 resolution: "@types/json-schema@npm:7.0.15" @@ -7015,6 +7028,15 @@ __metadata: languageName: node linkType: hard +"@types/react-slick@npm:^0": + version: 0.23.13 + resolution: "@types/react-slick@npm:0.23.13" + dependencies: + "@types/react": "npm:*" + checksum: 3e363220dd7efb7eb9d60aec80588365ddffb366cc75307b86ce83d84e6664d9a66edc9d796cc0b4f87bfd217fa912d4005a00c2a87c41b36fbe4412ae7a15a7 + languageName: node + linkType: hard + "@types/react-transition-group@npm:^4.4.5": version: 4.4.10 resolution: "@types/react-transition-group@npm:4.4.10" @@ -7104,6 +7126,22 @@ __metadata: languageName: node linkType: hard +"@types/sizzle@npm:*": + version: 2.3.8 + resolution: "@types/sizzle@npm:2.3.8" + checksum: 2ac62443dc917f5f903cbd9afc51c7d6cc1c6569b4e1a15faf04aea5b13b486e7f208650014c3dc4fed34653eded3e00fe5abffe0e6300cbf0e8a01beebf11a6 + languageName: node + linkType: hard + +"@types/slick-carousel@npm:^1": + version: 1.6.40 + resolution: "@types/slick-carousel@npm:1.6.40" + dependencies: + "@types/jquery": "npm:*" + checksum: 59b3127722c3ca2d1f504163b0384cfc853434edcbf4b06614d1b7564e921f4c824bfd4f71669f57b29780c6a2df1cb3e4806385275b78d917a52105d48576e5 + languageName: node + linkType: hard + "@types/sockjs@npm:^0.3.33": version: 0.3.36 resolution: "@types/sockjs@npm:0.3.36" @@ -10016,6 +10054,13 @@ __metadata: languageName: node linkType: hard +"classnames@npm:^2.2.5": + version: 2.5.1 + resolution: "classnames@npm:2.5.1" + checksum: 58eb394e8817021b153bb6e7d782cfb667e4ab390cb2e9dac2fc7c6b979d1cc2b2a733093955fc5c94aa79ef5c8c89f11ab77780894509be6afbb91dddd79d15 + languageName: node + linkType: hard + "clean-css@npm:^4.2.3": version: 4.2.4 resolution: "clean-css@npm:4.2.4" @@ -12089,6 +12134,13 @@ __metadata: languageName: node linkType: hard +"enquire.js@npm:^2.1.6": + version: 2.1.6 + resolution: "enquire.js@npm:2.1.6" + checksum: 246b4ec2cc7a4eb8e24e9ae836b3222b889d8d982ac1583f90f9641222610a688c8a3fab53e2dc6ee56457c2798ba487814f61f5553d30ae23cc74664e6f78f8 + languageName: node + linkType: hard + "entities@npm:^2.0.0": version: 2.2.0 resolution: "entities@npm:2.2.0" @@ -17262,6 +17314,15 @@ __metadata: languageName: node linkType: hard +"json2mq@npm:^0.2.0": + version: 0.2.0 + resolution: "json2mq@npm:0.2.0" + dependencies: + string-convert: "npm:^0.2.0" + checksum: 0ad2f6a268308beeaf3077652b5ae2b0701ef357840e1542cc838198424a79af21dad759595e2cce8cd9b154e0b0f758c217adea4b3dfbaafff3ff9bf82394a1 + languageName: node + linkType: hard + "json5@npm:^1.0.1, json5@npm:^1.0.2": version: 1.0.2 resolution: "json5@npm:1.0.2" @@ -21871,6 +21932,22 @@ __metadata: languageName: node linkType: hard +"react-slick@npm:^0.29.0": + version: 0.29.0 + resolution: "react-slick@npm:0.29.0" + dependencies: + classnames: "npm:^2.2.5" + enquire.js: "npm:^2.1.6" + json2mq: "npm:^0.2.0" + lodash.debounce: "npm:^4.0.8" + resize-observer-polyfill: "npm:^1.5.0" + peerDependencies: + react: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 + react-dom: ^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 + checksum: b50a5a005c1bd62350c29c516be62a8c2a5e9ea1b5940b1bd5748e36b9f8378016990fdd7a38c9346bb8b51a59faf7522a0b365dc9ed4d0fc4d67c9356513e5f + languageName: node + linkType: hard + "react-transition-group@npm:^4.4.5": version: 4.4.5 resolution: "react-transition-group@npm:4.4.5" @@ -22368,6 +22445,13 @@ __metadata: languageName: node linkType: hard +"resize-observer-polyfill@npm:^1.5.0": + version: 1.5.1 + resolution: "resize-observer-polyfill@npm:1.5.1" + checksum: e10ee50cd6cf558001de5c6fb03fee15debd011c2f694564b71f81742eef03fb30d6c2596d1d5bf946d9991cb692fcef529b7bd2e4057041377ecc9636c753ce + languageName: node + linkType: hard + "resolve-alpn@npm:^1.0.0": version: 1.2.1 resolution: "resolve-alpn@npm:1.2.1" @@ -23355,6 +23439,15 @@ __metadata: languageName: node linkType: hard +"slick-carousel@npm:^1.8.1": + version: 1.8.1 + resolution: "slick-carousel@npm:1.8.1" + peerDependencies: + jquery: ">=1.8.0" + checksum: b383e6f96b3bec573522467cfeeac1fce22d1cd53a68f5699cd3d62f0167777116b925dc48fbe64979a7a830777e4440036b8a7d4575c9ff8d18c3dd141b1ea2 + languageName: node + linkType: hard + "smart-buffer@npm:^4.2.0": version: 4.2.0 resolution: "smart-buffer@npm:4.2.0" @@ -23879,6 +23972,13 @@ __metadata: languageName: node linkType: hard +"string-convert@npm:^0.2.0": + version: 0.2.1 + resolution: "string-convert@npm:0.2.1" + checksum: f3eb484a45d29aa2ba2d9fe0471c971d5a56353633b56a4c8bc3e67237a2cdb1b6437f006a67d489b3d41e0a1c1f02e18d334c161a27fd7219e4aee1a9f68aac + languageName: node + linkType: hard + "string-length@npm:^4.0.1": version: 4.0.2 resolution: "string-length@npm:4.0.2" From 662ef8b373346dc19aedaaa1554cb32d856ade82 Mon Sep 17 00:00:00 2001 From: siandreev Date: Thu, 18 Jan 2024 18:30:20 +0100 Subject: [PATCH 05/16] fix: typings --- .../src/components/shared/carousel/index.tsx | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/packages/uikit/src/components/shared/carousel/index.tsx b/packages/uikit/src/components/shared/carousel/index.tsx index 826f3d070..de27915fd 100644 --- a/packages/uikit/src/components/shared/carousel/index.tsx +++ b/packages/uikit/src/components/shared/carousel/index.tsx @@ -28,7 +28,10 @@ const SwipeButton = styled.button<{ position: 'left' | 'right' }>` } `; -const SliderStyled = styled(Slider)<{ gap: string }>` +const CarouselWrapper = styled.div<{ gap: string }>` + overflow: hidden; + position: relative; + .slick-list { margin: 0 -${props => parseFloat(props.gap) / 2}px; } @@ -37,11 +40,6 @@ const SliderStyled = styled(Slider)<{ gap: string }>` } `; -const CarouselWrapper = styled.div` - overflow: hidden; - position: relative; -`; - export interface CarouselProps { gap: string; } @@ -80,19 +78,18 @@ export const Carousel: FC = ({ children, gap }; return ( - + sliderRef.current?.slickPrev()}> - {children} - +
sliderRef.current?.slickNext()}> From 2ec512b1245f9a75afc05b3bc9f39e1e15c61510 Mon Sep 17 00:00:00 2001 From: siandreev Date: Thu, 18 Jan 2024 18:44:55 +0100 Subject: [PATCH 06/16] fix: banner links added --- packages/uikit/src/pages/browser/index.tsx | 27 ++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/uikit/src/pages/browser/index.tsx b/packages/uikit/src/pages/browser/index.tsx index 747547e54..d5cea59ab 100644 --- a/packages/uikit/src/pages/browser/index.tsx +++ b/packages/uikit/src/pages/browser/index.tsx @@ -1,4 +1,4 @@ -import { FC } from 'react'; +import { FC, useRef } from 'react'; import { Carousel } from '../../components/shared'; import styled from 'styled-components'; import { useRecommendations } from '../../hooks/browser/useRecommendations'; @@ -7,6 +7,7 @@ import { AppRoute, SettingsRoute } from '../../libs/routes'; import { Link } from 'react-router-dom'; import { useUserCountry } from '../../state/country'; import { SkeletonText } from '../../components/Skeleton'; +import { useAppSdk } from '../../hooks/appSdk'; const Heading = styled.div` position: fixed; @@ -53,6 +54,7 @@ const CarouselCard = styled.div<{ img: string }>` display: inline-flex !important; align-items: flex-end; justify-content: flex-start; + cursor: pointer; `; const CardFooter = styled.div` @@ -83,6 +85,9 @@ const Body3Styled = styled(Body3)` const BrowserPage: FC = () => { const { data, isLoading, error } = useRecommendations(); const { data: country, isLoading: isCountryLoading } = useUserCountry(); + const sdk = useAppSdk(); + + const clickedPosition = useRef<{ clientX: number; clientY: number }>({}); return (
@@ -101,7 +106,25 @@ const BrowserPage: FC = () => { {!!data && ( {data.apps.map(item => ( - + + (clickedPosition.current = { + clientY: e.clientY, + clientX: e.clientX + }) + } + onMouseUp={e => { + const xInArea = + Math.abs(e.clientX - clickedPosition.current.clientX) < 10; + const yInArea = + Math.abs(e.clientY - clickedPosition.current.clientY) < 10; + if (xInArea && yInArea) { + sdk.openPage(item.url); + } + }} + > From 155fcff7c5ca29a095b29bbf6b8afe479134d7fd Mon Sep 17 00:00:00 2001 From: siandreev Date: Fri, 19 Jan 2024 17:43:31 +0100 Subject: [PATCH 07/16] feat: apps list carousel added --- packages/uikit/src/components/Body.tsx | 127 +++++++++--------- packages/uikit/src/components/Footer.tsx | 1 + .../src/components/shared/carousel/index.tsx | 63 ++++++--- .../src/hooks/browser/useRecommendations.ts | 2 +- packages/uikit/src/hooks/useElementSize.ts | 33 +++++ packages/uikit/src/hooks/useEventListener.ts | 68 ++++++++++ .../src/pages/browser/category-block.tsx | 124 +++++++++++++++++ packages/uikit/src/pages/browser/index.tsx | 92 +++---------- .../uikit/src/pages/browser/promoted-item.tsx | 27 ++++ .../src/pages/browser/promotions-carousel.tsx | 65 +++++++++ 10 files changed, 445 insertions(+), 157 deletions(-) create mode 100644 packages/uikit/src/hooks/useElementSize.ts create mode 100644 packages/uikit/src/hooks/useEventListener.ts create mode 100644 packages/uikit/src/pages/browser/category-block.tsx create mode 100644 packages/uikit/src/pages/browser/promoted-item.tsx create mode 100644 packages/uikit/src/pages/browser/promotions-carousel.tsx diff --git a/packages/uikit/src/components/Body.tsx b/packages/uikit/src/components/Body.tsx index 82a219fca..2e0caf8a8 100644 --- a/packages/uikit/src/components/Body.tsx +++ b/packages/uikit/src/components/Body.tsx @@ -126,70 +126,71 @@ export const useAppSelection = (elementRef: React.MutableRefObject( - ({ children }, ref) => { - const sdk = useAppSdk(); - const { standalone } = useAppContext(); - - const innerRef = useRef(null); - - const elementRef = ( - ref == null ? innerRef : ref - ) as React.MutableRefObject; - - useLayoutEffect(() => { - const element = elementRef.current; - if (!element) return; - if (!standalone) return; - - let timer: NodeJS.Timeout | undefined; - - const handlerScroll = throttle(() => { - if (element.scrollTop < 10) { - setTop(); - } else { - removeTop(); - } - if (element.scrollTop + element.clientHeight < element.scrollHeight - 10) { - removeBottom(); - } else { - setBottom(); - } - clearTimeout(timer); - if (!document.body.classList.contains('scroll')) { - document.body.classList.add('scroll'); - } - timer = setTimeout(function () { - document.body.classList.remove('scroll'); - }, 300); - }, 50); - - element.addEventListener('scroll', handlerScroll); - sdk.uiEvents.on('loading', handlerScroll); - - handlerScroll(); - - return () => { +export const InnerBody = React.forwardRef< + HTMLDivElement, + PropsWithChildren & { className?: string } +>(({ children, className }, ref) => { + const sdk = useAppSdk(); + const { standalone } = useAppContext(); + + const innerRef = useRef(null); + + const elementRef = ( + ref == null ? innerRef : ref + ) as React.MutableRefObject; + + useLayoutEffect(() => { + const element = elementRef.current; + if (!element) return; + if (!standalone) return; + + let timer: NodeJS.Timeout | undefined; + + const handlerScroll = throttle(() => { + if (element.scrollTop < 10) { setTop(); + } else { + removeTop(); + } + if (element.scrollTop + element.clientHeight < element.scrollHeight - 10) { + removeBottom(); + } else { setBottom(); - clearTimeout(timer); - sdk.uiEvents.off('loading', handlerScroll); - - element.removeEventListener('scroll', handlerScroll); - }; - }, [elementRef]); - - const selection = useAppSelection(elementRef); - const id = standalone ? 'body' : undefined; - - return ( - - - {children} - - - ); - } -); + } + clearTimeout(timer); + if (!document.body.classList.contains('scroll')) { + document.body.classList.add('scroll'); + } + timer = setTimeout(function () { + document.body.classList.remove('scroll'); + }, 300); + }, 50); + + element.addEventListener('scroll', handlerScroll); + sdk.uiEvents.on('loading', handlerScroll); + + handlerScroll(); + + return () => { + setTop(); + setBottom(); + clearTimeout(timer); + sdk.uiEvents.off('loading', handlerScroll); + + element.removeEventListener('scroll', handlerScroll); + }; + }, [elementRef]); + + const selection = useAppSelection(elementRef); + const id = standalone ? 'body' : undefined; + + return ( + + + {children} + + + ); +}); InnerBody.displayName = 'InnerBody'; diff --git a/packages/uikit/src/components/Footer.tsx b/packages/uikit/src/components/Footer.tsx index a65656096..3fdebb3f9 100644 --- a/packages/uikit/src/components/Footer.tsx +++ b/packages/uikit/src/components/Footer.tsx @@ -135,6 +135,7 @@ const Block = styled.div<{ standalone?: boolean; sticky?: boolean }>` max-width: 548px; box-sizing: border-box; overflow: visible !important; + z-index: 3; background-color: ${props => props.theme.backgroundPage}; diff --git a/packages/uikit/src/components/shared/carousel/index.tsx b/packages/uikit/src/components/shared/carousel/index.tsx index de27915fd..ecd8d3cd4 100644 --- a/packages/uikit/src/components/shared/carousel/index.tsx +++ b/packages/uikit/src/components/shared/carousel/index.tsx @@ -1,6 +1,6 @@ -import { FC, PropsWithChildren, useRef, WheelEvent } from 'react'; +import { FC, PropsWithChildren, useRef, useState, WheelEvent } from 'react'; import styled from 'styled-components'; -import Slider from 'react-slick'; +import Slider, { Settings } from 'react-slick'; import { ChevronLeftIcon, ChevronRightIcon } from '../../Icon'; import 'slick-carousel/slick/slick.css'; import 'slick-carousel/slick/slick-theme.css'; @@ -28,12 +28,13 @@ const SwipeButton = styled.button<{ position: 'left' | 'right' }>` } `; -const CarouselWrapper = styled.div<{ gap: string }>` +const CarouselWrapper = styled.div<{ gap: string; isFirst?: boolean }>` overflow: hidden; position: relative; .slick-list { margin: 0 -${props => parseFloat(props.gap) / 2}px; + ${props => props.isFirst && 'padding-left: 16px !important;'} } .slick-slide > div { margin: 0 ${props => parseFloat(props.gap) / 2}px; @@ -42,18 +43,29 @@ const CarouselWrapper = styled.div<{ gap: string }>` export interface CarouselProps { gap: string; + className?: string; } -export const Carousel: FC = ({ children, gap }) => { +const defaultSettings = { + speed: 500, + slidesToShow: 1, + slidesToScroll: 1, + centerMode: true, + arrows: false +}; + +export const Carousel: FC = ({ + children, + gap, + className, + ...settings +}) => { + const isInfinite = settings.infinite !== false; const sliderRef = useRef(null); const isSwiping = useRef(false); - const settings = { - infinite: true, - speed: 500, - slidesToShow: 1, - slidesToScroll: 1, - centerMode: true - }; + + const [hideRightButton, setHideRightButton] = useState(false); + const [hideLeftButton, setHideLeftButton] = useState(!isInfinite); const onWheel = (e: WheelEvent) => { if (!isSwiping.current) { @@ -77,22 +89,35 @@ export const Carousel: FC = ({ children, gap isSwiping.current = false; }; + const beforeChange = (_: number, nextIndex: number) => { + blockSwipe(); + + const childrenLength = children && Array.isArray(children) ? children.length : 0; + setHideRightButton(nextIndex === childrenLength - 1 && !isInfinite); + setHideLeftButton(nextIndex === 0 && !isInfinite); + }; + return ( - - sliderRef.current?.slickPrev()}> - - + + {!hideLeftButton && ( + sliderRef.current?.slickPrev()}> + + + )} {children} - sliderRef.current?.slickNext()}> - - + {!hideRightButton && ( + sliderRef.current?.slickNext()}> + + + )} ); }; diff --git a/packages/uikit/src/hooks/browser/useRecommendations.ts b/packages/uikit/src/hooks/browser/useRecommendations.ts index ee0e325da..0db07cb58 100644 --- a/packages/uikit/src/hooks/browser/useRecommendations.ts +++ b/packages/uikit/src/hooks/browser/useRecommendations.ts @@ -17,7 +17,7 @@ export interface PromotedApp { export interface PromotionCategory { id: string; title: string; - apps: PromotedApp; + apps: PromotedApp[]; } export interface Recommendations { diff --git a/packages/uikit/src/hooks/useElementSize.ts b/packages/uikit/src/hooks/useElementSize.ts new file mode 100644 index 000000000..9639f1436 --- /dev/null +++ b/packages/uikit/src/hooks/useElementSize.ts @@ -0,0 +1,33 @@ +import { useCallback, useLayoutEffect, useState } from 'react'; +import { useEventListener } from './useEventListener'; + +interface Size { + width: number; + height: number; +} + +export function useElementSize(): [ + (node: T | null) => void, + Size +] { + const [ref, setRef] = useState(null); + const [size, setSize] = useState({ + width: 0, + height: 0 + }); + + const handleSize = useCallback(() => { + setSize({ + width: ref?.offsetWidth || 0, + height: ref?.offsetHeight || 0 + }); + }, [ref?.offsetHeight, ref?.offsetWidth]); + + useEventListener('resize', handleSize); + + useLayoutEffect(() => { + handleSize(); + }, [ref?.offsetHeight, ref?.offsetWidth]); + + return [setRef, size]; +} diff --git a/packages/uikit/src/hooks/useEventListener.ts b/packages/uikit/src/hooks/useEventListener.ts new file mode 100644 index 000000000..e3d42d02a --- /dev/null +++ b/packages/uikit/src/hooks/useEventListener.ts @@ -0,0 +1,68 @@ +import { RefObject, useEffect, useLayoutEffect, useRef } from 'react'; + +function useEventListener( + eventName: K, + handler: (event: MediaQueryListEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +function useEventListener( + eventName: K, + handler: (event: WindowEventMap[K]) => void, + element?: undefined, + options?: boolean | AddEventListenerOptions +): void; + +function useEventListener< + K extends keyof HTMLElementEventMap, + T extends HTMLElement = HTMLDivElement +>( + eventName: K, + handler: (event: HTMLElementEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +function useEventListener( + eventName: K, + handler: (event: DocumentEventMap[K]) => void, + element: RefObject, + options?: boolean | AddEventListenerOptions +): void; + +function useEventListener< + KW extends keyof WindowEventMap, + KH extends keyof HTMLElementEventMap, + KM extends keyof MediaQueryListEventMap, + T extends HTMLElement | MediaQueryList | void = void +>( + eventName: KW | KH | KM, + handler: ( + event: WindowEventMap[KW] | HTMLElementEventMap[KH] | MediaQueryListEventMap[KM] | Event + ) => void, + element?: RefObject, + options?: boolean | AddEventListenerOptions +) { + const savedHandler = useRef(handler); + + useLayoutEffect(() => { + savedHandler.current = handler; + }, [handler]); + + useEffect(() => { + const targetElement: T | Window = element?.current ?? window; + + if (!(targetElement && targetElement.addEventListener)) return; + + const listener: typeof handler = event => savedHandler.current(event); + + targetElement.addEventListener(eventName, listener, options); + + return () => { + targetElement.removeEventListener(eventName, listener, options); + }; + }, [eventName, element, options]); +} + +export { useEventListener }; diff --git a/packages/uikit/src/pages/browser/category-block.tsx b/packages/uikit/src/pages/browser/category-block.tsx new file mode 100644 index 000000000..26019f496 --- /dev/null +++ b/packages/uikit/src/pages/browser/category-block.tsx @@ -0,0 +1,124 @@ +import { FC, useMemo } from 'react'; +import styled from 'styled-components'; +import { Body3, H3, Label1, Label2 } from '../../components/Text'; +import { PromotedApp, PromotionCategory } from '../../hooks/browser/useRecommendations'; +import { ListBlock, ListItem } from '../../components/List'; +import { Carousel } from '../../components/shared'; +import { PromotedItem, PromotedItemImage, PromotedItemText } from './promoted-item'; +import { useElementSize } from '../../hooks/useElementSize'; + +const Container = styled.div``; + +const Heading = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 1rem; + gap: 1rem; +`; + +const AllButton = styled.button` + border: none; + background: transparent; + height: fit-content; + width: fit-content; + color: ${props => props.theme.textAccent}; + cursor: pointer; + padding: 4px 8px; +`; + +const ListContainer = styled.div` + padding-left: 1rem; + padding-right: 1rem; +`; + +const ListBlockStyled = styled(ListBlock)<{ width: string; marginLeft?: string }>` + width: ${props => props.width} !important; + margin-left: ${props => props.marginLeft} !important; + margin-bottom: 0; +`; + +const ListItemStyled = styled(ListItem)` + padding-left: 16px; +`; + +export const CategoryBlock: FC<{ category: PromotionCategory; className?: string }> = ({ + category, + className +}) => { + const [containerRef, { width: w }] = useElementSize(); + const width = w - 36; + const groups = useMemo( + () => + category.apps.reduce((acc, app, index) => { + if (index % 3 === 0) { + acc.push([app]); + } else { + acc[acc.length - 1].push(app); + } + return acc; + }, [] as PromotedApp[][]), + [category.apps] + ); + + const groupsKeys = useMemo(() => groups.map(group => group.map(i => i.url).join('')), [groups]); + const canExpand = groups.length > 1; + + return ( + + +

{category.title}

+ {canExpand && ( + + All + + )} +
+ {canExpand ? ( + + {groups.map((group, groupIndex) => ( + + {group.map(item => ( + + + + + {item.name} + {item.description} + + + + ))} + + ))} + + ) : ( + groups.map((group, groupIndex) => ( + + + {group.map(item => ( + + + + + {item.name} + {item.description} + + + + ))} + + + )) + )} +
+ ); +}; diff --git a/packages/uikit/src/pages/browser/index.tsx b/packages/uikit/src/pages/browser/index.tsx index d5cea59ab..a5285214b 100644 --- a/packages/uikit/src/pages/browser/index.tsx +++ b/packages/uikit/src/pages/browser/index.tsx @@ -1,13 +1,18 @@ -import { FC, useRef } from 'react'; -import { Carousel } from '../../components/shared'; +import { FC } from 'react'; import styled from 'styled-components'; import { useRecommendations } from '../../hooks/browser/useRecommendations'; -import { Body3, H1, Label2 } from '../../components/Text'; +import { H1, Label2 } from '../../components/Text'; import { AppRoute, SettingsRoute } from '../../libs/routes'; import { Link } from 'react-router-dom'; import { useUserCountry } from '../../state/country'; import { SkeletonText } from '../../components/Skeleton'; -import { useAppSdk } from '../../hooks/appSdk'; +import { PromotionsCarousel } from './promotions-carousel'; +import { CategoryBlock } from './category-block'; +import { InnerBody } from '../../components/Body'; + +const InnerBodyStyled = styled(InnerBody)` + padding: 0; +`; const Heading = styled.div` position: fixed; @@ -18,6 +23,8 @@ const Heading = styled.div` padding: 12px 1rem; display: flex; align-items: center; + background-color: ${props => props.theme.backgroundPage}; + z-index: 3; `; const SkeletonCountry = styled(SkeletonText)` position: absolute; @@ -43,51 +50,13 @@ const CountryButton = styled.button` transition: background-color 0.1s ease; `; -const CarouselCard = styled.div<{ img: string }>` - width: 448px; - height: 224px; - - background-image: ${props => `url(${props.img})`}; - background-size: cover; - border-radius: ${props => props.theme.cornerSmall}; - - display: inline-flex !important; - align-items: flex-end; - justify-content: flex-start; - cursor: pointer; -`; - -const CardFooter = styled.div` - height: 76px; - display: flex; - align-items: center; - padding-left: 16px; -`; - -const CardFooterImage = styled.img` - height: 44px; - width: 44px; - border-radius: ${props => props.theme.cornerExtraSmall}; -`; - -const CardFooterText = styled.div<{ color?: string }>` - display: flex; - flex-direction: column; - padding: 11px 12px 13px; - word-break: break-word; - color: ${props => props.color || props.theme.textPrimary}; -`; - -const Body3Styled = styled(Body3)` - opacity: 0.73; +const PromotionsCarouselStyled = styled(PromotionsCarousel)` + margin-bottom: 16px; `; const BrowserPage: FC = () => { const { data, isLoading, error } = useRecommendations(); const { data: country, isLoading: isCountryLoading } = useUserCountry(); - const sdk = useAppSdk(); - - const clickedPosition = useRef<{ clientX: number; clientY: number }>({}); return (
@@ -104,37 +73,12 @@ const BrowserPage: FC = () => { )} {!!data && ( - - {data.apps.map(item => ( - - (clickedPosition.current = { - clientY: e.clientY, - clientX: e.clientX - }) - } - onMouseUp={e => { - const xInArea = - Math.abs(e.clientX - clickedPosition.current.clientX) < 10; - const yInArea = - Math.abs(e.clientY - clickedPosition.current.clientY) < 10; - if (xInArea && yInArea) { - sdk.openPage(item.url); - } - }} - > - - - - {item.name} - {item.description} - - - + + + {data.categories.map(category => ( + ))} - + )}
); diff --git a/packages/uikit/src/pages/browser/promoted-item.tsx b/packages/uikit/src/pages/browser/promoted-item.tsx new file mode 100644 index 000000000..9ca41a988 --- /dev/null +++ b/packages/uikit/src/pages/browser/promoted-item.tsx @@ -0,0 +1,27 @@ +import styled from 'styled-components'; + +export const PromotedItem = styled.div` + padding-top: 8px; + padding-bottom: 8px; + display: flex; + align-items: center; + width: 100%; +`; + +export const PromotedItemImage = styled.img` + height: 44px; + width: 44px; + border-radius: ${props => props.theme.cornerExtraSmall}; +`; + +export const PromotedItemText = styled.div<{ color?: string }>` + display: flex; + flex-direction: column; + padding: 11px 12px 13px; + word-break: break-word; + color: ${props => props.color || props.theme.textPrimary}; + + & > span:nth-child(2) { + opacity: 0.78; + } +`; diff --git a/packages/uikit/src/pages/browser/promotions-carousel.tsx b/packages/uikit/src/pages/browser/promotions-carousel.tsx new file mode 100644 index 000000000..888e9f9dd --- /dev/null +++ b/packages/uikit/src/pages/browser/promotions-carousel.tsx @@ -0,0 +1,65 @@ +import { Body3, Label2 } from '../../components/Text'; +import { FC, useRef } from 'react'; +import { Carousel } from '../../components/shared'; +import styled from 'styled-components'; +import { CarouselApp } from '../../hooks/browser/useRecommendations'; +import { useAppSdk } from '../../hooks/appSdk'; +import { PromotedItem, PromotedItemImage, PromotedItemText } from './promoted-item'; + +const CarouselCard = styled.div<{ img: string }>` + width: 448px; + height: 224px; + + background-image: ${props => `url(${props.img})`}; + background-size: cover; + border-radius: ${props => props.theme.cornerSmall}; + + display: inline-flex !important; + align-items: flex-end; + justify-content: flex-start; + cursor: pointer; +`; +const CarouselCardFooter = styled(PromotedItem)` + margin-left: 1rem; +`; + +export const PromotionsCarousel: FC<{ apps: CarouselApp[] }> = ({ apps }) => { + const sdk = useAppSdk(); + + const clickedPosition = useRef<{ clientX: number; clientY: number }>({ + clientX: 0, + clientY: 0 + }); + + return ( + + {apps.map(item => ( + + (clickedPosition.current = { + clientY: e.clientY, + clientX: e.clientX + }) + } + onMouseUp={e => { + const xInArea = Math.abs(e.clientX - clickedPosition.current.clientX) < 10; + const yInArea = Math.abs(e.clientY - clickedPosition.current.clientY) < 10; + if (xInArea && yInArea) { + sdk.openPage(item.url); + } + }} + > + + + + {item.name} + {item.description} + + + + ))} + + ); +}; From 92fa1582e17d9e7da39cd3043470630f5fc7d67b Mon Sep 17 00:00:00 2001 From: siandreev Date: Fri, 19 Jan 2024 18:36:06 +0100 Subject: [PATCH 08/16] fix: added open url on click --- packages/uikit/src/components/List.tsx | 19 ++++-- packages/uikit/src/hooks/useAreaClick.ts | 55 +++++++++++++++++ packages/uikit/src/libs/common.ts | 22 +++++++ .../src/pages/browser/category-block.tsx | 60 ++++++++++++------- packages/uikit/src/pages/browser/index.tsx | 8 ++- .../uikit/src/pages/browser/promoted-item.tsx | 4 +- .../src/pages/browser/promotions-carousel.tsx | 60 ++++++++----------- 7 files changed, 160 insertions(+), 68 deletions(-) create mode 100644 packages/uikit/src/hooks/useAreaClick.ts diff --git a/packages/uikit/src/components/List.tsx b/packages/uikit/src/components/List.tsx index 3919f7b7c..63e9d6109 100644 --- a/packages/uikit/src/components/List.tsx +++ b/packages/uikit/src/components/List.tsx @@ -1,6 +1,14 @@ -import React, { FC, PropsWithChildren, useContext, useLayoutEffect, useRef, useState } from 'react'; +import React, { + forwardRef, + PropsWithChildren, + useContext, + useLayoutEffect, + useRef, + useState +} from 'react'; import styled, { createGlobalStyle, css } from 'styled-components'; import { AppSelectionContext, useAppContext } from '../hooks/appContext'; +import { mergeRefs } from '../libs/common'; export const ListBlock = styled.div<{ margin?: boolean; @@ -129,14 +137,15 @@ export const GlobalListStyle = createGlobalStyle` } `; -export const ListItem: FC< +export const ListItem = forwardRef< + HTMLDivElement, PropsWithChildren< { hover?: boolean; dropDown?: boolean } & Omit< React.HTMLProps, 'size' | 'children' | 'as' | 'ref' > > -> = ({ children, hover, dropDown, ...props }) => { +>(({ children, hover, dropDown, ...props }, externalRef) => { const selection = useContext(AppSelectionContext); const { ios } = useAppContext(); const [isHover, setHover] = useState(false); @@ -154,7 +163,7 @@ export const ListItem: FC< ); -}; +}); diff --git a/packages/uikit/src/hooks/useAreaClick.ts b/packages/uikit/src/hooks/useAreaClick.ts new file mode 100644 index 000000000..182d421b7 --- /dev/null +++ b/packages/uikit/src/hooks/useAreaClick.ts @@ -0,0 +1,55 @@ +import { useCallback, useRef } from 'react'; +import { useEventListener } from './useEventListener'; +import { useAppSdk } from './appSdk'; + +export function useAreaClick({ + callback, + precisionXPx, + precisionYPx +}: { + callback: () => void; + precisionXPx?: number; + precisionYPx?: number; +}) { + const clickedPosition = useRef<{ clientX: number; clientY: number }>({ + clientX: 0, + clientY: 0 + }); + + const ref = useRef(null); + + const onMouseDown = useCallback((e: MouseEvent) => { + clickedPosition.current = { + clientY: e.clientY, + clientX: e.clientX + }; + }, []); + + const onMouseUp = useCallback( + (e: MouseEvent) => { + const xInArea = + Math.abs(e.clientX - clickedPosition.current.clientX) < (precisionXPx ?? 10); + const yInArea = + Math.abs(e.clientY - clickedPosition.current.clientY) < (precisionYPx ?? 10); + if (xInArea && yInArea) { + callback(); + } + }, + [callback, precisionXPx, precisionYPx] + ); + + useEventListener<'mousedown', HTMLElement>('mousedown', onMouseDown, ref!); + useEventListener('mouseup', onMouseUp, ref!); + + return ref; +} + +export function useOpenLinkOnAreaClick(url: string) { + const sdk = useAppSdk(); + + const callback = useCallback(() => { + sdk.openPage(url); + }, [url, sdk]); + + return useAreaClick({ callback }); +} diff --git a/packages/uikit/src/libs/common.ts b/packages/uikit/src/libs/common.ts index 1135a9966..e3340af96 100644 --- a/packages/uikit/src/libs/common.ts +++ b/packages/uikit/src/libs/common.ts @@ -1,3 +1,5 @@ +import { MutableRefObject, Ref, RefCallback } from 'react'; + export const scrollToTop = () => { if (!document.body.classList.contains('top')) { const body = document.getElementById('body'); @@ -12,3 +14,23 @@ export const scrollToTop = () => { } } }; + +export function mergeRefs(...inputRefs: (Ref | undefined)[]): Ref | RefCallback { + const filteredInputRefs = inputRefs.filter(Boolean); + + if (filteredInputRefs.length <= 1) { + const firstRef = filteredInputRefs[0]; + + return firstRef || null; + } + + return function mergedRefs(ref) { + filteredInputRefs.forEach(inputRef => { + if (typeof inputRef === 'function') { + inputRef(ref); + } else if (inputRef) { + (inputRef as MutableRefObject).current = ref; + } + }); + }; +} diff --git a/packages/uikit/src/pages/browser/category-block.tsx b/packages/uikit/src/pages/browser/category-block.tsx index 26019f496..3086a515d 100644 --- a/packages/uikit/src/pages/browser/category-block.tsx +++ b/packages/uikit/src/pages/browser/category-block.tsx @@ -6,8 +6,8 @@ import { ListBlock, ListItem } from '../../components/List'; import { Carousel } from '../../components/shared'; import { PromotedItem, PromotedItemImage, PromotedItemText } from './promoted-item'; import { useElementSize } from '../../hooks/useElementSize'; - -const Container = styled.div``; +import { ChevronRightIcon } from '../../components/Icon'; +import { useOpenLinkOnAreaClick } from '../../hooks/useAreaClick'; const Heading = styled.div` display: flex; @@ -38,8 +38,19 @@ const ListBlockStyled = styled(ListBlock)<{ width: string; marginLeft?: string } margin-bottom: 0; `; +const IconContainerStyled = styled.div` + margin-left: auto; + margin-right: 1rem; + color: ${props => props.theme.iconTertiary}; + transition: transform 0.2s ease; +`; + const ListItemStyled = styled(ListItem)` - padding-left: 16px; + padding-left: 1rem; + + &:hover ${IconContainerStyled} { + transform: translateX(2px); + } `; export const CategoryBlock: FC<{ category: PromotionCategory; className?: string }> = ({ @@ -65,7 +76,7 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string const canExpand = groups.length > 1; return ( - +

{category.title}

{canExpand && ( @@ -87,15 +98,7 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string marginLeft={groupIndex === 0 ? '-34px' : '0'} > {group.map(item => ( - - - - - {item.name} - {item.description} - - - + ))} ))} @@ -105,20 +108,31 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string {group.map(item => ( - - - - - {item.name} - {item.description} - - - + ))} )) )} - +
+ ); +}; + +const GroupItem: FC<{ item: PromotedApp }> = ({ item }) => { + const ref = useOpenLinkOnAreaClick(item.url); + + return ( + + + + + {item.name} + {item.description} + + + + + + ); }; diff --git a/packages/uikit/src/pages/browser/index.tsx b/packages/uikit/src/pages/browser/index.tsx index a5285214b..cd5430382 100644 --- a/packages/uikit/src/pages/browser/index.tsx +++ b/packages/uikit/src/pages/browser/index.tsx @@ -51,7 +51,11 @@ const CountryButton = styled.button` `; const PromotionsCarouselStyled = styled(PromotionsCarousel)` - margin-bottom: 16px; + margin-bottom: 1rem; +`; + +const CategoryBlockStyled = styled(CategoryBlock)` + margin-bottom: 1rem; `; const BrowserPage: FC = () => { @@ -76,7 +80,7 @@ const BrowserPage: FC = () => { {data.categories.map(category => ( - + ))} )} diff --git a/packages/uikit/src/pages/browser/promoted-item.tsx b/packages/uikit/src/pages/browser/promoted-item.tsx index 9ca41a988..4f6fd0207 100644 --- a/packages/uikit/src/pages/browser/promoted-item.tsx +++ b/packages/uikit/src/pages/browser/promoted-item.tsx @@ -1,8 +1,8 @@ import styled from 'styled-components'; export const PromotedItem = styled.div` - padding-top: 8px; - padding-bottom: 8px; + padding-top: 8px !important; + padding-bottom: 8px !important; display: flex; align-items: center; width: 100%; diff --git a/packages/uikit/src/pages/browser/promotions-carousel.tsx b/packages/uikit/src/pages/browser/promotions-carousel.tsx index 888e9f9dd..da6a1ee94 100644 --- a/packages/uikit/src/pages/browser/promotions-carousel.tsx +++ b/packages/uikit/src/pages/browser/promotions-carousel.tsx @@ -1,10 +1,10 @@ import { Body3, Label2 } from '../../components/Text'; -import { FC, useRef } from 'react'; +import { FC } from 'react'; import { Carousel } from '../../components/shared'; import styled from 'styled-components'; import { CarouselApp } from '../../hooks/browser/useRecommendations'; -import { useAppSdk } from '../../hooks/appSdk'; import { PromotedItem, PromotedItemImage, PromotedItemText } from './promoted-item'; +import { useOpenLinkOnAreaClick } from '../../hooks/useAreaClick'; const CarouselCard = styled.div<{ img: string }>` width: 448px; @@ -23,43 +23,31 @@ const CarouselCardFooter = styled(PromotedItem)` margin-left: 1rem; `; -export const PromotionsCarousel: FC<{ apps: CarouselApp[] }> = ({ apps }) => { - const sdk = useAppSdk(); - - const clickedPosition = useRef<{ clientX: number; clientY: number }>({ - clientX: 0, - clientY: 0 - }); - +export const PromotionsCarousel: FC<{ apps: CarouselApp[]; className?: string }> = ({ + apps, + className +}) => { return ( - + {apps.map(item => ( - - (clickedPosition.current = { - clientY: e.clientY, - clientX: e.clientX - }) - } - onMouseUp={e => { - const xInArea = Math.abs(e.clientX - clickedPosition.current.clientX) < 10; - const yInArea = Math.abs(e.clientY - clickedPosition.current.clientY) < 10; - if (xInArea && yInArea) { - sdk.openPage(item.url); - } - }} - > - - - - {item.name} - {item.description} - - - + ))} ); }; + +const CarouselItem: FC<{ item: CarouselApp }> = ({ item }) => { + const ref = useOpenLinkOnAreaClick(item.url); + + return ( + + + + + {item.name} + {item.description} + + + + ); +}; From 08f0511579598b820ae8779768dc1bf2d6f7fca5 Mon Sep 17 00:00:00 2001 From: siandreev Date: Fri, 19 Jan 2024 19:52:21 +0100 Subject: [PATCH 09/16] feat: category page added --- packages/uikit/src/components/SubHeader.tsx | 6 +- packages/uikit/src/libs/routes.ts | 5 + .../browser/BrowserRecommendationsPage.tsx | 90 ++++++++++++++++++ .../{category-block.tsx => CategoryBlock.tsx} | 18 ++-- .../uikit/src/pages/browser/CategoryPage.tsx | 29 ++++++ .../{promoted-item.tsx => PromotedItem.tsx} | 0 ...ns-carousel.tsx => PromotionsCarousel.tsx} | 2 +- packages/uikit/src/pages/browser/index.tsx | 93 ++----------------- 8 files changed, 149 insertions(+), 94 deletions(-) create mode 100644 packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx rename packages/uikit/src/pages/browser/{category-block.tsx => CategoryBlock.tsx} (88%) create mode 100644 packages/uikit/src/pages/browser/CategoryPage.tsx rename packages/uikit/src/pages/browser/{promoted-item.tsx => PromotedItem.tsx} (100%) rename packages/uikit/src/pages/browser/{promotions-carousel.tsx => PromotionsCarousel.tsx} (98%) diff --git a/packages/uikit/src/components/SubHeader.tsx b/packages/uikit/src/components/SubHeader.tsx index 3aa2e4e33..1e8a5026e 100644 --- a/packages/uikit/src/components/SubHeader.tsx +++ b/packages/uikit/src/components/SubHeader.tsx @@ -7,7 +7,9 @@ import { ChevronLeftIcon } from './Icon'; import { H3 } from './Text'; import { BackButton } from './fields/BackButton'; -const Block = styled.div` +export const WithHeadingDivider = styled.div``; + +const Block = styled(WithHeadingDivider)` flex-shrink: 0; padding: 1rem; @@ -27,7 +29,7 @@ const Block = styled.div` `; export const SybHeaderGlobalStyle = createGlobalStyle` - body:not(.top) ${Block} { + body:not(.top) ${WithHeadingDivider} { &:after { content: ''; display: block; diff --git a/packages/uikit/src/libs/routes.ts b/packages/uikit/src/libs/routes.ts index c0db22871..b9293340b 100644 --- a/packages/uikit/src/libs/routes.ts +++ b/packages/uikit/src/libs/routes.ts @@ -30,6 +30,11 @@ export enum SettingsRoute { country = '/country' } +export enum BrowserRoute { + index = '/', + category = '/category' +} + export const any = (route: string): string => { return `${route}/*`; }; diff --git a/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx b/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx new file mode 100644 index 000000000..7bc49113e --- /dev/null +++ b/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx @@ -0,0 +1,90 @@ +import { FC } from 'react'; +import styled from 'styled-components'; +import { useRecommendations } from '../../hooks/browser/useRecommendations'; +import { H1, Label2 } from '../../components/Text'; +import { AppRoute, SettingsRoute } from '../../libs/routes'; +import { Link } from 'react-router-dom'; +import { useUserCountry } from '../../state/country'; +import { SkeletonText } from '../../components/Skeleton'; +import { PromotionsCarousel } from './PromotionsCarousel'; +import { CategoryBlock } from './CategoryBlock'; +import { InnerBody } from '../../components/Body'; +import { WithHeadingDivider } from '../../components/SubHeader'; + +const InnerBodyStyled = styled(InnerBody)` + padding: 0; +`; + +const Heading = styled(WithHeadingDivider)` + position: fixed; + top: 0; + width: var(--app-width); + max-width: 548px; + box-sizing: border-box; + padding: 12px 1rem; + display: flex; + align-items: center; + background-color: ${props => props.theme.backgroundPage}; + z-index: 3; +`; +const SkeletonCountry = styled(SkeletonText)` + position: absolute; + right: 16px; + top: 24px; +`; + +const CountryButton = styled.button` + position: absolute; + right: 16px; + top: 16px; + color: ${props => props.theme.buttonSecondaryForeground}; + background: ${props => props.theme.buttonSecondaryBackground}; + border-radius: ${props => props.theme.cornerSmall}; + border: none; + padding: 6px 12px; + cursor: pointer; + + &:hover { + background-color: ${props => props.theme.backgroundContentTint}; + } + + transition: background-color 0.1s ease; +`; + +const PromotionsCarouselStyled = styled(PromotionsCarousel)` + margin-bottom: 1rem; +`; + +const CategoryBlockStyled = styled(CategoryBlock)` + margin-bottom: 1rem; +`; + +export const BrowserRecommendationsPage: FC = () => { + const { data, isLoading, error } = useRecommendations(); + const { data: country, isLoading: isCountryLoading } = useUserCountry(); + + return ( +
+ +

Discover

+ {isCountryLoading ? ( + + ) : ( + + + {country || '🌎'} + + + )} +
+ {!!data && ( + + + {data.categories.map(category => ( + + ))} + + )} +
+ ); +}; diff --git a/packages/uikit/src/pages/browser/category-block.tsx b/packages/uikit/src/pages/browser/CategoryBlock.tsx similarity index 88% rename from packages/uikit/src/pages/browser/category-block.tsx rename to packages/uikit/src/pages/browser/CategoryBlock.tsx index 3086a515d..e34e31b9f 100644 --- a/packages/uikit/src/pages/browser/category-block.tsx +++ b/packages/uikit/src/pages/browser/CategoryBlock.tsx @@ -4,10 +4,12 @@ import { Body3, H3, Label1, Label2 } from '../../components/Text'; import { PromotedApp, PromotionCategory } from '../../hooks/browser/useRecommendations'; import { ListBlock, ListItem } from '../../components/List'; import { Carousel } from '../../components/shared'; -import { PromotedItem, PromotedItemImage, PromotedItemText } from './promoted-item'; +import { PromotedItem, PromotedItemImage, PromotedItemText } from './PromotedItem'; import { useElementSize } from '../../hooks/useElementSize'; import { ChevronRightIcon } from '../../components/Icon'; import { useOpenLinkOnAreaClick } from '../../hooks/useAreaClick'; +import { Link } from 'react-router-dom'; +import { BrowserRoute } from '../../libs/routes'; const Heading = styled.div` display: flex; @@ -80,9 +82,11 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string

{category.title}

{canExpand && ( - - All - + + + All + + )}
{canExpand ? ( @@ -98,7 +102,7 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string marginLeft={groupIndex === 0 ? '-34px' : '0'} > {group.map(item => ( - + ))} ))} @@ -108,7 +112,7 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string {group.map(item => ( - + ))} @@ -118,7 +122,7 @@ export const CategoryBlock: FC<{ category: PromotionCategory; className?: string ); }; -const GroupItem: FC<{ item: PromotedApp }> = ({ item }) => { +export const CategoryGroupItem: FC<{ item: PromotedApp }> = ({ item }) => { const ref = useOpenLinkOnAreaClick(item.url); return ( diff --git a/packages/uikit/src/pages/browser/CategoryPage.tsx b/packages/uikit/src/pages/browser/CategoryPage.tsx new file mode 100644 index 000000000..ca04590cf --- /dev/null +++ b/packages/uikit/src/pages/browser/CategoryPage.tsx @@ -0,0 +1,29 @@ +import { SubHeader } from '../../components/SubHeader'; +import { InnerBody } from '../../components/Body'; +import React from 'react'; +import { useParams } from 'react-router-dom'; +import { CategoryGroupItem } from './CategoryBlock'; +import { ListBlock } from '../../components/List'; +import { useRecommendations } from '../../hooks/browser/useRecommendations'; + +export const CategoryPage = () => { + const { id } = useParams(); + const { data, isLoading, error } = useRecommendations(); + + const group = data?.categories.find(item => item.id === id); + + return ( + <> + + + {group && ( + + {group.apps.map(item => ( + + ))} + + )} + + + ); +}; diff --git a/packages/uikit/src/pages/browser/promoted-item.tsx b/packages/uikit/src/pages/browser/PromotedItem.tsx similarity index 100% rename from packages/uikit/src/pages/browser/promoted-item.tsx rename to packages/uikit/src/pages/browser/PromotedItem.tsx diff --git a/packages/uikit/src/pages/browser/promotions-carousel.tsx b/packages/uikit/src/pages/browser/PromotionsCarousel.tsx similarity index 98% rename from packages/uikit/src/pages/browser/promotions-carousel.tsx rename to packages/uikit/src/pages/browser/PromotionsCarousel.tsx index da6a1ee94..ae4b51257 100644 --- a/packages/uikit/src/pages/browser/promotions-carousel.tsx +++ b/packages/uikit/src/pages/browser/PromotionsCarousel.tsx @@ -3,7 +3,7 @@ import { FC } from 'react'; import { Carousel } from '../../components/shared'; import styled from 'styled-components'; import { CarouselApp } from '../../hooks/browser/useRecommendations'; -import { PromotedItem, PromotedItemImage, PromotedItemText } from './promoted-item'; +import { PromotedItem, PromotedItemImage, PromotedItemText } from './PromotedItem'; import { useOpenLinkOnAreaClick } from '../../hooks/useAreaClick'; const CarouselCard = styled.div<{ img: string }>` diff --git a/packages/uikit/src/pages/browser/index.tsx b/packages/uikit/src/pages/browser/index.tsx index cd5430382..b5e824dd9 100644 --- a/packages/uikit/src/pages/browser/index.tsx +++ b/packages/uikit/src/pages/browser/index.tsx @@ -1,90 +1,15 @@ -import { FC } from 'react'; -import styled from 'styled-components'; -import { useRecommendations } from '../../hooks/browser/useRecommendations'; -import { H1, Label2 } from '../../components/Text'; -import { AppRoute, SettingsRoute } from '../../libs/routes'; -import { Link } from 'react-router-dom'; -import { useUserCountry } from '../../state/country'; -import { SkeletonText } from '../../components/Skeleton'; -import { PromotionsCarousel } from './promotions-carousel'; -import { CategoryBlock } from './category-block'; -import { InnerBody } from '../../components/Body'; - -const InnerBodyStyled = styled(InnerBody)` - padding: 0; -`; - -const Heading = styled.div` - position: fixed; - top: 0; - width: var(--app-width); - max-width: 548px; - box-sizing: border-box; - padding: 12px 1rem; - display: flex; - align-items: center; - background-color: ${props => props.theme.backgroundPage}; - z-index: 3; -`; -const SkeletonCountry = styled(SkeletonText)` - position: absolute; - right: 16px; - top: 24px; -`; - -const CountryButton = styled.button` - position: absolute; - right: 16px; - top: 16px; - color: ${props => props.theme.buttonSecondaryForeground}; - background: ${props => props.theme.buttonSecondaryBackground}; - border-radius: ${props => props.theme.cornerSmall}; - border: none; - padding: 6px 12px; - cursor: pointer; - - &:hover { - background-color: ${props => props.theme.backgroundContentTint}; - } - - transition: background-color 0.1s ease; -`; - -const PromotionsCarouselStyled = styled(PromotionsCarousel)` - margin-bottom: 1rem; -`; - -const CategoryBlockStyled = styled(CategoryBlock)` - margin-bottom: 1rem; -`; +import React, { FC } from 'react'; +import { BrowserRoute } from '../../libs/routes'; +import { Route, Routes } from 'react-router-dom'; +import { CategoryPage } from './CategoryPage'; +import { BrowserRecommendationsPage } from './BrowserRecommendationsPage'; const BrowserPage: FC = () => { - const { data, isLoading, error } = useRecommendations(); - const { data: country, isLoading: isCountryLoading } = useUserCountry(); - return ( -
- -

Discover

- {isCountryLoading ? ( - - ) : ( - - - {country || '🌎'} - - - )} -
- {!!data && ( - - - {data.categories.map(category => ( - - ))} - - )} -
+ + } /> + } /> + ); }; From feab266b2554ea1330f719c503282216ccbe9d1a Mon Sep 17 00:00:00 2001 From: siandreev Date: Mon, 22 Jan 2024 13:40:16 +0100 Subject: [PATCH 10/16] feat: browser page skeleton added --- apps/web/src/App.tsx | 4 +- packages/uikit/src/components/Header.tsx | 50 ++++++++++- packages/uikit/src/components/Skeleton.tsx | 35 +++++++- .../components/skeletons/BrowserSkeletons.tsx | 61 ++++++++++++++ .../browser/BrowserRecommendationsPage.tsx | 83 +++++-------------- .../uikit/src/pages/browser/CategoryPage.tsx | 13 ++- 6 files changed, 177 insertions(+), 69 deletions(-) create mode 100644 packages/uikit/src/components/skeletons/BrowserSkeletons.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 1c59a6974..8cc5829dd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,7 +11,7 @@ import { GlobalListStyle } from '@tonkeeper/uikit/dist/components/List'; import { Loading } from '@tonkeeper/uikit/dist/components/Loading'; import MemoryScroll from '@tonkeeper/uikit/dist/components/MemoryScroll'; import { - ActivitySkeletonPage, + ActivitySkeletonPage, BrowserSkeletonPage, CoinSkeletonPage, HomeSkeleton, SettingsSkeletonPage @@ -280,7 +280,7 @@ export const Content: FC<{ }> + }> } diff --git a/packages/uikit/src/components/Header.tsx b/packages/uikit/src/components/Header.tsx index e176886ec..db39a0058 100644 --- a/packages/uikit/src/components/Header.tsx +++ b/packages/uikit/src/components/Header.tsx @@ -1,6 +1,6 @@ import { formatAddress, toShortValue } from '@tonkeeper/core/dist/utils/common'; import React, { FC, useState } from 'react'; -import { useNavigate } from 'react-router-dom'; +import { Link, useNavigate } from 'react-router-dom'; import styled, { createGlobalStyle, css } from 'styled-components'; import { useAppContext, useWalletContext } from '../hooks/appContext'; import { useTranslation } from '../hooks/translation'; @@ -11,9 +11,11 @@ import { DropDown } from './DropDown'; import { DoneIcon, DownIcon, PlusIcon, SettingsIcon } from './Icon'; import { ColumnText, Divider } from './Layout'; import { ListItem, ListItemPayload } from './List'; -import { H1, H3, Label1 } from './Text'; +import { H1, H3, Label1, Label2 } from './Text'; import { ScanButton } from './connect/ScanButton'; import { ImportNotification } from './create/ImportNotification'; +import { useUserCountry } from '../state/country'; +import { SkeletonText } from './Skeleton'; const Block = styled.div<{ center?: boolean; @@ -236,3 +238,47 @@ export const SettingsHeader = () => { ); }; + +const SkeletonCountry = styled(SkeletonText)` + position: absolute; + right: 16px; + top: 24px; +`; + +const CountryButton = styled.button` + position: absolute; + right: 16px; + top: 16px; + color: ${props => props.theme.buttonSecondaryForeground}; + background: ${props => props.theme.buttonSecondaryBackground}; + border-radius: ${props => props.theme.cornerSmall}; + border: none; + padding: 6px 12px; + cursor: pointer; + + &:hover { + background-color: ${props => props.theme.backgroundContentTint}; + } + + transition: background-color 0.1s ease; +`; + +export const BrowserHeader = () => { + const { t } = useTranslation(); + const { data: country, isLoading: isCountryLoading } = useUserCountry(); + + return ( + +

{t('browser_title')}

+ {isCountryLoading ? ( + + ) : ( + + + {country || '🌎'} + + + )} +
+ ); +}; diff --git a/packages/uikit/src/components/Skeleton.tsx b/packages/uikit/src/components/Skeleton.tsx index c7cbc7288..00c8a29b9 100644 --- a/packages/uikit/src/components/Skeleton.tsx +++ b/packages/uikit/src/components/Skeleton.tsx @@ -2,7 +2,7 @@ import React, { FC, useEffect } from 'react'; import styled, { css } from 'styled-components'; import { useAppSdk } from '../hooks/appSdk'; import { InnerBody } from './Body'; -import { ActivityHeader, SettingsHeader } from './Header'; +import { ActivityHeader, BrowserHeader, SettingsHeader } from './Header'; import { ActionsRow } from './home/Actions'; import { BalanceSkeleton } from './home/Balance'; import { CoinInfoSkeleton } from './jettons/Info'; @@ -10,6 +10,7 @@ import { ColumnText } from './Layout'; import { ListBlock, ListItem, ListItemPayload } from './List'; import { SubHeader } from './SubHeader'; import { H3 } from './Text'; +import { RecommendationsPageBodySkeleton } from './skeletons/BrowserSkeletons'; function randomIntFromInterval(min: number, max: number) { return Math.floor(Math.random() * (max - min + 1) + min); @@ -74,6 +75,27 @@ export const SkeletonText: FC<{ size?: 'large' | 'small'; width?: string; classN return ; }); +export const Skeleton = styled(Base)<{ + width: string; + height: string; + borderRadius?: string; + margin?: string; + marginBottom?: string; +}>` + display: block; + border-radius: ${props => + props.borderRadius + ? props.theme[props.borderRadius] || props.theme.cornerExtraExtraSmall + : props.theme.cornerExtraExtraSmall}; + + ${props => css` + width: ${props.width ?? '3rem'}; + height: ${props.height ?? '20px'}; + ${props.margin && `margin: ${props.margin};`} + ${props.marginBottom && `margin-bottom: ${props.marginBottom};`} + `} +`; + const Image = styled(Base)<{ width?: string }>` border-radius: ${props => props.theme.cornerFull}; @@ -212,6 +234,17 @@ export const SettingsSkeletonPage = React.memo(() => { ); }); +export const BrowserSkeletonPage = React.memo(() => { + return ( + <> + + + + + + ); +}); + export const HistoryBlock = styled.div` margin-top: 3rem; `; diff --git a/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx b/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx new file mode 100644 index 000000000..9338846b0 --- /dev/null +++ b/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx @@ -0,0 +1,61 @@ +import { PromotedItem, PromotedItemText } from '../../pages/browser/PromotedItem'; +import { Skeleton } from '../Skeleton'; +import React, { FC } from 'react'; +import { ListBlock, ListItem } from '../List'; +import styled from 'styled-components'; + +const ListItemStyled = styled(ListItem)` + padding-left: 1rem; +`; + +const Heading = styled.div` + display: flex; + justify-content: space-between; + align-items: center; + padding: 14px 0; + gap: 1rem; +`; + +export const RecommendationsPageBodySkeleton: FC = () => { + return ( + <> + + + + + ); +}; + +export const RecommendationPageListItemSkeleton = () => { + return ( + + + + + + + + + + ); +}; + +const CategorySkeleton: FC<{ className?: string }> = ({ className }) => { + return ( +
+ + + + + + + + +
+ ); +}; diff --git a/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx b/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx index 7bc49113e..37feb3ee3 100644 --- a/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx +++ b/packages/uikit/src/pages/browser/BrowserRecommendationsPage.tsx @@ -1,56 +1,16 @@ import { FC } from 'react'; import styled from 'styled-components'; import { useRecommendations } from '../../hooks/browser/useRecommendations'; -import { H1, Label2 } from '../../components/Text'; -import { AppRoute, SettingsRoute } from '../../libs/routes'; -import { Link } from 'react-router-dom'; -import { useUserCountry } from '../../state/country'; -import { SkeletonText } from '../../components/Skeleton'; import { PromotionsCarousel } from './PromotionsCarousel'; import { CategoryBlock } from './CategoryBlock'; import { InnerBody } from '../../components/Body'; -import { WithHeadingDivider } from '../../components/SubHeader'; +import { RecommendationsPageBodySkeleton } from '../../components/skeletons/BrowserSkeletons'; +import { BrowserHeader } from '../../components/Header'; const InnerBodyStyled = styled(InnerBody)` padding: 0; `; -const Heading = styled(WithHeadingDivider)` - position: fixed; - top: 0; - width: var(--app-width); - max-width: 548px; - box-sizing: border-box; - padding: 12px 1rem; - display: flex; - align-items: center; - background-color: ${props => props.theme.backgroundPage}; - z-index: 3; -`; -const SkeletonCountry = styled(SkeletonText)` - position: absolute; - right: 16px; - top: 24px; -`; - -const CountryButton = styled.button` - position: absolute; - right: 16px; - top: 16px; - color: ${props => props.theme.buttonSecondaryForeground}; - background: ${props => props.theme.buttonSecondaryBackground}; - border-radius: ${props => props.theme.cornerSmall}; - border: none; - padding: 6px 12px; - cursor: pointer; - - &:hover { - background-color: ${props => props.theme.backgroundContentTint}; - } - - transition: background-color 0.1s ease; -`; - const PromotionsCarouselStyled = styled(PromotionsCarousel)` margin-bottom: 1rem; `; @@ -59,32 +19,31 @@ const CategoryBlockStyled = styled(CategoryBlock)` margin-bottom: 1rem; `; +const SkeletonContainer = styled.div` + padding: 0 1rem; +`; + export const BrowserRecommendationsPage: FC = () => { - const { data, isLoading, error } = useRecommendations(); - const { data: country, isLoading: isCountryLoading } = useUserCountry(); + const { data } = useRecommendations(); return (
- -

Discover

- {isCountryLoading ? ( - + + + + {data ? ( + <> + + {data.categories.map(category => ( + + ))} + ) : ( - - - {country || '🌎'} - - + + + )} -
- {!!data && ( - - - {data.categories.map(category => ( - - ))} - - )} +
); }; diff --git a/packages/uikit/src/pages/browser/CategoryPage.tsx b/packages/uikit/src/pages/browser/CategoryPage.tsx index ca04590cf..5a633dc39 100644 --- a/packages/uikit/src/pages/browser/CategoryPage.tsx +++ b/packages/uikit/src/pages/browser/CategoryPage.tsx @@ -5,10 +5,11 @@ import { useParams } from 'react-router-dom'; import { CategoryGroupItem } from './CategoryBlock'; import { ListBlock } from '../../components/List'; import { useRecommendations } from '../../hooks/browser/useRecommendations'; +import { RecommendationPageListItemSkeleton } from '../../components/skeletons/BrowserSkeletons'; export const CategoryPage = () => { const { id } = useParams(); - const { data, isLoading, error } = useRecommendations(); + const { data } = useRecommendations(); const group = data?.categories.find(item => item.id === id); @@ -16,12 +17,20 @@ export const CategoryPage = () => { <> - {group && ( + {group ? ( {group.apps.map(item => ( ))} + ) : ( + + + + + + + )} From 71f6e73ebe347752788fa2109f321eace8c7a0c0 Mon Sep 17 00:00:00 2001 From: siandreev Date: Mon, 22 Jan 2024 14:15:18 +0100 Subject: [PATCH 11/16] fix: browser page added to desktop, extension and twa apps --- apps/desktop/src/app/App.tsx | 10 ++++++++++ apps/extension/src/App.tsx | 11 ++++++++++- apps/twa/src/App.tsx | 11 ++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/app/App.tsx b/apps/desktop/src/app/App.tsx index 9cb72ae3d..0f74491a2 100644 --- a/apps/desktop/src/app/App.tsx +++ b/apps/desktop/src/app/App.tsx @@ -14,6 +14,7 @@ import MemoryScroll from '@tonkeeper/uikit/dist/components/MemoryScroll'; import QrScanner from '@tonkeeper/uikit/dist/components/QrScanner'; import { ActivitySkeletonPage, + BrowserSkeletonPage, CoinSkeletonPage, HomeSkeleton, SettingsSkeletonPage @@ -46,6 +47,7 @@ import { UnlockNotification } from '@tonkeeper/uikit/dist/pages/home/UnlockNotif import ImportRouter from '@tonkeeper/uikit/dist/pages/import'; import Initialize, { InitializeContainer } from '@tonkeeper/uikit/dist/pages/import/Initialize'; import Settings from '@tonkeeper/uikit/dist/pages/settings'; +import Browser from '@tonkeeper/uikit/dist/pages/browser'; import { UserThemeProvider } from '@tonkeeper/uikit/dist/providers/UserThemeProvider'; import { useAccountState } from '@tonkeeper/uikit/dist/state/account'; import { useAuthState } from '@tonkeeper/uikit/dist/state/password'; @@ -239,6 +241,14 @@ export const Content: FC<{ } /> + }> + + + } + /> import('@tonkeeper/uikit/dist/pages/import')); const Settings = React.lazy(() => import('@tonkeeper/uikit/dist/pages/settings')); +const Browser = React.lazy(() => import('@tonkeeper/uikit/dist/pages/browser')); const Activity = React.lazy(() => import('@tonkeeper/uikit/dist/pages/activity/Activity')); const Home = React.lazy(() => import('@tonkeeper/uikit/dist/pages/home/Home')); const Coin = React.lazy(() => import('@tonkeeper/uikit/dist/pages/coin/Coin')); @@ -282,6 +283,14 @@ export const Content: FC<{ } /> + }> + + + } + /> import('@tonkeeper/uikit/dist/pages/import/Initialize')); const ImportRouter = React.lazy(() => import('@tonkeeper/uikit/dist/pages/import')); +const Browser = React.lazy(() => import('@tonkeeper/uikit/dist/pages/browser')); const Settings = React.lazy(() => import('@tonkeeper/uikit/dist/pages/settings')); const Activity = React.lazy(() => import('@tonkeeper/uikit/dist/pages/activity/Activity')); const Home = React.lazy(() => import('@tonkeeper/uikit/dist/pages/home/Home')); @@ -350,6 +351,14 @@ const MainPages: FC<{ showQrScan: boolean }> = ({ showQrScan }) => { } /> + }> + + + } + /> Date: Mon, 22 Jan 2024 15:01:49 +0100 Subject: [PATCH 12/16] Add aliases --- apps/web/vite.config.mts | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/web/vite.config.mts b/apps/web/vite.config.mts index 2bc946fc1..2c4348fc5 100644 --- a/apps/web/vite.config.mts +++ b/apps/web/vite.config.mts @@ -1,6 +1,17 @@ import react from '@vitejs/plugin-react'; -import { defineConfig } from "vite"; +import * as path from 'path'; +import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [react()] + plugins: [react()], + resolve: { + alias: { + react: path.resolve(__dirname, './node_modules/react'), + 'react-dom': path.resolve(__dirname, './node_modules/react-dom'), + 'react-router-dom': path.resolve(__dirname, './node_modules/react-router-dom'), + 'styled-components': path.resolve(__dirname, './node_modules/styled-components'), + 'react-i18next': path.resolve(__dirname, './node_modules/react-i18next'), + '@tanstack/react-query': path.resolve(__dirname, './node_modules/@tanstack/react-query') + } + } }); From 45c8941ce6f71c35ba191a84dabb9ae48311cc51 Mon Sep 17 00:00:00 2001 From: siandreev Date: Mon, 22 Jan 2024 18:18:37 +0100 Subject: [PATCH 13/16] fix: https://github.com/tonkeeper/tonkeeper-web/pull/23 comments --- apps/desktop/src/app/App.tsx | 8 +- apps/extension/src/App.tsx | 2 + apps/twa/src/App.tsx | 4 +- apps/web/src/App.tsx | 3 +- packages/core/src/AppSdk.ts | 5 + packages/core/src/tonkeeperApi/tonendpoint.ts | 97 +++++++++++++++---- packages/uikit/src/components/Header.tsx | 4 +- .../src/hooks/browser/useRecommendations.ts | 34 +------ .../uikit/src/pages/browser/CategoryBlock.tsx | 2 +- .../uikit/src/pages/browser/PromotedItem.tsx | 14 ++- .../src/pages/browser/PromotionsCarousel.tsx | 18 +++- packages/uikit/src/state/tonendpoint.ts | 15 ++- 12 files changed, 139 insertions(+), 67 deletions(-) diff --git a/apps/desktop/src/app/App.tsx b/apps/desktop/src/app/App.tsx index 0f74491a2..ef6d7039a 100644 --- a/apps/desktop/src/app/App.tsx +++ b/apps/desktop/src/app/App.tsx @@ -73,6 +73,7 @@ const queryClient = new QueryClient({ }); const sdk = new DesktopAppSdk(); +const TARGET_ENV = 'desktop'; const langs = 'en,zh_CN,ru,it,tr'; const listOfAuth: AuthState['kind'][] = ['keychain']; @@ -135,7 +136,12 @@ export const Loader: FC = () => { const { data: account } = useAccountState(); const { data: auth } = useAuthState(); - const tonendpoint = useTonendpoint(sdk.version, activeWallet?.network, activeWallet?.lang); + const tonendpoint = useTonendpoint( + TARGET_ENV, + sdk.version, + activeWallet?.network, + activeWallet?.lang + ); const { data: config } = useTonenpointConfig(tonendpoint); const navigate = useNavigate(); diff --git a/apps/extension/src/App.tsx b/apps/extension/src/App.tsx index 412632a4b..4ca3fcf18 100644 --- a/apps/extension/src/App.tsx +++ b/apps/extension/src/App.tsx @@ -82,6 +82,7 @@ const queryClient = new QueryClient({ }); const sdk = new ExtensionAppSdk(); +const TARGET_ENV = 'extension'; connectToBackground(); export const App: FC = () => { @@ -166,6 +167,7 @@ export const Loader: FC = React.memo(() => { const { data: account } = useAccountState(); const { data: auth } = useAuthState(); const tonendpoint = useTonendpoint( + TARGET_ENV, sdk.version, activeWallet?.network, localizationFrom(browser.i18n.getUILanguage()) diff --git a/apps/twa/src/App.tsx b/apps/twa/src/App.tsx index 7b8fadd8a..abc8c92d9 100644 --- a/apps/twa/src/App.tsx +++ b/apps/twa/src/App.tsx @@ -78,6 +78,8 @@ const queryClient = new QueryClient({ } }); +const TARGET_ENV = 'twa'; + export const App = () => { return ( @@ -212,7 +214,7 @@ export const Loader: FC<{ sdk: IAppSdk }> = ({ sdk }) => { const { data: account } = useAccountState(); const { data: auth } = useAuthState(); - const tonendpoint = useTonendpoint(sdk.version, activeWallet?.network, activeWallet?.lang); + const tonendpoint = useTonendpoint(TARGET_ENV, sdk.version, activeWallet?.network, activeWallet?.lang); const { data: config } = useTonenpointConfig(tonendpoint); const navigate = useNavigate(); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 8cc5829dd..0d4d3dbb0 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -82,6 +82,7 @@ const queryClient = new QueryClient({ }); const sdk = new BrowserAppSdk(); +const TARGET_ENV = 'web'; export const App: FC = () => { const { t, i18n } = useTranslation(); @@ -171,7 +172,7 @@ export const Loader: FC = () => { const { data: account } = useAccountState(); const { data: auth } = useAuthState(); - const tonendpoint = useTonendpoint(sdk.version, activeWallet?.network, activeWallet?.lang); + const tonendpoint = useTonendpoint(TARGET_ENV, sdk.version, activeWallet?.network, activeWallet?.lang); const { data: config } = useTonenpointConfig(tonendpoint); const navigate = useNavigate(); diff --git a/packages/core/src/AppSdk.ts b/packages/core/src/AppSdk.ts index 797982526..84f14d996 100644 --- a/packages/core/src/AppSdk.ts +++ b/packages/core/src/AppSdk.ts @@ -103,6 +103,7 @@ export abstract class BaseApp implements IAppSdk { uiEvents = new EventEmitter(); constructor(public storage: IStorage) {} + nativeBackButton?: NativeBackButton | undefined; topMessage = (text?: string) => { @@ -136,7 +137,9 @@ export abstract class BaseApp implements IAppSdk { isStandalone = () => false; confirm = async (text: string) => window.confirm(text); + alert = async (text: string) => window.alert(text); + prompt = async (message: string, defaultValue?: string) => window.prompt(message, defaultValue); requestExtensionPermission = async () => {}; @@ -153,3 +156,5 @@ export class MockAppSdk extends BaseApp { super(new MemoryStorage()); } } + +export type TargetEnv = 'web' | 'extension' | 'desktop' | 'twa'; diff --git a/packages/core/src/tonkeeperApi/tonendpoint.ts b/packages/core/src/tonkeeperApi/tonendpoint.ts index ee8961e31..5305fb3a8 100644 --- a/packages/core/src/tonkeeperApi/tonendpoint.ts +++ b/packages/core/src/tonkeeperApi/tonendpoint.ts @@ -1,6 +1,7 @@ import { intlLocale } from '../entries/language'; import { Network } from '../entries/network'; import { FetchAPI } from '../tonApiV2'; +import { TargetEnv } from '../AppSdk'; interface BootParams { platform: 'ios' | 'android' | 'web'; @@ -39,6 +40,8 @@ export interface TonendpointConfig { transactionExplorer?: string; NFTOnExplorerUrl?: string; + featured_play_interval?: number; + /** * @deprecated use ton api */ @@ -66,16 +69,20 @@ export class Tonendpoint { public basePath: string; + public readonly targetEnv: TargetEnv; + constructor( { lang = 'en', build = '3.0.0', network = Network.MAINNET, platform = 'web', - countryCode - }: Partial, + countryCode, + targetEnv + }: Partial & { targetEnv: TargetEnv }, { fetchApi = defaultFetch, basePath = defaultTonendpoint }: BootOptions ) { + this.targetEnv = targetEnv; this.params = { lang, build, network, platform, countryCode }; this.fetchApi = fetchApi; this.basePath = basePath; @@ -85,15 +92,31 @@ export class Tonendpoint { this.params.countryCode = countryCode; }; - toSearchParams = () => { + toSearchParams = ( + rewriteParams?: Partial, + additionalParams?: Record + ) => { const params = new URLSearchParams({ - lang: intlLocale(this.params.lang), - build: this.params.build, - chainName: this.params.network === Network.TESTNET ? 'testnet' : 'mainnet', - platform: this.params.platform + lang: intlLocale(rewriteParams?.lang ?? this.params.lang), + build: rewriteParams?.build ?? this.params.build, + chainName: + (rewriteParams?.network ?? this.params.network) === Network.TESTNET + ? 'testnet' + : 'mainnet', + platform: rewriteParams?.platform ?? this.params.platform }); - if (this.params.countryCode) { - params.append('countryCode', this.params.countryCode); + const countryCode = rewriteParams?.countryCode ?? this.params.countryCode; + + if (countryCode) { + params.append('countryCode', countryCode); + } + + if (!additionalParams) { + return params.toString(); + } + + for (const key in additionalParams) { + params.append(key, additionalParams[key].toString()); } return params.toString(); }; @@ -109,10 +132,17 @@ export class Tonendpoint { return response.json(); }; - GET = async (path: string): Promise => { - const response = await this.fetchApi(`${this.basePath}${path}?${this.toSearchParams()}`, { - method: 'GET' - }); + GET = async ( + path: string, + rewriteParams?: Partial, + additionalParams?: Record + ): Promise => { + const response = await this.fetchApi( + `${this.basePath}${path}?${this.toSearchParams(rewriteParams, additionalParams)}`, + { + method: 'GET' + } + ); const result: TonendpointResponse = await response.json(); if (!result.success) { @@ -121,6 +151,18 @@ export class Tonendpoint { return result.data; }; + + getFiatMethods = (countryCode?: string | null | undefined): Promise => { + return this.GET('/fiat/methods', { countryCode }); + }; + + getAppsPopular = (countryCode?: string | null | undefined): Promise => { + return this.GET( + '/apps/popular', + { countryCode }, + { track: this.targetEnv === 'extension' ? 'extension' : 'desktop' } + ); + }; } export const getServerConfig = async (tonendpoint: Tonendpoint): Promise => { @@ -168,10 +210,25 @@ export interface TonendpoinFiatMethods { categories: TonendpoinFiatCategory[]; } -export const getFiatMethods = async ( - tonendpoint: Tonendpoint, - countryCode: string | null | undefined -) => { - tonendpoint.setCountryCode(countryCode); - return tonendpoint.GET('/fiat/methods'); -}; +export interface CarouselApp extends PromotedApp { + poster: string; +} + +export interface PromotedApp { + name: string; + description: string; + icon: string; + url: string; + textColor?: string; +} + +export interface PromotionCategory { + id: string; + title: string; + apps: PromotedApp[]; +} + +export interface Recommendations { + categories: PromotionCategory[]; + apps: CarouselApp[]; +} diff --git a/packages/uikit/src/components/Header.tsx b/packages/uikit/src/components/Header.tsx index db39a0058..e34e2a0d5 100644 --- a/packages/uikit/src/components/Header.tsx +++ b/packages/uikit/src/components/Header.tsx @@ -29,8 +29,8 @@ const Block = styled.div<{ width: var(--app-width); overflow: visible !important; max-width: 548px; - top: 0px; - z-index: 1; + top: 0; + z-index: 4; ${props => css` diff --git a/packages/uikit/src/hooks/browser/useRecommendations.ts b/packages/uikit/src/hooks/browser/useRecommendations.ts index 0db07cb58..afa376252 100644 --- a/packages/uikit/src/hooks/browser/useRecommendations.ts +++ b/packages/uikit/src/hooks/browser/useRecommendations.ts @@ -1,41 +1,15 @@ import { useUserCountry } from '../../state/country'; import { useQuery } from '@tanstack/react-query'; import { QueryKey } from '../../libs/queryKey'; - -export interface CarouselApp extends PromotedApp { - poster: string; -} - -export interface PromotedApp { - name: string; - description: string; - icon: string; - url: string; - textColor?: string; -} - -export interface PromotionCategory { - id: string; - title: string; - apps: PromotedApp[]; -} - -export interface Recommendations { - categories: PromotionCategory[]; - apps: CarouselApp[]; -} +import { Recommendations } from '@tonkeeper/core/dist/tonkeeperApi/tonendpoint'; +import { useAppContext } from '../appContext'; export function useRecommendations() { + const { tonendpoint } = useAppContext(); const country = useUserCountry(); const lang = country.data || 'en'; return useQuery([QueryKey.featuredRecommendations, lang], async () => { - const result = await ( - await fetch(`https://api.tonkeeper.com/apps/popular?lang=${lang}`) - ).json(); - if (!result.success) { - throw new Error('Fetch recommendations api error: success false'); - } - return result.data; + return tonendpoint.getAppsPopular(lang); }); } diff --git a/packages/uikit/src/pages/browser/CategoryBlock.tsx b/packages/uikit/src/pages/browser/CategoryBlock.tsx index e34e31b9f..36320026b 100644 --- a/packages/uikit/src/pages/browser/CategoryBlock.tsx +++ b/packages/uikit/src/pages/browser/CategoryBlock.tsx @@ -1,7 +1,6 @@ import { FC, useMemo } from 'react'; import styled from 'styled-components'; import { Body3, H3, Label1, Label2 } from '../../components/Text'; -import { PromotedApp, PromotionCategory } from '../../hooks/browser/useRecommendations'; import { ListBlock, ListItem } from '../../components/List'; import { Carousel } from '../../components/shared'; import { PromotedItem, PromotedItemImage, PromotedItemText } from './PromotedItem'; @@ -10,6 +9,7 @@ import { ChevronRightIcon } from '../../components/Icon'; import { useOpenLinkOnAreaClick } from '../../hooks/useAreaClick'; import { Link } from 'react-router-dom'; import { BrowserRoute } from '../../libs/routes'; +import { PromotedApp, PromotionCategory } from '@tonkeeper/core/dist/tonkeeperApi/tonendpoint'; const Heading = styled.div` display: flex; diff --git a/packages/uikit/src/pages/browser/PromotedItem.tsx b/packages/uikit/src/pages/browser/PromotedItem.tsx index 4f6fd0207..3f445a5dc 100644 --- a/packages/uikit/src/pages/browser/PromotedItem.tsx +++ b/packages/uikit/src/pages/browser/PromotedItem.tsx @@ -1,8 +1,9 @@ import styled from 'styled-components'; export const PromotedItem = styled.div` - padding-top: 8px !important; - padding-bottom: 8px !important; + padding-top: 0 !important; + padding-bottom: 0 !important; + height: 76px; display: flex; align-items: center; width: 100%; @@ -16,12 +17,17 @@ export const PromotedItemImage = styled.img` export const PromotedItemText = styled.div<{ color?: string }>` display: flex; + min-width: 0; flex-direction: column; padding: 11px 12px 13px; - word-break: break-word; color: ${props => props.color || props.theme.textPrimary}; & > span:nth-child(2) { opacity: 0.78; - } + overflow: hidden; + text-overflow: ellipsis; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + max-height: 32px; `; diff --git a/packages/uikit/src/pages/browser/PromotionsCarousel.tsx b/packages/uikit/src/pages/browser/PromotionsCarousel.tsx index ae4b51257..b17e2fe4c 100644 --- a/packages/uikit/src/pages/browser/PromotionsCarousel.tsx +++ b/packages/uikit/src/pages/browser/PromotionsCarousel.tsx @@ -2,9 +2,10 @@ import { Body3, Label2 } from '../../components/Text'; import { FC } from 'react'; import { Carousel } from '../../components/shared'; import styled from 'styled-components'; -import { CarouselApp } from '../../hooks/browser/useRecommendations'; import { PromotedItem, PromotedItemImage, PromotedItemText } from './PromotedItem'; import { useOpenLinkOnAreaClick } from '../../hooks/useAreaClick'; +import { CarouselApp } from '@tonkeeper/core/dist/tonkeeperApi/tonendpoint'; +import { useAppContext } from '../../hooks/appContext'; const CarouselCard = styled.div<{ img: string }>` width: 448px; @@ -18,6 +19,16 @@ const CarouselCard = styled.div<{ img: string }>` align-items: flex-end; justify-content: flex-start; cursor: pointer; + + @media (max-width: ${480}px) { + width: 400px; + height: 200px; + } + + @media (max-width: ${436}px) { + width: 340px; + height: 170px; + } `; const CarouselCardFooter = styled(PromotedItem)` margin-left: 1rem; @@ -27,8 +38,11 @@ export const PromotionsCarousel: FC<{ apps: CarouselApp[]; className?: string }> apps, className }) => { + const { config } = useAppContext(); + const speed = config.featured_play_interval || 1000 * 10; + return ( - + {apps.map(item => ( ))} diff --git a/packages/uikit/src/state/tonendpoint.ts b/packages/uikit/src/state/tonendpoint.ts index 4db1fdeec..fcdcc1a9b 100644 --- a/packages/uikit/src/state/tonendpoint.ts +++ b/packages/uikit/src/state/tonendpoint.ts @@ -6,18 +6,23 @@ import { TonendpoinFiatItem, Tonendpoint, TonendpointConfig, - getFiatMethods, getServerConfig } from '@tonkeeper/core/dist/tonkeeperApi/tonendpoint'; import { useMemo } from 'react'; import { useAppContext } from '../hooks/appContext'; import { QueryKey, TonkeeperApiKey } from '../libs/queryKey'; import { useUserCountry } from './country'; +import { TargetEnv } from '@tonkeeper/core/dist/AppSdk'; -export const useTonendpoint = (build: string, network?: Network, lang?: Language) => { +export const useTonendpoint = ( + targetEnv: TargetEnv, + build: string, + network?: Network, + lang?: Language +) => { return useMemo(() => { - return new Tonendpoint({ build, network, lang: localizationText(lang) }, {}); - }, [build, network, lang]); + return new Tonendpoint({ build, network, lang: localizationText(lang), targetEnv }, {}); + }, [targetEnv, build, network, lang]); }; export const useTonenpointConfig = (tonendpoint: Tonendpoint) => { @@ -37,7 +42,7 @@ export const useTonendpointBuyMethods = () => { return useQuery( [QueryKey.tonkeeperApi, TonkeeperApiKey.fiat, tonendpoint.params.lang, countryCode], async () => { - const methods = await getFiatMethods(tonendpoint, countryCode); + const methods = await tonendpoint.getFiatMethods(countryCode); const buy = methods.categories[0]; const layout = methods.layoutByCountry.find(item => item.countryCode === countryCode); From ca3c1d612550aa381914803d5b69523b4e48b86b Mon Sep 17 00:00:00 2001 From: siandreev Date: Mon, 22 Jan 2024 18:49:36 +0100 Subject: [PATCH 14/16] chore: fix tsc --- packages/uikit/src/hooks/appContext.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/uikit/src/hooks/appContext.ts b/packages/uikit/src/hooks/appContext.ts index a2363ed7a..b7aac34a9 100644 --- a/packages/uikit/src/hooks/appContext.ts +++ b/packages/uikit/src/hooks/appContext.ts @@ -34,7 +34,7 @@ export const AppContext = React.createContext({ auth: defaultAuthState, fiat: FiatCurrencies.USD, config: defaultTonendpointConfig, - tonendpoint: new Tonendpoint({}, {}), + tonendpoint: new Tonendpoint({ targetEnv: 'web' }, {}), standalone: false, extension: false, ios: false, From 6a47228c3e2cbcdac897ee80fae68ffadf470b9f Mon Sep 17 00:00:00 2001 From: siandreev Date: Mon, 22 Jan 2024 18:59:48 +0100 Subject: [PATCH 15/16] fix: carousel on mobile devices --- .../src/pages/browser/PromotionsCarousel.tsx | 22 ++++++++----------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/packages/uikit/src/pages/browser/PromotionsCarousel.tsx b/packages/uikit/src/pages/browser/PromotionsCarousel.tsx index b17e2fe4c..44ca529d0 100644 --- a/packages/uikit/src/pages/browser/PromotionsCarousel.tsx +++ b/packages/uikit/src/pages/browser/PromotionsCarousel.tsx @@ -8,8 +8,8 @@ import { CarouselApp } from '@tonkeeper/core/dist/tonkeeperApi/tonendpoint'; import { useAppContext } from '../../hooks/appContext'; const CarouselCard = styled.div<{ img: string }>` - width: 448px; - height: 224px; + width: 100%; + aspect-ratio: 2 / 1; background-image: ${props => `url(${props.img})`}; background-size: cover; @@ -19,16 +19,6 @@ const CarouselCard = styled.div<{ img: string }>` align-items: flex-end; justify-content: flex-start; cursor: pointer; - - @media (max-width: ${480}px) { - width: 400px; - height: 200px; - } - - @media (max-width: ${436}px) { - width: 340px; - height: 170px; - } `; const CarouselCardFooter = styled(PromotedItem)` margin-left: 1rem; @@ -42,7 +32,13 @@ export const PromotionsCarousel: FC<{ apps: CarouselApp[]; className?: string }> const speed = config.featured_play_interval || 1000 * 10; return ( - + {apps.map(item => ( ))} From 53db3ce34fdcc737e415d72bfee0074d5f5a626e Mon Sep 17 00:00:00 2001 From: siandreev Date: Mon, 22 Jan 2024 19:15:09 +0100 Subject: [PATCH 16/16] fix: carousel skeleton size --- apps/web/src/App.tsx | 3 +- packages/uikit/src/components/Header.tsx | 2 +- packages/uikit/src/components/Skeleton.tsx | 105 +----------------- .../uikit/src/components/home/Balance.tsx | 2 +- .../uikit/src/components/jettons/Info.tsx | 2 +- .../uikit/src/components/shared/Skeleton.tsx | 96 ++++++++++++++++ .../components/skeletons/BrowserSkeletons.tsx | 14 +-- packages/uikit/src/libs/common.ts | 4 + 8 files changed, 117 insertions(+), 111 deletions(-) create mode 100644 packages/uikit/src/components/shared/Skeleton.tsx diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 0d4d3dbb0..bcdabf100 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -11,7 +11,8 @@ import { GlobalListStyle } from '@tonkeeper/uikit/dist/components/List'; import { Loading } from '@tonkeeper/uikit/dist/components/Loading'; import MemoryScroll from '@tonkeeper/uikit/dist/components/MemoryScroll'; import { - ActivitySkeletonPage, BrowserSkeletonPage, + ActivitySkeletonPage, + BrowserSkeletonPage, CoinSkeletonPage, HomeSkeleton, SettingsSkeletonPage diff --git a/packages/uikit/src/components/Header.tsx b/packages/uikit/src/components/Header.tsx index e34e2a0d5..920691f79 100644 --- a/packages/uikit/src/components/Header.tsx +++ b/packages/uikit/src/components/Header.tsx @@ -15,7 +15,7 @@ import { H1, H3, Label1, Label2 } from './Text'; import { ScanButton } from './connect/ScanButton'; import { ImportNotification } from './create/ImportNotification'; import { useUserCountry } from '../state/country'; -import { SkeletonText } from './Skeleton'; +import { SkeletonText } from './shared/Skeleton'; const Block = styled.div<{ center?: boolean; diff --git a/packages/uikit/src/components/Skeleton.tsx b/packages/uikit/src/components/Skeleton.tsx index 00c8a29b9..062efc1bb 100644 --- a/packages/uikit/src/components/Skeleton.tsx +++ b/packages/uikit/src/components/Skeleton.tsx @@ -1,8 +1,8 @@ import React, { FC, useEffect } from 'react'; -import styled, { css } from 'styled-components'; +import styled from 'styled-components'; import { useAppSdk } from '../hooks/appSdk'; import { InnerBody } from './Body'; -import { ActivityHeader, BrowserHeader, SettingsHeader } from './Header'; +import {ActivityHeader, BrowserHeader, SettingsHeader} from './Header'; import { ActionsRow } from './home/Actions'; import { BalanceSkeleton } from './home/Balance'; import { CoinInfoSkeleton } from './jettons/Info'; @@ -10,104 +10,9 @@ import { ColumnText } from './Layout'; import { ListBlock, ListItem, ListItemPayload } from './List'; import { SubHeader } from './SubHeader'; import { H3 } from './Text'; -import { RecommendationsPageBodySkeleton } from './skeletons/BrowserSkeletons'; - -function randomIntFromInterval(min: number, max: number) { - return Math.floor(Math.random() * (max - min + 1) + min); -} - -function randomWidth() { - return randomIntFromInterval(30, 90) + '%'; -} - -const Base = styled.div` - display: inline-block; - - @keyframes placeHolderShimmer { - 0% { - background-position: -800px 0; - } - 100% { - background-position: 800px 0; - } - } - - animation-duration: 2s; - animation-fill-mode: forwards; - animation-iteration-count: infinite; - animation-name: placeHolderShimmer; - animation-timing-function: linear; - background-color: #f6f7f8; - background: linear-gradient(to right, #4f5a70 8%, #bbbbbb 18%, #4f5a70 33%); - background-size: 800px 104px; - - opacity: 0.1; - - position: relative; -`; -const Block = styled(Base)<{ size?: string; width?: string }>` - border-radius: ${props => props.theme.cornerExtraExtraSmall}; - - ${props => css` - width: ${props.width ?? randomWidth()}; - `} - - ${props => { - switch (props.size) { - case 'large': - return css` - height: 1.5rem; - `; - case 'small': - return css` - height: 0.5rem; - `; - default: - return css` - height: 1rem; - `; - } - }} -`; - -export const SkeletonText: FC<{ size?: 'large' | 'small'; width?: string; className?: string }> = - React.memo(({ size, width, className }) => { - return ; - }); - -export const Skeleton = styled(Base)<{ - width: string; - height: string; - borderRadius?: string; - margin?: string; - marginBottom?: string; -}>` - display: block; - border-radius: ${props => - props.borderRadius - ? props.theme[props.borderRadius] || props.theme.cornerExtraExtraSmall - : props.theme.cornerExtraExtraSmall}; - - ${props => css` - width: ${props.width ?? '3rem'}; - height: ${props.height ?? '20px'}; - ${props.margin && `margin: ${props.margin};`} - ${props.marginBottom && `margin-bottom: ${props.marginBottom};`} - `} -`; - -const Image = styled(Base)<{ width?: string }>` - border-radius: ${props => props.theme.cornerFull}; - - ${props => css` - width: ${props.width ?? '44px'}; - height: ${props.width ?? '44px'}; - `} -`; - -export const SkeletonImage: FC<{ width?: string }> = React.memo(({ width }) => { - return ; -}); +import { SkeletonImage, SkeletonText } from './shared/Skeleton'; +import {randomIntFromInterval} from "../libs/common"; +import {RecommendationsPageBodySkeleton} from "./skeletons/BrowserSkeletons"; export const SkeletonSubHeader = React.memo(() => { return } />; diff --git a/packages/uikit/src/components/home/Balance.tsx b/packages/uikit/src/components/home/Balance.tsx index 380e692f2..73f60ffe4 100644 --- a/packages/uikit/src/components/home/Balance.tsx +++ b/packages/uikit/src/components/home/Balance.tsx @@ -13,7 +13,7 @@ import { formatFiatCurrency } from '../../hooks/balance'; import { useTranslation } from '../../hooks/translation'; import { QueryKey } from '../../libs/queryKey'; import { TokenRate, getRateKey } from '../../state/rates'; -import { SkeletonText } from '../Skeleton'; +import { SkeletonText } from '../shared/Skeleton'; import { Body3, Label2, Num2 } from '../Text'; import { AssetData } from './Jettons'; diff --git a/packages/uikit/src/components/jettons/Info.tsx b/packages/uikit/src/components/jettons/Info.tsx index 5ed61806b..e3d313b3d 100644 --- a/packages/uikit/src/components/jettons/Info.tsx +++ b/packages/uikit/src/components/jettons/Info.tsx @@ -1,6 +1,6 @@ import React, { FC } from 'react'; import styled from 'styled-components'; -import { SkeletonImage, SkeletonText } from '../Skeleton'; +import { SkeletonImage, SkeletonText } from '../shared/Skeleton'; import { H2 } from '../Text'; import { Body } from './CroppedText'; diff --git a/packages/uikit/src/components/shared/Skeleton.tsx b/packages/uikit/src/components/shared/Skeleton.tsx new file mode 100644 index 000000000..a97e4d4a9 --- /dev/null +++ b/packages/uikit/src/components/shared/Skeleton.tsx @@ -0,0 +1,96 @@ +import styled, { css } from 'styled-components'; +import React, { FC } from 'react'; +import { randomIntFromInterval } from '../../libs/common'; + +function randomWidth() { + return randomIntFromInterval(30, 90) + '%'; +} + +const Base = styled.div` + display: inline-block; + + @keyframes placeHolderShimmer { + 0% { + background-position: -800px 0; + } + 100% { + background-position: 800px 0; + } + } + + animation-duration: 2s; + animation-fill-mode: forwards; + animation-iteration-count: infinite; + animation-name: placeHolderShimmer; + animation-timing-function: linear; + background-color: #f6f7f8; + background: linear-gradient(to right, #4f5a70 8%, #bbbbbb 18%, #4f5a70 33%); + background-size: 800px 104px; + + opacity: 0.1; + + position: relative; +`; +const Block = styled(Base)<{ size?: string; width?: string }>` + border-radius: ${props => props.theme.cornerExtraExtraSmall}; + + ${props => css` + width: ${props.width ?? randomWidth()}; + `} + + ${props => { + switch (props.size) { + case 'large': + return css` + height: 1.5rem; + `; + case 'small': + return css` + height: 0.5rem; + `; + default: + return css` + height: 1rem; + `; + } + }} +`; + +export const SkeletonText: FC<{ size?: 'large' | 'small'; width?: string; className?: string }> = + React.memo(({ size, width, className }) => { + return ; + }); + +export const Skeleton = styled(Base)<{ + width: string; + height?: string; + borderRadius?: string; + margin?: string; + marginBottom?: string; +}>` + display: block; + border-radius: ${props => + props.borderRadius + ? props.theme[props.borderRadius] || props.theme.cornerExtraExtraSmall + : props.theme.cornerExtraExtraSmall}; + + ${props => css` + width: ${props.width ?? '3rem'}; + height: ${props.height ?? '20px'}; + ${props.margin && `margin: ${props.margin};`} + ${props.marginBottom && `margin-bottom: ${props.marginBottom};`} + `} +`; + +const Image = styled(Base)<{ width?: string }>` + border-radius: ${props => props.theme.cornerFull}; + + ${props => css` + width: ${props.width ?? '44px'}; + height: ${props.width ?? '44px'}; + `} +`; + +export const SkeletonImage: FC<{ width?: string }> = React.memo(({ width }) => { + return ; +}); diff --git a/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx b/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx index 9338846b0..d41cab06c 100644 --- a/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx +++ b/packages/uikit/src/components/skeletons/BrowserSkeletons.tsx @@ -1,5 +1,5 @@ import { PromotedItem, PromotedItemText } from '../../pages/browser/PromotedItem'; -import { Skeleton } from '../Skeleton'; +import { Skeleton } from '../shared/Skeleton'; import React, { FC } from 'react'; import { ListBlock, ListItem } from '../List'; import styled from 'styled-components'; @@ -16,15 +16,15 @@ const Heading = styled.div` gap: 1rem; `; +const CarouselSkeleton = styled(Skeleton)` + height: auto; + aspect-ratio: 2 / 1; +`; + export const RecommendationsPageBodySkeleton: FC = () => { return ( <> - + diff --git a/packages/uikit/src/libs/common.ts b/packages/uikit/src/libs/common.ts index e3340af96..cae49b453 100644 --- a/packages/uikit/src/libs/common.ts +++ b/packages/uikit/src/libs/common.ts @@ -34,3 +34,7 @@ export function mergeRefs(...inputRefs: (Ref | undefined)[]): Ref | Ref }); }; } + +export function randomIntFromInterval(min: number, max: number) { + return Math.floor(Math.random() * (max - min + 1) + min); +}