Files
zeekayandClaude Opus 5.5 80bd5d7c6a Zoo's explorer counts no Lux chains and links no private repo
The Zoo stats panel counted the Lux primary network's chains, a list its
explorer does not show; the chain count now appears only on the Lux
explorer. /ai takes the primary-network guard like /bridge and /dex. Zoo's
source repo is private, so its footer prints the version as text rather
than a link every visitor gets as a 404. A white-label host has no source
repo either, so its branding carries an empty sourceUrl instead of none,
which printed "undefined/tree/<version>".

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-09-23 20:11:57 -07:00

235 lines
8.4 KiB
TypeScript

import { Skeleton } from '@luxfi/ui/skeleton';
import { useQuery } from '@tanstack/react-query';
import React from 'react';
import type { CustomLinksGroup } from 'types/footerLinks';
import config from 'configs/app';
import { getCurrentChain } from 'configs/app/chainRegistry';
import type { ResourceError } from 'lib/api/resources';
import useApiQuery from 'lib/api/useApiQuery';
import useFetch from 'lib/hooks/useFetch';
import { Link } from 'toolkit/next/link';
import { copy } from 'toolkit/utils/htmlEntities';
import { CONTENT_MAX_WIDTH } from 'ui/shared/layout/utils';
import NetworkAddToWallet from 'ui/shared/NetworkAddToWallet';
import FooterLinkItem from './FooterLinkItem';
import IntTxsIndexingStatus from './IntTxsIndexingStatus';
import getApiVersionUrl from './utils/getApiVersionUrl';
const MAX_LINKS_COLUMNS = 4;
const Footer = () => {
const { data: backendVersionData } = useApiQuery('general:config_backend_version', {
queryOptions: {
staleTime: Infinity,
enabled: !config.features.multichain.isEnabled,
refetchOnMount: false,
},
});
const apiVersionUrl = getApiVersionUrl(backendVersionData?.backend_version);
const currentChain = getCurrentChain();
const branding = currentChain.branding;
const FOOTER_LINKS = [
{
icon: 'social/git' as const,
iconSize: '20px',
text: 'Contribute',
url: branding.githubUrl,
},
{
icon: 'social/twitter' as const,
iconSize: '24px',
text: 'X (Twitter)',
url: branding.twitterUrl,
},
{
icon: 'social/discord' as const,
iconSize: '24px',
text: 'Discord',
url: branding.discordUrl,
},
{
icon: 'globe' as const,
iconSize: '18px',
text: branding.brandName,
url: branding.websiteUrl,
},
{
icon: 'docs' as const,
iconSize: '20px',
text: 'llms.txt',
url: `${ config.app.baseUrl }/llms.txt`,
},
].filter(Boolean);
const frontendLink = (() => {
const version = config.UI.footer.frontendVersion || config.UI.footer.frontendCommit;
if (version && !branding.sourceUrl) {
return <span>{ version }</span>;
}
if (config.UI.footer.frontendVersion) {
const href = `${ branding.sourceUrl }/tree/${ config.UI.footer.frontendVersion }`;
return <Link href={ href } external noIcon>{ config.UI.footer.frontendVersion }</Link>;
}
if (config.UI.footer.frontendCommit) {
const href = `${ branding.sourceUrl }/commit/${ config.UI.footer.frontendCommit }`;
return <Link href={ href } external noIcon>{ config.UI.footer.frontendCommit }</Link>;
}
return null;
})();
const fetch = useFetch();
const { isPlaceholderData, data: linksData } = useQuery<unknown, ResourceError<unknown>, Array<CustomLinksGroup>>({
queryKey: [ 'footer-links' ],
queryFn: async() => fetch(config.UI.footer.links || '', undefined, { resource: 'footer-links' }),
enabled: Boolean(config.UI.footer.links),
staleTime: Infinity,
placeholderData: [],
});
const colNum = isPlaceholderData ? 1 : Math.min(linksData?.length || Infinity, MAX_LINKS_COLUMNS) + 1;
const renderNetworkInfo = React.useCallback(() => {
return (
<div className="flex items-center flex-wrap justify-start gap-x-3 gap-y-2 mb-5 lg:mb-10 empty:hidden">
{ !config.UI.indexingAlert.intTxs.isHidden && <IntTxsIndexingStatus/> }
{ !config.features.multichain.isEnabled && <NetworkAddToWallet source="Footer"/> }
</div>
);
}, []);
const renderProjectInfo = React.useCallback(() => {
return (
<div>
<Link href={ branding.websiteUrl } external noIcon className="inline-flex" style={{ color: 'inherit' }}>
<span className="font-bold text-lg">{ branding.brandName }</span>
</Link>
<p className="mt-3 text-xs">
{ branding.description }
</p>
<div className="mt-6 text-xs">
{ apiVersionUrl && (
<p>
Backend: <Link href={ apiVersionUrl } external noIcon>{ backendVersionData?.backend_version }</Link>
</p>
) }
{ frontendLink && (
<p>
Frontend: { frontendLink }
</p>
) }
<p>
{ copy } { (new Date()).getFullYear() } { branding.orgName }
</p>
</div>
</div>
);
}, [ apiVersionUrl, backendVersionData?.backend_version, frontendLink, branding ]);
const renderRecaptcha = () => {
if (!config.services.reCaptchaV2.siteKey) {
return <div/>;
}
return (
<div className="text-xs mt-6">
<span>This site is protected by reCAPTCHA and the Google </span>
<Link href="https://policies.google.com/privacy" external noIcon>Privacy Policy</Link>
<span> and </span>
<Link href="https://policies.google.com/terms" external noIcon>Terms of Service</Link>
<span> apply.</span>
</div>
);
};
const horizontalPadding = config.UI.navigation.layout === 'horizontal' ? 'lg:px-6' : 'lg:px-12';
// mobile: single column so the network description + link groups wrap inside
// the viewport instead of forcing a 470px column past the 390px edge.
const footerGridCols = 'grid grid-cols-1 lg:grid-cols-[minmax(auto,470px)_1fr]';
// the project info block is the one surface that takes its text colour by
// inheritance, and the enclosing theme wrapper hands it white — unreadable on
// the light --color-bg-primary. Name the token, like every other surface does.
const footerClassName = 'border-t border-[var(--color-border-divider)] text-[var(--color-text-secondary)]';
if (config.UI.footer.links) {
return (
<footer className={ footerClassName }>
<div
className={ `${ footerGridCols } px-4 ${ horizontalPadding } 2xl:px-6 py-4 lg:py-8 mx-auto gap-x-8 lg:gap-x-[100px]` }
style={{ maxWidth: `${ CONTENT_MAX_WIDTH }px` }}
>
<div className="min-w-0">
{ renderNetworkInfo() }
{ renderProjectInfo() }
{ renderRecaptcha() }
</div>
<div
// mobile: 2 flexible columns that fit the viewport; desktop: the
// measured number of fixed 160px columns, right-aligned.
className="grid grid-cols-2 lg:grid-cols-[repeat(var(--footer-cols),160px)] justify-start lg:justify-end gap-2 lg:gap-8 xl:gap-12 mt-8 lg:mt-0"
style={{ '--footer-cols': colNum } as React.CSSProperties}
>
{
([
{ title: currentChain.branding.brandName, links: FOOTER_LINKS },
...(linksData || []),
])
.slice(0, colNum)
.map(linkGroup => (
<div key={ linkGroup.title }>
<Skeleton className="font-medium mb-3 inline-block" loading={ isPlaceholderData }>{ linkGroup.title }</Skeleton>
<div className="flex flex-col gap-1 items-start">
{ linkGroup.links.map(link => <FooterLinkItem { ...link } key={ link.text } isLoading={ isPlaceholderData }/>) }
</div>
</div>
))
}
</div>
</div>
</footer>
);
}
return (
<footer className={ footerClassName }>
<div
className={ `${ footerGridCols } lg:gap-x-[100px] px-4 ${ horizontalPadding } 2xl:px-6 py-4 lg:py-8 mx-auto` }
style={{ maxWidth: `${ CONTENT_MAX_WIDTH }px` }}
>
{ /* the grid has two columns, so the left one takes one child: without
this wrapper the three blocks fill both columns and the links land
in the 470px track, where five 160px columns do not fit. */ }
<div className="min-w-0">
{ renderNetworkInfo() }
{ renderProjectInfo() }
{ renderRecaptcha() }
</div>
<div
// desktop flows one 160px column per link, so the two mobile columns
// have to be dropped: kept, they stay the only explicit tracks and
// collapse to zero width, which is what stacked Contribute on
// X (Twitter) on Discord in the bottom-left corner.
className={ 'grid grid-cols-2 lg:grid-cols-none lg:grid-flow-col lg:auto-cols-[160px] ' +
'content-start justify-start lg:justify-end gap-1 mt-8 lg:mt-0' }
>
{ FOOTER_LINKS.map(link => <FooterLinkItem { ...link } key={ link.text }/>) }
</div>
</div>
</footer>
);
};
export default React.memo(Footer);