[CHORE] Make ecosystem 100% SSR. (#759)

* Update ecosystem to full support SSR

* use our fonts
This commit is contained in:
Léo Galley
2024-07-31 12:19:29 -04:00
committed by GitHub
parent 1e9cacd639
commit be1dbde730
9 changed files with 168 additions and 141 deletions

View File

@@ -4,8 +4,10 @@ import { Divider } from 'apps/web/src/components/Divider/Divider';
import { List } from 'apps/web/src/components/Ecosystem/List';
import type { Metadata } from 'next';
import Image from 'next/image';
import ecosystemApps from 'apps/web/src/data/ecosystem.json';
import { TagChip } from 'apps/web/src/components/Ecosystem/TagChip';
import { SearchBar } from 'apps/web/src/components/Ecosystem/SearchBar';
import { Suspense } from 'react';
export const metadata: Metadata = {
metadataBase: new URL('https://base.org'),
title: `Base | About`,
@@ -15,6 +17,42 @@ export const metadata: Metadata = {
},
};
export type EcosystemApp = {
searchName: string;
name: string;
url: string;
description: string;
tags: string[];
imageUrl: string;
};
const tags = [
'all',
...ecosystemApps
.map((app) => app.tags)
.flat()
.filter((value, index, array) => {
return array.indexOf(value) === index;
}),
];
function orderedEcosystemAppsAsc() {
return ecosystemApps.sort((a, b) => {
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
if (b.name.toLowerCase() > a.name.toLowerCase()) {
return -1;
}
return 0;
});
}
const decoratedEcosystemApps: EcosystemApp[] = orderedEcosystemAppsAsc().map((d) => ({
...d,
searchName: d.name.toLowerCase(),
}));
async function EcosystemHero() {
return (
<div className="mt-[-96px] flex w-full flex-col items-center bg-black pb-[96px]">
@@ -44,14 +82,45 @@ async function EcosystemHero() {
);
}
export default async function Ecosystem() {
type EcosystemProps = {
searchParams: { tag?: string; search?: string; showCount: number };
};
export default async function Ecosystem(page: EcosystemProps) {
const selectedTag = page.searchParams.tag ?? tags[0];
const search = page.searchParams.search ?? '';
const showCount = page.searchParams.showCount ? Number(page.searchParams.showCount) : 16;
const filteredEcosystemApps = decoratedEcosystemApps.filter((app) => {
const isTagged = selectedTag === 'all' || app.tags.includes(selectedTag);
const isSearched = search === '' || app.name.toLowerCase().match(search.toLocaleLowerCase());
return isTagged && isSearched;
});
return (
<main className="flex w-full flex-col items-center bg-black">
<EcosystemHero />
<Divider />
<Suspense>
<List />
</Suspense>
<div className="flex min-h-32 w-full max-w-[1440px] flex-col gap-10 px-8 pb-32">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:gap-12">
<div className="flex flex-row flex-wrap gap-3">
{tags.map((tag) => (
<TagChip tag={tag} isSelected={selectedTag === tag} key={tag} />
))}
</div>
<div className="order-first grow lg:order-last">
<Suspense>
<SearchBar value={search} />
</Suspense>
</div>
</div>
<List
selectedTag={selectedTag}
searchText={search}
apps={filteredEcosystemApps}
showCount={showCount}
/>
</div>
</main>
);
}

View File

@@ -1,5 +1,6 @@
'use client';
import '@rainbow-me/rainbowkit/styles.css';
import '@coinbase/onchainkit/styles.css';
import {
Provider as CookieManagerProvider,
Region,
@@ -9,7 +10,6 @@ import {
import { OnchainKitProvider } from '@coinbase/onchainkit';
import { Provider as TooltipProvider } from '@radix-ui/react-tooltip';
import { connectorsForWallets, RainbowKitProvider } from '@rainbow-me/rainbowkit';
import '@rainbow-me/rainbowkit/styles.css';
import {
coinbaseWallet,
metaMaskWallet,

View File

@@ -1,6 +1,7 @@
import '@coinbase/onchainkit/styles.css';
import './global.css';
import AppProviders from 'apps/web/app/AppProviders';
import localFont from 'next/font/local';
import { Footer } from 'apps/web/src/components/Layout/Footer/Footer';
import DatadogInit from 'apps/web/app/datadog';

View File

@@ -84,7 +84,7 @@ export function ConnectWalletButton({
);
}, [openConnectModal]);
const userAddressClasses = classNames('text-lg', {
const userAddressClasses = classNames('text-lg font-display', {
'text-white': color === 'white',
'text-black': color === 'black',
});
@@ -126,21 +126,29 @@ export function ConnectWalletButton({
return (
<Wallet>
<ConnectWallet withWalletAggregator className="bg-transparent p-2 hover:bg-gray-40/20">
<ConnectWallet withWalletAggregator className="bg-transparent p-2 hover:bg-gray-40/20">
<UserAvatar />
<Name chain={basenameChain} className={userAddressClasses} />
</ConnectWallet>
<WalletDropdown>
<Identity className={classNames('px-4 pb-2 pt-3', className)} hasCopyAddressOnClick>
<WalletDropdown className="font-sans">
<Identity
className={classNames('px-4 pb-2 pt-3 font-display', className)}
hasCopyAddressOnClick
>
<UserAvatar />
<Name chain={basenameChain} />
<EthBalance />
<Name chain={basenameChain} className="font-display" />
<EthBalance className="font-display" />
</Identity>
<WalletDropdownBaseName />
<WalletDropdownLink icon="wallet" href="https://wallet.coinbase.com">
<WalletDropdownBaseName className="font-display" />
<WalletDropdownLink
icon="wallet"
href="https://wallet.coinbase.com"
target="_blank"
className="font-display"
>
Go to Wallet Dashboard
</WalletDropdownLink>
<WalletDropdownDisconnect />
<WalletDropdownDisconnect className="font-display" />
</WalletDropdown>
</Wallet>
);

View File

@@ -1,5 +1,5 @@
/* eslint-disable react-perf/jsx-no-new-object-as-prop */
import Image from 'next/image';
'use client';
import ImageWithLoading from 'apps/web/src/components/ImageWithLoading';
type Props = {
name: string;
@@ -13,7 +13,7 @@ function getNiceDomainDisplayFromUrl(url: string) {
return url.replace('https://', '').replace('http://', '').replace('www.', '').split('/')[0];
}
export function Card({ name, url, description, imageUrl, tags }: Props) {
export async function Card({ name, url, description, imageUrl, tags }: Props) {
return (
<a
href={url}
@@ -23,7 +23,13 @@ export function Card({ name, url, description, imageUrl, tags }: Props) {
>
<div className="flex flex-row justify-between">
<div className="relative h-[80px] w-[80px] overflow-hidden rounded-[3px]">
<Image src={imageUrl} fill style={{ objectFit: 'contain' }} alt={`Logo of ${name}`} />
<ImageWithLoading
src={imageUrl}
alt={`Logo of ${name}`}
width={80}
height={80}
backgroundClassName="bg-black"
/>
</div>
<div className="flex h-6 flex-col justify-center rounded-[100px] bg-black px-2 py-1">
<span className="font-mono text-xs uppercase text-white">{tags[0]}</span>

View File

@@ -1,102 +1,33 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import ErrorImg from 'apps/web/public/images/error.png';
import data from 'apps/web/src/data/ecosystem.json';
import Image from 'next/image';
import { Button } from '../Button/Button';
import { Card } from './Card';
import { SearchBar } from './SearchBar';
import { TagChip } from './TagChip';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { EcosystemApp } from 'apps/web/app/(base-org)/ecosystem/page';
import Link from 'next/link';
import { Url } from 'next/dist/shared/lib/router/router';
const TagList = [
'all',
'bridge',
'dao',
'defi',
'gaming',
// note in partnerCsvToEcosystemJson.js we remap infrastructure -> infra so it'll fit in the chip
'infra',
'nft',
'onramp',
'security',
'social',
'wallet',
'x-chain',
];
function orderedDataAsc() {
return data.sort((a, b) => {
if (a.name.toLowerCase() > b.name.toLowerCase()) {
return 1;
}
if (b.name.toLowerCase() > a.name.toLowerCase()) {
return -1;
}
return 0;
});
}
const decoratedData = orderedDataAsc().map((d) => ({
...d,
searchName: d.name.toLowerCase(),
}));
export function List() {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const selectedTag = searchParams?.get('tag') ?? 'all';
const [searchText, setSearchText] = useState('');
const [showNum, setShowNum] = useState(16);
const selectTag = useCallback(
(tag: string) => {
const params = new URLSearchParams();
params.set('tag', tag);
router.push(pathname + '?' + params.toString(), { scroll: false });
},
[pathname, router],
);
const filteredApps = useMemo(
() =>
decoratedData.filter((app) => {
const isTagged = selectedTag === 'all' || app.tags.includes(selectedTag);
const isSearched =
searchText === '' || app.name.toLowerCase().match(searchText.toLocaleLowerCase());
return isTagged && isSearched;
}),
[selectedTag, searchText],
);
const truncatedApps = useMemo(() => filteredApps.slice(0, showNum), [filteredApps, showNum]);
const canShowMore = showNum < filteredApps.length;
const showEmptyState = filteredApps.length === 0;
const showMore = useCallback(() => setShowNum((num) => num + 16), [setShowNum]);
export async function List({
selectedTag,
searchText,
apps,
showCount,
}: {
selectedTag: string;
searchText: string;
apps: EcosystemApp[];
showCount: number;
}) {
const canShowMore = showCount < apps.length;
const showEmptyState = apps.length === 0;
const truncatedApps = apps.slice(0, showCount);
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
const tagHref: Url = {
pathname: '/ecosystem',
query: { tag: selectedTag, search: searchText, showCount: showCount + 16 },
};
return (
<div className="flex min-h-32 w-full max-w-[1440px] flex-col gap-10 px-8 pb-32">
<div className="flex flex-col justify-between gap-8 lg:flex-row lg:gap-12">
<div className="flex flex-row flex-wrap gap-3">
{TagList.map((tag) => (
<TagChip
tag={tag}
isSelected={selectedTag === tag}
setSelectedTag={selectTag}
key={tag}
/>
))}
</div>
<div className="order-first grow lg:order-last">
<SearchBar setSearchText={setSearchText} />
</div>
</div>
<>
<div className="flex flex-col gap-10 lg:grid lg:grid-cols-4">
{truncatedApps.map((app) => (
<Card {...app} key={app.url} />
@@ -112,12 +43,13 @@ export function List() {
<span className="font-sans text-gray-muted">Try searching for another term</span>
</div>
)}
{canShowMore && (
<div className="mt-12 flex justify-center">
<Button onClick={showMore}>VIEW MORE</Button>
<Link href={tagHref} scroll={false}>
<Button>VIEW MORE</Button>
</Link>
</div>
)}
</div>
</>
);
}

View File

@@ -1,9 +1,7 @@
'use client';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useRef, useState } from 'react';
type Props = {
setSearchText: (val: string) => void;
};
function SearchIcon() {
return (
<svg
@@ -42,9 +40,24 @@ function XIcon() {
const DEBOUNCE_LENGTH_MS = 300;
export function SearchBar({ setSearchText }: Props) {
const [text, setText] = useState('');
export function SearchBar({ value }: { value: string }) {
const [text, setText] = useState(value);
const debounced = useRef<number>();
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const tag = searchParams?.get('tag');
const updateRoute = useCallback(
(search: string) => {
const params = new URLSearchParams(searchParams?.toString());
if (tag) params.set('tag', tag);
if (search) params.set('search', search);
if (!search) params.delete('search');
router.push(pathname + '?' + params.toString(), { scroll: false });
},
[pathname, router, searchParams, tag],
);
const onChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
@@ -52,18 +65,18 @@ export function SearchBar({ setSearchText }: Props) {
const val = e.target.value;
setText(val);
updateRoute(val);
debounced.current = window.setTimeout(() => {
setSearchText(val);
}, DEBOUNCE_LENGTH_MS);
debounced.current = window.setTimeout(() => {}, DEBOUNCE_LENGTH_MS);
},
[setText, setSearchText],
[updateRoute],
);
const clearInput = useCallback(() => {
setText('');
setSearchText('');
}, [setSearchText]);
updateRoute('');
}, [updateRoute]);
return (
<div className="flex h-10 flex-row items-center gap-2 rounded-[56px] border border-gray-60 p-2 md:w-full lg:w-80">

View File

@@ -1,22 +1,19 @@
import { useCallback } from 'react';
import { Url } from 'next/dist/shared/lib/router/router';
import Link from 'next/link';
type Props = {
tag: string;
isSelected: boolean;
setSelectedTag: (val: string) => void;
};
export function TagChip({ tag, isSelected, setSelectedTag }: Props) {
const select = useCallback(
(event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
setSelectedTag(tag);
},
[tag, setSelectedTag],
);
export async function TagChip({ tag, isSelected }: Props) {
// eslint-disable-next-line react-perf/jsx-no-new-object-as-prop
const tagHref: Url = {
pathname: '/ecosystem',
query: { tag },
};
return (
<button type="button" onClick={select}>
<Link href={tagHref} scroll={false}>
<div
className={`flex h-10 shrink-0 cursor-pointer flex-col justify-center rounded-[100px] border border-gray-muted px-8 hover:border-white ${
isSelected ? 'bg-gray-muted' : ''
@@ -24,6 +21,6 @@ export function TagChip({ tag, isSelected, setSelectedTag }: Props) {
>
<span className="text-center font-mono text-base uppercase text-white">{tag}</span>
</div>
</button>
</Link>
);
}

View File

@@ -55,6 +55,7 @@ export default function Banner({ href, text, bannerName }: BannerProps) {
onClick={hideBanner}
onKeyDown={hideBanner}
type="button"
aria-label="Close Banner"
>
<Icon name="close" color="black" width="16" height="16" />
</button>