Skip to content
49 changes: 48 additions & 1 deletion app/[locale]/wallets/find-wallet/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import {
setRequestLocale,
} from "next-intl/server"

import type { Lang, PageParams } from "@/lib/types"
import type { Lang, PageParams, WalletData } from "@/lib/types"

import Breadcrumbs from "@/components/Breadcrumbs"
import FindWalletProductTable from "@/components/FindWalletProductTable/lazy"
import I18nProvider from "@/components/I18nProvider"
import ListingMethodology from "@/components/ListingMethodology"
import MainArticle from "@/components/MainArticle"
import { UnorderedList } from "@/components/ui/list"

import { getAppPageContributorInfo } from "@/lib/utils/contributors"
import { formatDate } from "@/lib/utils/date"
import { getMetadata } from "@/lib/utils/metadata"
import { getRequiredNamespacesForPage } from "@/lib/utils/translations"
import {
Expand Down Expand Up @@ -43,6 +46,16 @@ const Page = async (props: { params: Promise<PageParams> }) => {
),
}))

const mostRecentWalletUpdate = walletsData
.map((wallet: WalletData) => wallet.last_updated)
.filter((d) => d.length > 0)
.sort()
.at(-1)

const lastUpdatedDisplay = mostRecentWalletUpdate
? formatDate(mostRecentWalletUpdate, locale)
: ""

// Get i18n messages
const allMessages = await getMessages({ locale })
const requiredNamespaces = getRequiredNamespacesForPage(
Expand Down Expand Up @@ -76,6 +89,40 @@ const Page = async (props: { params: Promise<PageParams> }) => {
</div>

<FindWalletProductTable wallets={wallets} />

<ListingMethodology
heading={t("page-find-wallet-methodology-title")}
description={t("page-find-wallet-methodology-intro")}
lastUpdated={lastUpdatedDisplay}
href="/contributing/adding-wallets/"
footers={[
t("page-find-wallet-footnote-1"),
t("page-find-wallet-footnote-2"),
]}
>
<p>{t("page-find-wallet-methodology-must-haves-label")}</p>

<UnorderedList className="space-y-2">
{[
"security",
"track-record",
"maintenance",
"honest-info",
"contact",
"eip1559",
"ux",
"ethereum-focused",
].map((key) => (
<li key={key}>
{t(`page-find-wallet-methodology-criterion-${key}`)}
</li>
))}
</UnorderedList>

<p>{t("page-find-wallet-methodology-verification")}</p>

<p>{t("page-find-wallet-methodology-filters")}</p>
</ListingMethodology>
</MainArticle>
</I18nProvider>
</>
Expand Down
13 changes: 11 additions & 2 deletions src/components/ExpandableCard.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client"

import React, { type ReactNode, useState } from "react"
import type { AccordionContentProps } from "@radix-ui/react-accordion"

import { Flex, HStack, VStack } from "@/components/ui/flex"

Expand All @@ -26,7 +27,7 @@ export type ExpandableCardProps = {
eventName?: string
visible?: boolean
className?: string
}
} & Pick<AccordionContentProps, "forceMount">

const ExpandableCard = ({
children,
Expand All @@ -38,6 +39,7 @@ const ExpandableCard = ({
eventName = "",
visible = false,
className,
forceMount,
}: ExpandableCardProps) => {
const [isVisible, setIsVisible] = useState(visible)
const { t } = useTranslation("common")
Expand Down Expand Up @@ -91,7 +93,14 @@ const ExpandableCard = ({
</span>
</Flex>
</AccordionTrigger>
<AccordionContent className="p-6 pt-0 md:p-6 md:pt-0">
<AccordionContent
forceMount={forceMount}
className={cn(
"p-6 pt-0 md:p-6 md:pt-0",
forceMount &&
"in-data-[state=closed]:hidden in-data-[state=closed]:h-0"
)}
>
<div className="border-t pt-6 text-md text-body">{children}</div>
</AccordionContent>
</AccordionItem>
Expand Down
78 changes: 78 additions & 0 deletions src/components/ListingMethodology/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { ReactNode } from "react"
import { getTranslations } from "next-intl/server"

import { BaseLink } from "@/components/ui/Link"
import { Section } from "@/components/ui/section"

import ExpandableCard from "../ExpandableCard"

type ListingMethodologyProps = {
heading: string
description: string
href?: string // Full criteria link
lastUpdated: string
children: ReactNode
footers?: string[]
}

const ListingMethodology = async ({
heading,
description,
href,
lastUpdated,
children,
footers,
}: ListingMethodologyProps) => {
const t = await getTranslations("component-listing-methodology")

return (
<Section
id="listing-methodology"
aria-labelledby="methodology-heading"
className="mt-12 border-t border-body-light pt-12 md:mt-16 md:pt-16"
>
<div className="flex w-full flex-col gap-6 px-4 pb-16 md:w-2/3 lg:w-3/5">
<h2 id="methodology-heading" className="text-3xl font-bold md:text-4xl">
{heading}
</h2>

<p className="text-lg leading-relaxed text-body-medium">
{description}
</p>

{href && (
<BaseLink href={href}>{t("full-criteria-link-label")}</BaseLink>
)}

<div className="flex flex-col gap-1 text-base text-body-medium">
<p>
<strong>{t("attribution")}</strong>
</p>
<p>
{t("last-update")} {lastUpdated}
</p>
</div>

<ExpandableCard
title={t("details-title")}
contentPreview={t("details-preview")}
forceMount
>
<div className="space-y-4 text-lg leading-relaxed">
{children}

{footers && (
<div className="mt-6 space-y-2 border-t border-body-light pt-6 text-sm text-body-medium">
{footers.map((footer) => (
<p key={footer}>{footer}</p>
))}
</div>
)}
</div>
</ExpandableCard>
</div>
</Section>
)
}

export default ListingMethodology
7 changes: 7 additions & 0 deletions src/intl/en/component-listing-methodology.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"attribution": "Curated by the ethereum.org editorial team.",
"details-preview": "Must-have requirements, re-verification policy, and disclaimers",
"details-title": "See listing criteria and policies",
"full-criteria-link-label": "Read the full listing criteria and removal policy",
"last-update": "Most recent listing update:"
}
15 changes: 14 additions & 1 deletion src/intl/en/page-wallets-find-wallet.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,5 +87,18 @@
"page-find-wallet-privacy": "Privacy",
"page-find-wallet-privacy-desc": "Wallets that support built-in private transactions",
"page-find-wallet-see-wallets": "See wallets",
"page-find-wallet-search-languages": "Search languages..."
"page-find-wallet-search-languages": "Search languages...",
"page-find-wallet-methodology-title": "How we evaluate wallets",
"page-find-wallet-methodology-intro": "Every wallet on this page is reviewed by the ethereum.org team before being listed. We apply a published set of criteria focused on security, self-custody, and Ethereum-native support so users can navigate the ecosystem with greater confidence.",
"page-find-wallet-methodology-must-haves-label": "To be listed, a wallet must meet the following requirements:",
"page-find-wallet-methodology-criterion-security": "Security-tested through audit, an internal security team, or open-source code review.",
"page-find-wallet-methodology-criterion-track-record": "Been live for at least six months, or built by a team with an established track record.",
"page-find-wallet-methodology-criterion-maintenance": "Actively maintained, with support available for users.",
"page-find-wallet-methodology-criterion-honest-info": "Provides honest, accurate listing information. Products that falsify details are removed.",
"page-find-wallet-methodology-criterion-contact": "Has a named point of contact so we can verify information when it changes.",
"page-find-wallet-methodology-criterion-eip1559": "Supports EIP-1559 (type 2) transactions on Ethereum Mainnet.",
"page-find-wallet-methodology-criterion-ux": "Offers a reviewable user experience. If our team finds a product difficult to use, we may request improvements before listing it.",
"page-find-wallet-methodology-criterion-ethereum-focused": "Is Ethereum-focused, with Ethereum or a Layer 2 set as the default network.",
"page-find-wallet-methodology-verification": "Listings are not static. Wallet providers are required to resubmit information every six months. If a team does not respond, we remove the wallet. This keeps the directory accurate as products evolve.",
"page-find-wallet-methodology-filters": "Filter toggles on this page (open source, self-custody, hardware wallet support, and others) reflect attributes tracked per wallet. Each listing also shows the date its information was last verified."
}
5 changes: 4 additions & 1 deletion src/scripts/intl-pipeline/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,10 @@ async function main() {
// so a rerun of just the failed combinations naturally retries them without
// touching the work that landed this run.
if (failures.length > 0) {
log(`${failures.length} task(s) failed (continuing with successes):`, "warn")
log(
`${failures.length} task(s) failed (continuing with successes):`,
"warn"
)
for (const f of failures) {
log(` [${f.locale}] ${f.file}: ${f.message}`, "warn")
}
Expand Down
Loading