diff --git a/.env.example b/.env.example index d1cc890a81..2f1838e829 100644 --- a/.env.example +++ b/.env.example @@ -82,6 +82,6 @@ export NEXT_PUBLIC_ONESIGNAL_APP_ID= export NEXT_PUBLIC_SAFARI_WEB_ID= export NEXT_PUBLIC_ONESIGNAL_WEBHOOK= -# rewards-screen ragdoll easter egg (triple-click "Rewards" title). +# PeanutRagdoll mascot on the 404, maintenance and waitlist-jail screens. # On by default; set to `false` to disable + tree-shake the chunk out. export NEXT_PUBLIC_RAGDOLL_ENABLED= diff --git a/public/llms-full.txt b/public/llms-full.txt index 14b3362856..9b03d0593b 100644 --- a/public/llms-full.txt +++ b/public/llms-full.txt @@ -71,7 +71,7 @@ Tourists, digital nomads, expats, remote workers, freelancers, and families movi ## Company - Website: https://peanut.me -- Twitter/X: https://twitter.com/PeanutProtocol +- Twitter/X: https://x.com/joinpeanut - GitHub: https://github.com/peanutprotocol - LinkedIn: https://www.linkedin.com/company/peanut-trade/ - Careers: https://peanut.me/careers diff --git a/redirects.json b/redirects.json index f29571fec6..c7af07c0e1 100644 --- a/redirects.json +++ b/redirects.json @@ -141,6 +141,16 @@ "destination": "/", "permanent": true }, + { + "source": "/lp/card", + "destination": "/shhhhh", + "permanent": true + }, + { + "source": "/exchange", + "destination": "/", + "permanent": true + }, { "source": "/sh", "destination": "/card", @@ -206,6 +216,16 @@ "destination": "/:locale/deposit/via-spei", "permanent": true }, + { + "source": "/:locale/blog", + "destination": "/:locale/content", + "permanent": true + }, + { + "source": "/:locale/team", + "destination": "/:locale/press", + "permanent": true + }, { "source": "/:locale(en|es-419|es-ar|pt-br)/:path+/", "destination": "/:locale/:path+", diff --git a/scripts/generate-valid-links.ts b/scripts/generate-valid-links.ts index 82911450c0..156b71b7be 100644 --- a/scripts/generate-valid-links.ts +++ b/scripts/generate-valid-links.ts @@ -59,7 +59,7 @@ function main() { // Static pages lines.push('## Static Pages') lines.push('') - for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/lp/card']) { + for (const p of ['/', '/careers', '/privacy', '/terms']) { lines.push(`- \`${p}\``) } lines.push('') diff --git a/scripts/ping-indexnow.ts b/scripts/ping-indexnow.ts index f0d0e0c1bd..df309bf90e 100644 --- a/scripts/ping-indexnow.ts +++ b/scripts/ping-indexnow.ts @@ -27,7 +27,7 @@ async function getAllPaths(): Promise { const { SUPPORTED_LOCALES } = await import('../src/i18n/types') const { listContentSlugs } = await import('../src/lib/content') - const paths: string[] = ['/', '/lp/card', '/careers', '/exchange', '/privacy', '/terms'] + const paths: string[] = ['/', '/shhhhh', '/careers', '/privacy', '/terms'] for (const locale of SUPPORTED_LOCALES) { for (const country of Object.keys(COUNTRIES_SEO)) { diff --git a/scripts/verify-content.ts b/scripts/verify-content.ts index c45997ffce..a816a4c77a 100644 --- a/scripts/verify-content.ts +++ b/scripts/verify-content.ts @@ -157,7 +157,7 @@ function discoverRoutes(): Set { const routes = new Set() // Static pages - for (const p of ['/', '/careers', '/exchange', '/privacy', '/terms', '/lp/card']) { + for (const p of ['/', '/careers', '/privacy', '/terms']) { routes.add(p) } @@ -732,7 +732,7 @@ function expectedSitemapUrls(): string[] { urls.push(`/${locale}/supported-networks`) urls.push(`/${locale}/pricing`) urls.push(`/${locale}/content`) - urls.push(`/${locale}/blog`) + // No /{locale}/blog index — it is a 308 to /{locale}/content. Posts stay. for (const slug of blogSlugs) urls.push(`/${locale}/blog/${slug}`) } diff --git a/src/app/[...recipient]/page.tsx b/src/app/[...recipient]/page.tsx index 9d90ea983c..f95293eb66 100644 --- a/src/app/[...recipient]/page.tsx +++ b/src/app/[...recipient]/page.tsx @@ -186,8 +186,8 @@ export async function generateMetadata({ params, searchParams }: PageProps) { }, twitter: { card: 'summary_large_image', - site: '@PeanutProtocol', - creator: '@PeanutProtocol', + site: '@joinpeanut', + creator: '@joinpeanut', title, description, images: [ diff --git a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx index 37bcc3e7dc..b829d05d7a 100644 --- a/src/app/[locale]/(marketing)/blog/[slug]/page.tsx +++ b/src/app/[locale]/(marketing)/blog/[slug]/page.tsx @@ -87,9 +87,13 @@ export default async function BlogPostPageLocalized({ params }: PageProps) { } : null + // The standalone /{locale}/blog index is gone (308 to the content hub), so the + // parent crumb is the hub filtered to blog — same shape as compare/ and use-cases/. + const hubHref = `/${locale}/content?type=blog` + const breadcrumbs = [ { name: i18n.home, href: `/${locale}` }, - { name: i18n.blog, href: `/${locale}/blog` }, + { name: i18n.filterBlog, href: hubHref }, { name: post.frontmatter.title, href: `/${locale}/blog/${slug}` }, ] @@ -116,8 +120,8 @@ export default async function BlogPostPageLocalized({ params }: PageProps) { {faqSchema && } -} - -export async function generateStaticParams() { - return SUPPORTED_LOCALES.map((locale) => ({ locale })) -} -export const dynamicParams = false - -export async function generateMetadata({ params }: PageProps): Promise { - const { locale } = await params - if (!isValidLocale(locale)) return {} - - const i18n = getTranslations(locale as Locale) - - return { - ...metadataHelper({ - locale, - title: `${i18n.blog} | Peanut`, - description: i18n.allArticles, - canonical: `/${locale}/blog`, - }), - alternates: { - canonical: `/${locale}/blog`, - languages: getAlternates('blog'), - }, - } -} - -function BlogSkeleton() { - return ( -
-
-
- {[1, 2, 3, 4].map((j) => ( -
-
-
-
- ))} -
-
- ) -} - -export default async function BlogIndexPageLocalized({ params }: PageProps) { - const { locale } = await params - if (!isValidLocale(locale)) notFound() - - const typedLocale = locale as Locale - const i18n = getTranslations(typedLocale) - const items = listAllContent(typedLocale) - - return ( - - - }> - - - - ) -} diff --git a/src/app/[locale]/(marketing)/content/page.tsx b/src/app/[locale]/(marketing)/content/page.tsx index 94bc9c9493..2888004515 100644 --- a/src/app/[locale]/(marketing)/content/page.tsx +++ b/src/app/[locale]/(marketing)/content/page.tsx @@ -5,10 +5,10 @@ import { generateMetadata as metadataHelper } from '@/app/metadata' import { SUPPORTED_LOCALES, getAlternates, isValidLocale } from '@/i18n/config' import { getTranslations } from '@/i18n' import type { Locale } from '@/i18n/types' -import { listAllContent } from '@/lib/content' +import { listAllContent, type ContentItem } from '@/lib/content' import { ContentPage } from '@/components/Marketing/ContentPage' import { Hero } from '@/components/Marketing/mdx/Hero' -import ContentLanding from '@/components/Marketing/ContentLanding' +import ContentLanding, { ContentLinkList, type ContentLandingStrings } from '@/components/Marketing/ContentLanding' interface PageProps { params: Promise<{ locale: string }> @@ -39,26 +39,20 @@ export async function generateMetadata({ params }: PageProps): Promise } } -function LandingSkeleton() { +/** + * Suspense fallback — and the crawlable version of this page. ContentLanding reads the URL + * through nuqs, so on a statically generated route Next prerenders this fallback in its place; + * whatever renders here is what search engines get. Serve the real link list, and skeleton only + * the search box that genuinely needs the client. + */ +function LandingFallback({ items, strings }: { items: ContentItem[]; strings: ContentLandingStrings }) { return ( -
-
-
- {[1, 2, 3].map((i) => ( -
-
-
- {[1, 2, 3].map((j) => ( -
-
-
-
- ))} -
-
- ))} + <> +
+
-
+ + ) } @@ -70,6 +64,16 @@ export default async function ContentHubPage({ params }: PageProps) { const i18n = getTranslations(typedLocale) const items = listAllContent(typedLocale) + const strings: ContentLandingStrings = { + searchPlaceholder: i18n.contentSearchPlaceholder, + noResults: i18n.noContentResults, + filterAll: i18n.filterAll, + filterBlog: i18n.filterBlog, + filterStories: i18n.filterStories, + filterUseCases: i18n.filterUseCases, + filterCompare: i18n.filterCompare, + } + return ( - }> - + }> + ) diff --git a/src/app/[locale]/(marketing)/press/page.tsx b/src/app/[locale]/(marketing)/press/page.tsx index 9b6cd0ccb0..23e2c6a30d 100644 --- a/src/app/[locale]/(marketing)/press/page.tsx +++ b/src/app/[locale]/(marketing)/press/page.tsx @@ -18,7 +18,8 @@ import { // Press kit data lives in mono at content/press/{lang}.md frontmatter — singleton // content authored by marketing/leadership, shipped via the mirror. Team member -// data is reused as-is from content/team/{lang}.md (one fact, one place). +// data comes from content/team/{lang}.md (one fact, one place); this page is the +// only surface for it since /team was removed. // // brand_assets/team_photos hrefs are author-supplied frontmatter that can be // pushed straight to mono main without code review — only emit http(s) URLs @@ -114,6 +115,15 @@ export default async function PressPage({ params }: PageProps) { name: 'Peanut', url: 'https://peanut.me', description: fm.boilerplate?.press, + member: members.map((member) => { + const linkedin = safeHttpUrl(member.social?.linkedin) + return { + '@type': 'Person', + name: member.name, + jobTitle: member.role, + ...(linkedin ? { sameAs: [linkedin] } : {}), + } + }), } return ( @@ -126,7 +136,7 @@ export default async function PressPage({ params }: PageProps) {
{fm.boilerplate && (
-

Company description

+

{i18n.pressCompanyDescription}

{fm.boilerplate.short && ( @@ -152,7 +162,7 @@ export default async function PressPage({ params }: PageProps) { {fm.tagline && (
-

Tagline & headlines

+

{i18n.pressTaglineHeadlines}

{fm.tagline}

{fm.secondary_line &&

{fm.secondary_line}

} @@ -173,7 +183,7 @@ export default async function PressPage({ params }: PageProps) { {fm.brand_assets && fm.brand_assets.length > 0 && (
-

Brand assets

+

{i18n.pressBrandAssets}

{fm.brand_assets.map((group) => ( @@ -205,7 +215,7 @@ export default async function PressPage({ params }: PageProps) { {members.length > 0 && (
-

Team

+

{i18n.pressTeam}

{members.map((member) => ( @@ -242,7 +252,7 @@ export default async function PressPage({ params }: PageProps) { {fm.company_facts && fm.company_facts.length > 0 && (
-

Company

+

{i18n.pressCompany}

{fm.company_facts.map((fact) => (

{fact} @@ -253,7 +263,7 @@ export default async function PressPage({ params }: PageProps) { {fm.media_contact && (

-

Media contact

+

{i18n.pressMediaContact}

{fm.media_contact} diff --git a/src/app/[locale]/(marketing)/stories/page.tsx b/src/app/[locale]/(marketing)/stories/page.tsx index 39f1cc0e4b..61deac2d5f 100644 --- a/src/app/[locale]/(marketing)/stories/page.tsx +++ b/src/app/[locale]/(marketing)/stories/page.tsx @@ -44,6 +44,7 @@ export default async function StoriesIndexPage({ params }: PageProps) { const stories = slugs .map((slug) => { + if (slug === 'index') return null // legacy stories/index/ directory const content = readPageContentLocalized('stories', slug, locale) if (!content || content.frontmatter.published === false) return null return { diff --git a/src/app/[locale]/(marketing)/team/page.tsx b/src/app/[locale]/(marketing)/team/page.tsx deleted file mode 100644 index f4105536eb..0000000000 --- a/src/app/[locale]/(marketing)/team/page.tsx +++ /dev/null @@ -1,164 +0,0 @@ -import { notFound } from 'next/navigation' -import { type Metadata } from 'next' -import Image from 'next/image' -import { generateMetadata as metadataHelper } from '@/app/metadata' -import { MarketingHero } from '@/components/Marketing/MarketingHero' -import { MarketingShell } from '@/components/Marketing/MarketingShell' -import { JsonLd } from '@/components/Marketing/JsonLd' -import { Card } from '@/components/0_Bruddle/Card' -import { SUPPORTED_LOCALES, getAlternatesFor, isValidLocale } from '@/i18n/config' -import type { Locale } from '@/i18n/types' -import { getTranslations } from '@/i18n' -import { - availableSingletonLocales, - readSingletonContentLocalized, - singletonLocaleFor, - type TeamFrontmatter, -} from '@/lib/content' - -// Team data lives in mono at content/team/{lang}.md frontmatter — singleton -// content authored by marketing/leadership, shipped via the mirror. - -// `members` is authored frontmatter — only emit http(s) URLs so a `javascript:` -// or `data:` value can't reach an href or a JSON-LD `sameAs`. -function safeHttpUrl(url: string | undefined): string | undefined { - if (!url) return undefined - try { - const { protocol } = new URL(url) - return protocol === 'https:' || protocol === 'http:' ? url : undefined - } catch { - return undefined - } -} - -interface PageProps { - params: Promise<{ locale: string }> -} - -export async function generateStaticParams() { - if (process.env.NODE_ENV === 'production') return [] - return SUPPORTED_LOCALES.map((locale) => ({ locale })) -} - -export async function generateMetadata({ params }: PageProps): Promise { - const { locale } = await params - if (!isValidLocale(locale)) return {} - - const i18n = getTranslations(locale as Locale) - - // Member bios come from the en-only content singleton, so a non-en URL - // canonicalizes to the locale that owns the prose. - const contentLocale = singletonLocaleFor('team', locale) - - return { - ...metadataHelper({ - locale, - title: `${i18n.teamTitle} | Peanut`, - description: i18n.teamSubtitle, - canonical: `/${contentLocale}/team`, - }), - alternates: { - canonical: `/${contentLocale}/team`, - languages: getAlternatesFor(availableSingletonLocales('team'), 'team'), - }, - } -} - -export default async function TeamPage({ params }: PageProps) { - const { locale } = await params - if (!isValidLocale(locale)) notFound() - - const i18n = getTranslations(locale as Locale) - const team = readSingletonContentLocalized('team', locale) - const members = team?.frontmatter.members ?? [] - - const orgSchema = { - '@context': 'https://schema.org', - '@type': 'Organization', - name: 'Peanut', - url: 'https://peanut.me', - member: members.map((m) => { - const linkedin = safeHttpUrl(m.social?.linkedin) - return { - '@type': 'Person', - name: m.name, - jobTitle: m.role, - ...(linkedin ? { sameAs: [linkedin] } : {}), - } - }), - } - - return ( - <> - - - - - -
- {members.map((member) => { - const linkedin = safeHttpUrl(member.social?.linkedin) - const twitter = safeHttpUrl(member.social?.twitter) - const github = safeHttpUrl(member.social?.github) - return ( - - {member.image ? ( - {member.name} - ) : ( -
- {member.name.charAt(0)} -
- )} -
-

{member.name}

-

{member.role}

-
-

{member.bio}

- {(linkedin || twitter || github) && ( -
- {linkedin && ( - - LinkedIn - - )} - {twitter && ( - - X / Twitter - - )} - {github && ( - - GitHub - - )} -
- )} -
- ) - })} -
-
- - ) -} diff --git a/src/app/careers/page.tsx b/src/app/careers/page.tsx index 8fd06ba278..9396bedc21 100644 --- a/src/app/careers/page.tsx +++ b/src/app/careers/page.tsx @@ -1,11 +1,12 @@ import { generateMetadata } from '@/app/metadata' -import { Careers } from '@/components/Jobs' +import { Careers } from '@/components/Jobs/Careers' +import Footer from '@/components/LandingPage/Footer' export const metadata = generateMetadata({ - title: 'Careers | Join Peanut - Instant Global P2P Payments', + title: 'Careers | Work at Peanut', description: - 'Explore career opportunities at Peanut. Join our team to build the future of fast, global peer-to-peer payments with digital dollars.', - keywords: 'careers, jobs, employment, Peanut careers, P2P payments jobs, fintech jobs, crypto jobs, tech jobs', + 'Open roles at Peanut, the money app for people who cross borders — send and receive money globally, spend with the Peanut Card, and cash in and out through local rails.', + keywords: 'careers, jobs, remote jobs, Peanut careers, fintech jobs, stablecoin jobs, growth jobs', // Without this the page inherits the root layout's `canonical: '/'` and // declares the homepage as its canonical while sitting in the sitemap. canonical: '/careers', @@ -13,8 +14,13 @@ export const metadata = generateMetadata({ export default function CareersPage() { return ( -
- -
+ // /careers lives outside [locale], so it never inherits the marketing + // layout's footer — render it here explicitly. +
+
+ +
+
+
) } diff --git a/src/app/crisp-proxy/layout.tsx b/src/app/crisp-proxy/layout.tsx new file mode 100644 index 0000000000..dbf6545170 --- /dev/null +++ b/src/app/crisp-proxy/layout.tsx @@ -0,0 +1,19 @@ +import { type Metadata } from 'next' +import { generateMetadata as metadataHelper } from '@/app/metadata' + +// The page itself is a client component, so its metadata has to live here. +// Without it the route inherits the root layout's `canonical: '/'` and declares +// the homepage as its canonical while serving `index, follow` — on a route +// robots.ts already disallows. +export const metadata: Metadata = { + ...metadataHelper({ + title: 'Support Chat | Peanut', + description: 'Peanut support chat, embedded in the app.', + canonical: '/crisp-proxy', + }), + robots: { index: false, follow: false }, +} + +export default function CrispProxyLayout({ children }: { children: React.ReactNode }) { + return children +} diff --git a/src/app/exchange/layout.tsx b/src/app/exchange/layout.tsx deleted file mode 100644 index 7771d1196b..0000000000 --- a/src/app/exchange/layout.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { type Metadata } from 'next' -import { generateMetadata as metadataHelper } from '@/app/metadata' - -// Without this the page inherits the root layout's `canonical: '/'` and -// declares the homepage as its canonical while sitting in the sitemap. -export const metadata: Metadata = metadataHelper({ - title: 'Live Exchange Rates | Peanut', - description: 'Compare the real exchange rate with what banks and transfer apps actually charge.', - canonical: '/exchange', -}) - -export default function ExchangeLayout({ children }: { children: React.ReactNode }) { - return children -} diff --git a/src/app/exchange/page.tsx b/src/app/exchange/page.tsx deleted file mode 100644 index 53fd3c0348..0000000000 --- a/src/app/exchange/page.tsx +++ /dev/null @@ -1,19 +0,0 @@ -'use client' - -import Layout from '@/components/Global/Layout' -import { NoFees } from '@/components/LandingPage' -import Footer from '@/components/LandingPage/Footer' -import { EN_LANDING_STRINGS } from '@/components/LandingPage/landingStrings' -import { DEFAULT_LOCALE } from '@/i18n/types' -export default function ExchangePage() { - return ( - - -