diff --git a/public/index.html b/public/index.html index 9f1fbc5b..42b44967 100644 --- a/public/index.html +++ b/public/index.html @@ -7,21 +7,46 @@ - + + + + + + + + + + + Sysnode | Syscoin Sentry Node Dashboard diff --git a/public/social-card.png b/public/social-card.png new file mode 100644 index 00000000..3140d888 Binary files /dev/null and b/public/social-card.png differ diff --git a/src/components/PageMeta.js b/src/components/PageMeta.js index 246d0fff..bc596382 100644 --- a/src/components/PageMeta.js +++ b/src/components/PageMeta.js @@ -1,9 +1,15 @@ import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; const SITE_NAME = 'Sysnode'; const DEFAULT_TITLE = 'Sysnode | Syscoin Sentry Node Dashboard'; const DEFAULT_DESCRIPTION = - 'Track Syscoin Sentry Node count, locked supply, rewards, governance proposals, setup guidance, and market context in one clean dashboard.'; + 'Live Syscoin Sentry Node stats, governance proposals, rewards, setup guidance, and market data.'; +const SITE_URL = 'https://sysnode.info'; +const SOCIAL_IMAGE_URL = `${SITE_URL}/social-card.png?v=20260518b`; +const SOCIAL_IMAGE_ALT = + 'Sysnode social share card with the Syscoin logo, Sysnode wordmark, and a connected globe network illustration.'; +const TWITTER_HANDLE = '@syscoin'; function ensureMeta(selector, attributeName, attributeValue) { let element = document.head.querySelector(selector); @@ -18,8 +24,10 @@ function ensureMeta(selector, attributeName, attributeValue) { } export default function PageMeta(props) { + const location = useLocation(); const description = props.description || DEFAULT_DESCRIPTION; const fullTitle = props.title ? `${props.title} | ${SITE_NAME}` : DEFAULT_TITLE; + const pageUrl = `${window.location.origin}${location.pathname}${location.search}${location.hash}`; useEffect( function syncDocumentMeta() { @@ -48,12 +56,50 @@ export default function PageMeta(props) { ); ensureMeta('meta[property="og:url"]', 'property', 'og:url').setAttribute( 'content', - window.location.href + pageUrl ); + ensureMeta('meta[property="og:image"]', 'property', 'og:image').setAttribute( + 'content', + SOCIAL_IMAGE_URL + ); + ensureMeta( + 'meta[property="og:image:secure_url"]', + 'property', + 'og:image:secure_url' + ).setAttribute('content', SOCIAL_IMAGE_URL); + ensureMeta( + 'meta[property="og:image:type"]', + 'property', + 'og:image:type' + ).setAttribute('content', 'image/png'); + ensureMeta( + 'meta[property="og:image:width"]', + 'property', + 'og:image:width' + ).setAttribute('content', '1200'); + ensureMeta( + 'meta[property="og:image:height"]', + 'property', + 'og:image:height' + ).setAttribute('content', '630'); + ensureMeta( + 'meta[property="og:image:alt"]', + 'property', + 'og:image:alt' + ).setAttribute('content', SOCIAL_IMAGE_ALT); ensureMeta('meta[name="twitter:card"]', 'name', 'twitter:card').setAttribute( 'content', - 'summary' + 'summary_large_image' ); + ensureMeta('meta[name="twitter:site"]', 'name', 'twitter:site').setAttribute( + 'content', + TWITTER_HANDLE + ); + ensureMeta( + 'meta[name="twitter:creator"]', + 'name', + 'twitter:creator' + ).setAttribute('content', TWITTER_HANDLE); ensureMeta('meta[name="twitter:title"]', 'name', 'twitter:title').setAttribute( 'content', fullTitle @@ -63,8 +109,17 @@ export default function PageMeta(props) { 'name', 'twitter:description' ).setAttribute('content', description); + ensureMeta('meta[name="twitter:image"]', 'name', 'twitter:image').setAttribute( + 'content', + SOCIAL_IMAGE_URL + ); + ensureMeta( + 'meta[name="twitter:image:alt"]', + 'name', + 'twitter:image:alt' + ).setAttribute('content', SOCIAL_IMAGE_ALT); }, - [description, fullTitle] + [description, fullTitle, pageUrl] ); return null; diff --git a/src/components/PageMeta.test.js b/src/components/PageMeta.test.js new file mode 100644 index 00000000..298c90a4 --- /dev/null +++ b/src/components/PageMeta.test.js @@ -0,0 +1,52 @@ +import React, { act } from 'react'; +import { render, waitFor } from '@testing-library/react'; +import { Router } from 'react-router-dom'; +import { createMemoryHistory } from 'history'; + +import PageMeta from './PageMeta'; + +afterEach(() => { + document.title = ''; + document.head + .querySelectorAll( + 'meta[name="description"], meta[property^="og:"], meta[name^="twitter:"], link[rel="canonical"]' + ) + .forEach((element) => { + element.remove(); + }); +}); + +test('updates og:url when only the location changes', async () => { + const staticOgUrl = document.createElement('meta'); + staticOgUrl.setAttribute('property', 'og:url'); + staticOgUrl.setAttribute('content', 'https://sysnode.info/'); + document.head.appendChild(staticOgUrl); + + const history = createMemoryHistory({ + initialEntries: ['/verify-email?token=alpha'], + }); + + render( + + + + ); + + expect(document.head.querySelector('meta[property="og:url"]')).toHaveAttribute( + 'content', + 'http://localhost/verify-email?token=alpha' + ); + expect(document.head.querySelector('link[rel="canonical"]')).toBeNull(); + + await act(async () => { + history.push('/verify-email?token=beta'); + }); + + await waitFor(() => + expect(document.head.querySelector('meta[property="og:url"]')).toHaveAttribute( + 'content', + 'http://localhost/verify-email?token=beta' + ) + ); + expect(document.head.querySelector('link[rel="canonical"]')).toBeNull(); +}); diff --git a/src/pages/Home.js b/src/pages/Home.js index 356faae7..a777c888 100644 --- a/src/pages/Home.js +++ b/src/pages/Home.js @@ -112,7 +112,7 @@ export default function Home() {