From dd81200520cdb533440c1a29a55d410ef95789c6 Mon Sep 17 00:00:00 2001 From: smian1 <smian1@gmail.com> Date: Wed, 4 Dec 2024 00:50:58 -0800 Subject: [PATCH 1/2] Refactor metadata handling and improve API calls in OMI Apps - Introduced envConfig for dynamic URL management across the application. - Updated metadata generation functions to utilize envConfig for URLs, enhancing maintainability and consistency. - Improved API call caching strategy in app-list component for better performance. - Cleaned up metadata schemas to streamline structured data generation for SEO. - Enhanced Open Graph and Twitter card metadata for improved social sharing and visibility. --- frontend/src/app/apps/[id]/page.tsx | 20 ++++-- frontend/src/app/apps/components/app-list.tsx | 4 +- frontend/src/app/apps/page.tsx | 32 ++++----- frontend/src/app/apps/utils/metadata.ts | 68 +++++++------------ .../app/components/product-banner/index.tsx | 6 +- frontend/src/hooks/useLocalStorage.ts | 2 +- frontend/src/lib/api/apps.ts | 36 +++++----- 7 files changed, 80 insertions(+), 88 deletions(-) diff --git a/frontend/src/app/apps/[id]/page.tsx b/frontend/src/app/apps/[id]/page.tsx index c518918e3d..e00b71837e 100644 --- a/frontend/src/app/apps/[id]/page.tsx +++ b/frontend/src/app/apps/[id]/page.tsx @@ -9,6 +9,7 @@ import { Calendar, User, FolderOpen, Puzzle } from 'lucide-react'; import { Metadata, ResolvingMetadata } from 'next'; import { ProductBanner } from '@/src/app/components/product-banner'; import { getAppById, getAppsByCategory } from '@/src/lib/api/apps'; +import envConfig from '@/src/constants/envConfig'; type Props = { params: { id: string }; @@ -28,21 +29,26 @@ export async function generateMetadata( } const categoryName = formatCategoryName(plugin.category); - const canonicalUrl = `https://omi.me/apps/${plugin.id}`; - const appStoreUrl = 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163'; - const playStoreUrl = 'https://play.google.com/store/apps/details?id=com.friend.ios'; + const canonicalUrl = `${envConfig.WEB_URL}/apps/${plugin.id}`; return { title: `${plugin.name} - ${categoryName} App | Omi`, description: `${plugin.description} Available on Omi, the AI-powered wearable platform.`, - metadataBase: new URL('https://omi.me'), + metadataBase: new URL(envConfig.WEB_URL), alternates: { canonical: canonicalUrl, }, openGraph: { title: `${plugin.name} - ${categoryName} App`, description: plugin.description, - images: [plugin.image], + images: [ + { + url: plugin.image, + width: 1200, + height: 630, + alt: `${plugin.name} App for Omi`, + }, + ], url: canonicalUrl, type: 'website', siteName: 'Omi', @@ -52,8 +58,8 @@ export async function generateMetadata( title: `${plugin.name} - ${categoryName} App`, description: plugin.description, images: [plugin.image], - creator: '@omi', - site: '@omi', + creator: '@omiHQ', + site: '@omiHQ', }, other: { 'application-name': 'Omi', diff --git a/frontend/src/app/apps/components/app-list.tsx b/frontend/src/app/apps/components/app-list.tsx index 213cc3bfdc..775b79ff29 100644 --- a/frontend/src/app/apps/components/app-list.tsx +++ b/frontend/src/app/apps/components/app-list.tsx @@ -9,12 +9,12 @@ import { ScrollableCategoryNav } from './scrollable-category-nav'; async function getPluginsData() { const [pluginsResponse, statsResponse] = await Promise.all([ fetch(`${envConfig.API_URL}/v1/approved-apps?include_reviews=true`, { - cache: 'no-store', + next: { revalidate: 3600 }, }), fetch( 'https://raw.githubusercontent.com/BasedHardware/omi/refs/heads/main/community-plugin-stats.json', { - cache: 'no-store', + next: { revalidate: 3600 }, }, ), ]); diff --git a/frontend/src/app/apps/page.tsx b/frontend/src/app/apps/page.tsx index 8bf6a75ad0..7c985f13d0 100644 --- a/frontend/src/app/apps/page.tsx +++ b/frontend/src/app/apps/page.tsx @@ -9,6 +9,7 @@ import { } from './utils/metadata'; import { ProductBanner } from '../components/product-banner'; import { getApprovedApps } from '@/src/lib/api/apps'; +import envConfig from '@/src/constants/envConfig'; async function getAppsCount() { const plugins = await getApprovedApps(); @@ -19,27 +20,28 @@ export async function generateMetadata(): Promise<Metadata> { const appsCount = await getAppsCount(); const title = 'OMI Apps Marketplace - AI-Powered Apps for Your OMI Necklace'; const description = `Discover and install ${appsCount}+ AI-powered apps for your OMI Necklace. Browse apps across productivity, entertainment, health, and more. Transform your OMI experience with voice-controlled applications.`; - const baseMetadata = getBaseMetadata(title, description); return { - ...baseMetadata, + title, + description, + metadataBase: new URL(envConfig.WEB_URL), keywords: 'OMI apps, AI apps, voice control apps, wearable apps, productivity apps, health apps, entertainment apps', alternates: { - canonical: 'https://omi.me/apps', + canonical: `${envConfig.WEB_URL}/apps`, }, openGraph: { title, description, - url: 'https://omi.me/apps', + url: `${envConfig.WEB_URL}/apps`, siteName: 'OMI', images: [ { - url: '/omi-app.png', + url: `${envConfig.WEB_URL}/omi-app.png`, width: 1200, height: 630, alt: 'OMI Apps Marketplace', - } + }, ], locale: 'en_US', type: 'website', @@ -48,7 +50,7 @@ export async function generateMetadata(): Promise<Metadata> { card: 'summary_large_image', title, description, - images: ['/omi-app.png'], + images: [`${envConfig.WEB_URL}/omi-app.png`], creator: '@omiHQ', }, robots: { @@ -59,15 +61,13 @@ export async function generateMetadata(): Promise<Metadata> { follow: true, }, }, - verification: { - other: { - 'structured-data': JSON.stringify([ - generateCollectionPageSchema(title, description, 'https://omi.me/apps'), - generateProductSchema(), - generateOrganizationSchema(), - generateBreadcrumbSchema(), - ]), - }, + other: { + 'structured-data': JSON.stringify([ + generateCollectionPageSchema(), + generateProductSchema(), + generateOrganizationSchema(), + generateBreadcrumbSchema(), + ]), }, }; } diff --git a/frontend/src/app/apps/utils/metadata.ts b/frontend/src/app/apps/utils/metadata.ts index e74a402d3f..321949f49f 100644 --- a/frontend/src/app/apps/utils/metadata.ts +++ b/frontend/src/app/apps/utils/metadata.ts @@ -1,4 +1,5 @@ import { Metadata } from 'next'; +import envConfig from '@/src/constants/envConfig'; export interface CategoryMetadata { title: string; @@ -94,13 +95,13 @@ export function generateBreadcrumbSchema(category?: string) { '@type': 'ListItem', position: 1, name: 'Home', - item: 'https://omi.me', + item: envConfig.WEB_URL, }, { '@type': 'ListItem', position: 2, name: 'Apps', - item: 'https://omi.me/apps', + item: `${envConfig.WEB_URL}/apps`, }, ], }; @@ -110,7 +111,7 @@ export function generateBreadcrumbSchema(category?: string) { '@type': 'ListItem', position: 3, name: categoryMetadata[category]?.title || category, - item: `https://omi.me/apps/category/${category}`, + item: `${envConfig.WEB_URL}/apps/category/${category}`, }); } @@ -123,47 +124,36 @@ export function generateProductSchema() { '@type': 'Product', name: productInfo.name, description: productInfo.description, - brand: { - '@type': 'Brand', - name: 'OMI', - }, + image: `${envConfig.WEB_URL}/omi-app.png`, offers: { '@type': 'Offer', price: productInfo.price, priceCurrency: productInfo.currency, - availability: 'https://schema.org/InStock', url: productInfo.url, + availability: 'https://schema.org/InStock', + }, + brand: { + '@type': 'Brand', + name: 'OMI', }, - additionalProperty: [ - { - '@type': 'PropertyValue', - name: 'App Store', - value: appStoreInfo.ios, - }, - { - '@type': 'PropertyValue', - name: 'Play Store', - value: appStoreInfo.android, - }, - ], }; } export function generateCollectionPageSchema( title: string, description: string, - url: string, + canonicalUrl: string, ) { return { '@context': 'https://schema.org', '@type': 'CollectionPage', - name: title, - description: description, - url: url, + name: 'OMI Apps Marketplace', + description: 'Discover and install AI-powered apps for your OMI Necklace.', + url: `${envConfig.WEB_URL}/apps`, isPartOf: { '@type': 'WebSite', name: 'OMI Apps Marketplace', - url: 'https://omi.me/apps', + url: envConfig.WEB_URL, }, }; } @@ -173,8 +163,13 @@ export function generateOrganizationSchema() { '@context': 'https://schema.org', '@type': 'Organization', name: 'OMI', - url: 'https://omi.me', - sameAs: [appStoreInfo.ios, appStoreInfo.android], + url: envConfig.WEB_URL, + logo: `${envConfig.WEB_URL}/omi-app.png`, + sameAs: [ + 'https://twitter.com/omiHQ', + 'https://www.instagram.com/omi.me/', + 'https://www.linkedin.com/company/omi-me/', + ], }; } @@ -205,23 +200,10 @@ export function getBaseMetadata(title: string, description: string): Metadata { return { title, description, - metadataBase: new URL('https://omi.me'), - openGraph: { - title, - description, - type: 'website', - siteName: 'OMI Apps Marketplace', - }, - twitter: { - card: 'summary_large_image', - title, - description, - creator: '@omi', - site: '@omi', - }, + metadataBase: new URL(envConfig.WEB_URL), other: { - 'apple-itunes-app': `app-id=6502156163`, - 'google-play-app': `app-id=com.friend.ios`, + 'apple-itunes-app': `app-id=${appStoreInfo.ios.split('/id')[1]}`, + 'google-play-app': `app-id=${appStoreInfo.android.split('id=')[1]}`, }, }; } diff --git a/frontend/src/app/components/product-banner/index.tsx b/frontend/src/app/components/product-banner/index.tsx index 9c5b741d9d..1d260ca0c3 100644 --- a/frontend/src/app/components/product-banner/index.tsx +++ b/frontend/src/app/components/product-banner/index.tsx @@ -230,7 +230,11 @@ export function ProductBanner({ </span> <span className="inline-flex items-center gap-1 rounded-full bg-teal-500/10 px-2 py-0.5 text-xs text-teal-300"> <svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"> - <path fillRule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clipRule="evenodd" /> + <path + fillRule="evenodd" + d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" + clipRule="evenodd" + /> </svg> <span className="whitespace-nowrap">Memory</span> </span> diff --git a/frontend/src/hooks/useLocalStorage.ts b/frontend/src/hooks/useLocalStorage.ts index da7f4fa37a..5925e5dc69 100644 --- a/frontend/src/hooks/useLocalStorage.ts +++ b/frontend/src/hooks/useLocalStorage.ts @@ -35,4 +35,4 @@ export function useLocalStorage<T>( }; return [storedValue, setValue]; -} \ No newline at end of file +} diff --git a/frontend/src/lib/api/apps.ts b/frontend/src/lib/api/apps.ts index e0a7ca0b12..3c1cfd4133 100644 --- a/frontend/src/lib/api/apps.ts +++ b/frontend/src/lib/api/apps.ts @@ -1,6 +1,6 @@ -import { cache } from 'react' -import envConfig from '@/src/constants/envConfig' -import { Plugin, PluginStat } from '@/src/app/apps/components/types' +import { cache } from 'react'; +import envConfig from '@/src/constants/envConfig'; +import { Plugin, PluginStat } from '@/src/app/apps/components/types'; /** * Cached fetch utility for approved apps @@ -12,39 +12,39 @@ export const getApprovedApps = cache(async () => { `${envConfig.API_URL}/v1/approved-apps?include_reviews=true`, { next: { revalidate: 21600 }, // 6 hours - } - ) + }, + ); if (!response.ok) { - throw new Error(`Failed to fetch apps: ${response.statusText}`) + throw new Error(`Failed to fetch apps: ${response.statusText}`); } - const plugins = (await response.json()) as Plugin[] - return plugins + const plugins = (await response.json()) as Plugin[]; + return plugins; } catch (error) { - console.error('Error fetching approved apps:', error) - throw error + console.error('Error fetching approved apps:', error); + throw error; } -}) +}); /** * Get a single app by ID using the cached data */ export const getAppById = cache(async (id: string) => { - const plugins = await getApprovedApps() - return plugins.find((p) => p.id === id) -}) + const plugins = await getApprovedApps(); + return plugins.find((p) => p.id === id); +}); /** * Get apps by category using the cached data */ export const getAppsByCategory = cache(async (category: string) => { - const plugins = await getApprovedApps() + const plugins = await getApprovedApps(); return category === 'integration' ? plugins.filter( (plugin) => Array.isArray(plugin.capabilities) && - plugin.capabilities.includes('external_integration') + plugin.capabilities.includes('external_integration'), ) - : plugins.filter((plugin) => plugin.category === category) -}) \ No newline at end of file + : plugins.filter((plugin) => plugin.category === category); +}); From b42d6d58e59caf45685c00bef79864325c58df7d Mon Sep 17 00:00:00 2001 From: smian1 <smian1@gmail.com> Date: Wed, 4 Dec 2024 20:04:15 -0800 Subject: [PATCH 2/2] Update dependencies and enhance app layout - Added `lodash` and its type definitions to package dependencies for improved utility functions. - Introduced a new `SearchBar` component in the app list for enhanced user search experience. - Integrated `GoogleAnalytics` into the layout for better tracking of user interactions. - Added a CSS class `.search-hidden` to manage visibility of search elements. These changes aim to improve functionality and user experience across the application. --- frontend/package-lock.json | 14 +++ frontend/package.json | 2 + frontend/src/app/apps/components/app-list.tsx | 4 + .../apps/components/plugin-card/compact.tsx | 4 + .../apps/components/plugin-card/featured.tsx | 4 + .../app/apps/components/search/search-bar.tsx | 98 +++++++++++++++++++ frontend/src/app/globals.css | 4 + frontend/src/app/layout.tsx | 2 + .../components/shared/google-analytics.tsx | 27 +++++ 9 files changed, 159 insertions(+) create mode 100644 frontend/src/app/apps/components/search/search-bar.tsx create mode 100644 frontend/src/components/shared/google-analytics.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 67e0d0e254..ba7a3cc535 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -19,6 +19,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", + "@types/lodash": "^4.17.13", "algoliasearch": "^5.2.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -26,6 +27,7 @@ "gleap": "^13.9.2", "iconoir-react": "^7.8.0", "instantsearch.css": "^8.5.0", + "lodash": "^4.17.21", "lucide-react": "^0.438.0", "markdown-to-jsx": "^7.5.0", "moment": "^2.30.1", @@ -2152,6 +2154,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.13", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.13.tgz", + "integrity": "sha512-lfx+dftrEZcdBPczf9d0Qv0x+j/rfNCMuC6OcfXmO8gkfeNAY88PgKUbvG56whcN23gc27yenwF6oJZXGFpYxg==", + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.16.1", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.16.1.tgz", @@ -5369,6 +5377,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" + }, "node_modules/lodash.castarray": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 550dcf8709..b248c13e45 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -22,6 +22,7 @@ "@radix-ui/react-tabs": "^1.1.0", "@radix-ui/react-tooltip": "^1.1.4", "@radix-ui/react-visually-hidden": "^1.1.0", + "@types/lodash": "^4.17.13", "algoliasearch": "^5.2.5", "class-variance-authority": "^0.7.0", "clsx": "^2.1.1", @@ -29,6 +30,7 @@ "gleap": "^13.9.2", "iconoir-react": "^7.8.0", "instantsearch.css": "^8.5.0", + "lodash": "^4.17.21", "lucide-react": "^0.438.0", "markdown-to-jsx": "^7.5.0", "moment": "^2.30.1", diff --git a/frontend/src/app/apps/components/app-list.tsx b/frontend/src/app/apps/components/app-list.tsx index 775b79ff29..10bd966b25 100644 --- a/frontend/src/app/apps/components/app-list.tsx +++ b/frontend/src/app/apps/components/app-list.tsx @@ -5,6 +5,7 @@ import { CategoryHeader } from './category-header'; import type { Plugin, PluginStat } from './types'; import { ChevronRight } from 'lucide-react'; import { ScrollableCategoryNav } from './scrollable-category-nav'; +import { SearchBar } from './search/search-bar'; async function getPluginsData() { const [pluginsResponse, statsResponse] = await Promise.all([ @@ -100,6 +101,9 @@ export default async function AppList() { <p className="mt-3 text-gray-400"> Discover our most popular AI-powered applications </p> + <div className="mt-6"> + <SearchBar /> + </div> </div> </div> diff --git a/frontend/src/app/apps/components/plugin-card/compact.tsx b/frontend/src/app/apps/components/plugin-card/compact.tsx index 1279f5a5f9..96fb6be6e2 100644 --- a/frontend/src/app/apps/components/plugin-card/compact.tsx +++ b/frontend/src/app/apps/components/plugin-card/compact.tsx @@ -22,6 +22,10 @@ export function CompactPluginCard({ plugin, index }: CompactPluginCardProps) { <Link href={`/apps/${plugin.id}`} className="flex items-start gap-4 rounded-lg p-2 text-left transition-colors duration-300 hover:bg-[#1A1F2E]/50" + data-plugin-card + data-search-content={`${plugin.name} ${plugin.author} ${plugin.description}`} + data-categories={plugin.category} + data-capabilities={Array.from(plugin.capabilities).join(' ')} > {/* Index number */} <span className="w-6 text-sm font-medium text-gray-400">{index}</span> diff --git a/frontend/src/app/apps/components/plugin-card/featured.tsx b/frontend/src/app/apps/components/plugin-card/featured.tsx index 368dfb033b..2471fe4a43 100644 --- a/frontend/src/app/apps/components/plugin-card/featured.tsx +++ b/frontend/src/app/apps/components/plugin-card/featured.tsx @@ -22,6 +22,10 @@ export function FeaturedPluginCard({ plugin, hideStats }: FeaturedPluginCardProp <Link href={`/apps/${plugin.id}`} className="group relative block overflow-hidden rounded-xl bg-[#1A1F2E]" + data-plugin-card + data-search-content={`${plugin.name} ${plugin.author} ${plugin.description}`} + data-categories={plugin.category} + data-capabilities={Array.from(plugin.capabilities).join(' ')} > {/* Image */} <div className="aspect-[16/9] w-full overflow-hidden"> diff --git a/frontend/src/app/apps/components/search/search-bar.tsx b/frontend/src/app/apps/components/search/search-bar.tsx new file mode 100644 index 0000000000..640b6ae513 --- /dev/null +++ b/frontend/src/app/apps/components/search/search-bar.tsx @@ -0,0 +1,98 @@ +'use client'; + +import { Search, X } from 'lucide-react'; +import { useCallback, useState, useEffect } from 'react'; +import { cn } from '@/src/lib/utils'; +import debounce from 'lodash/debounce'; + +interface SearchBarProps { + className?: string; +} + +export function SearchBar({ className }: SearchBarProps) { + const [searchQuery, setSearchQuery] = useState(''); + const [isFocused, setIsFocused] = useState(false); + + const handleSearch = useCallback((query: string) => { + setSearchQuery(query); + const searchContent = query.toLowerCase().trim(); + const cards = document.querySelectorAll('[data-plugin-card]'); + cards.forEach((card) => { + const content = card.getAttribute('data-search-content')?.toLowerCase() || ''; + const categories = card.getAttribute('data-categories')?.toLowerCase() || ''; + const capabilities = card.getAttribute('data-capabilities')?.toLowerCase() || ''; + if ( + searchContent === '' || + content.includes(searchContent) || + categories.includes(searchContent) || + capabilities.includes(searchContent) + ) { + card.classList.remove('search-hidden'); + } else { + card.classList.add('search-hidden'); + } + }); + + document.querySelectorAll('section').forEach((section) => { + const visibleCards = section.querySelectorAll( + '[data-plugin-card]:not(.search-hidden)', + ); + if (visibleCards.length === 0) { + section.classList.add('search-hidden'); + } else { + section.classList.remove('search-hidden'); + } + }); + }, []); + + const debouncedSearch = useCallback( + debounce((query: string) => handleSearch(query), 150), + [handleSearch], + ); + + const clearSearch = useCallback(() => { + setSearchQuery(''); + handleSearch(''); + }, [handleSearch]); + + useEffect(() => { + return () => { + debouncedSearch.cancel(); + }; + }, [debouncedSearch]); + + return ( + <div className={cn('relative mx-auto w-full max-w-2xl', className)}> + <div className="group relative"> + <Search + className={cn( + 'absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 transition-colors', + isFocused || searchQuery + ? 'text-[#6C8EEF]' + : 'text-gray-400 group-hover:text-[#6C8EEF]', + )} + /> + <input + type="text" + value={searchQuery} + onChange={(e) => { + setSearchQuery(e.target.value); + debouncedSearch(e.target.value); + }} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + placeholder="Search apps, categories, or capabilities..." + className="h-12 w-full rounded-full bg-[#1A1F2E] pl-11 pr-11 text-sm text-white placeholder-gray-400 outline-none ring-1 ring-white/5 transition-all hover:ring-white/10 focus:bg-[#242938] focus:ring-[#6C8EEF]/50" + /> + {searchQuery && ( + <button + onClick={clearSearch} + className="absolute right-4 top-1/2 -translate-y-1/2 text-gray-400 transition-colors hover:text-white" + > + <X className="h-4 w-4" /> + </button> + )} + </div> + </div> + ); +} \ No newline at end of file diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 11f10bf7ee..4b845aacd2 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -29,3 +29,7 @@ animation: gradient-x 15s ease infinite; background-size: 400% 400%; } + +.search-hidden { + display: none !important; +} diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 8e42d3685c..0bdb2bd817 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import AppHeader from '../components/shared/app-header'; import Footer from '../components/shared/footer'; import envConfig from '../constants/envConfig'; import { GleapInit } from '@/src/components/shared/gleap'; +import { GoogleAnalytics } from '@/src/components/shared/google-analytics'; const inter = Mulish({ subsets: ['latin'], @@ -36,6 +37,7 @@ export default function RootLayout({ <Footer /> </body> <GleapInit /> + <GoogleAnalytics /> </html> ); } diff --git a/frontend/src/components/shared/google-analytics.tsx b/frontend/src/components/shared/google-analytics.tsx new file mode 100644 index 0000000000..aadee3a4fc --- /dev/null +++ b/frontend/src/components/shared/google-analytics.tsx @@ -0,0 +1,27 @@ +'use client'; + +import Script from 'next/script'; + +export function GoogleAnalytics() { + if (process.env.NODE_ENV !== 'production') { + return null; + } + + return ( + <> + <Script + src="https://www.googletagmanager.com/gtag/js?id=G-2WSLB4VPWF" + strategy="afterInteractive" + /> + <Script id="google-analytics" strategy="afterInteractive"> + {` + window.dataLayer = window.dataLayer || []; + function gtag(){dataLayer.push(arguments);} + gtag('js', new Date()); + + gtag('config', 'G-2WSLB4VPWF'); + `} + </Script> + </> + ); +} \ No newline at end of file