+
-
-
{plugin.name}
-
by {plugin.author}
-
- {/* Capability Pills */}
-
- {Array.from(plugin.capabilities).map((cap) => {
- const formattedCap = formatCapabilityName(cap);
- const Icon = getCapabilityIcon(formattedCap);
- return (
-
-
- {formattedCap}
-
- );
- })}
-
-
-
- {plugin.description}
-
+
+ {/* Content Column - 2 columns */}
+
+
+ {/* App Info Container */}
+
+
{plugin.name}
+
by {plugin.author}
- {/* Stats */}
-
-
-
-
- {(plugin.rating_avg ?? 0).toFixed(1)} ({plugin.rating_count})
-
+ {/* Stats Section */}
+
+
+
+ {plugin.rating_avg?.toFixed(1)}
+
+
+ ★
+
+ ({plugin.rating_count} reviews)
+
+
+
+
+
+
+ {plugin.installs.toLocaleString()}
+
+ downloads
+
-
•
-
-
-
- {plugin.installs.toLocaleString()} installs
-
+
+ {/* Action Button */}
+
+
+ {/* Store Buttons */}
+
+
+
+
- {/* Action Button */}
-
-
+ {/* Product Banner */}
+
+
+ {/* About Section */}
+
+ About
+
+
+ {plugin.description}
+
+
+
+
+ {/* Additional Details Section */}
+
+ Additional Details
+
+
+
+
+ {formatDate(plugin.created_at)}
+
+
+
+
+
+
+
+ {Array.from(plugin.capabilities).map((cap) => (
+
+ {cap}
+
+ ))}
-
-
+
- {/* Related Apps Section */}
-
-
- More {categoryName} Apps
-
-
- {relatedApps.map((app, index) => (
-
- ))}
-
+ {/* Related Apps Section */}
+
+
+ More {categoryName} Apps
+
+
+ {relatedApps.map((app, index) => (
+ s.id === app.id)}
+ index={index + 1}
+ />
+ ))}
+
+
-
+
);
}
diff --git a/frontend/src/app/apps/category/[category]/page.tsx b/frontend/src/app/apps/category/[category]/page.tsx
index 954e4292a..fa7c4afdd 100644
--- a/frontend/src/app/apps/category/[category]/page.tsx
+++ b/frontend/src/app/apps/category/[category]/page.tsx
@@ -1,8 +1,20 @@
import envConfig from '@/src/constants/envConfig';
import { CompactPluginCard } from '../../components/plugin-card/compact';
-import { CategoryNav } from '../../components/category-nav';
+import { FeaturedPluginCard } from '../../components/plugin-card/featured';
+import { ScrollableCategoryNav } from '../../components/scrollable-category-nav';
+import { CategoryBreadcrumb } from '../../components/category-breadcrumb';
+import { CategoryHeader } from '../../components/category-header';
import type { Plugin, PluginStat } from '../../components/types';
-import { getCategoryDisplay } from '../../utils/category';
+import { Metadata } from 'next';
+import {
+ categoryMetadata,
+ getBaseMetadata,
+ generateProductSchema,
+ generateCollectionPageSchema,
+ generateBreadcrumbSchema,
+ generateAppListSchema,
+} from '../../utils/metadata';
+import { ProductBanner } from '@/src/app/components/product-banner';
interface CategoryPageProps {
params: {
@@ -10,15 +22,15 @@ interface CategoryPageProps {
};
}
-async function getPluginsData() {
+async function getCategoryData(category: string) {
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 },
},
),
]);
@@ -26,86 +38,158 @@ async function getPluginsData() {
const plugins = (await pluginsResponse.json()) as Plugin[];
const stats = (await statsResponse.json()) as PluginStat[];
- return { plugins, stats };
+ const categoryPlugins =
+ category === 'integration'
+ ? plugins.filter(
+ (plugin) =>
+ Array.isArray(plugin.capabilities) &&
+ plugin.capabilities.includes('external_integration'),
+ )
+ : plugins.filter((plugin) => plugin.category === category);
+
+ return { categoryPlugins, stats };
+}
+
+export async function generateMetadata({ params }: CategoryPageProps): Promise
{
+ const { category } = params;
+ const { categoryPlugins } = await getCategoryData(category);
+ const metadata = categoryMetadata[category];
+
+ if (!metadata) {
+ return {
+ title: 'Category Not Found - OMI Apps',
+ description: 'The requested category could not be found.',
+ };
+ }
+
+ const title = `${metadata.title} - OMI Apps Marketplace`;
+ const description = `${metadata.description} Browse ${categoryPlugins.length}+ ${category} apps for your OMI Necklace.`;
+
+ const baseMetadata = getBaseMetadata(title, description);
+ const canonicalUrl = `https://omi.me/apps/category/${category}`;
+
+ return {
+ ...baseMetadata,
+ keywords: metadata.keywords.join(', '),
+ alternates: {
+ canonical: canonicalUrl,
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ },
+ },
+ verification: {
+ other: {
+ 'structured-data': JSON.stringify([
+ generateCollectionPageSchema(title, description, canonicalUrl),
+ generateProductSchema(),
+ generateBreadcrumbSchema(category),
+ generateAppListSchema(categoryPlugins),
+ ]),
+ },
+ },
+ };
+}
+
+// Helper for Fisher-Yates shuffle
+function shuffleArray(array: T[]): T[] {
+ const newArray = [...array];
+ for (let i = newArray.length - 1; i > 0; i--) {
+ const j = Math.floor(Math.random() * (i + 1));
+ [newArray[i], newArray[j]] = [newArray[j], newArray[i]];
+ }
+ return newArray;
+}
+
+// Get new or recent apps
+function getNewOrRecentApps(apps: Plugin[]): Plugin[] {
+ // First try zero downloads
+ const zeroDownloads = apps.filter((plugin) => plugin.installs === 0);
+
+ if (zeroDownloads.length >= 4) {
+ // If we have enough zero download apps, shuffle and take 4
+ return shuffleArray(zeroDownloads).slice(0, 4);
+ } else {
+ // If not enough zero downloads, get the lowest download count apps
+ return shuffleArray([...apps].sort((a, b) => a.installs - b.installs).slice(0, 4));
+ }
}
export default async function CategoryPage({ params }: CategoryPageProps) {
- const { plugins, stats } = await getPluginsData();
-
- // Filter plugins for this category
- const categoryPlugins = plugins.filter((plugin) => plugin.category === params.category);
-
- // Sort plugins by different criteria
- const mostPopular = [...categoryPlugins]
- .sort((a, b) => b.installs - a.installs)
- .slice(0, 9);
-
- const highestRated = [...categoryPlugins]
- .sort((a, b) => (b.rating_avg || 0) - (a.rating_avg || 0))
- .slice(0, 9);
-
- // For now, use remaining plugins for "Most Recent" section
- const mostRecent = [...categoryPlugins]
- .filter((plugin) => !mostPopular.includes(plugin) && !highestRated.includes(plugin))
- .slice(0, 9);
-
- // Group remaining plugins by category for nav
- const groupedPlugins = plugins.reduce((acc, plugin) => {
- const category = plugin.category;
- if (!acc[category]) {
- acc[category] = [];
- }
- acc[category].push(plugin);
- return acc;
- }, {} as Record);
+ const { categoryPlugins, stats } = await getCategoryData(params.category);
+
+ // Get new/recent apps
+ const newOrRecentApps = getNewOrRecentApps(categoryPlugins);
+
+ // Get most popular apps (if we have enough)
+ const mostPopular =
+ categoryPlugins.length > 6
+ ? [...categoryPlugins].sort((a, b) => b.installs - a.installs).slice(0, 6)
+ : [];
+
+ // Get all apps sorted by installs
+ const allApps = [...categoryPlugins].sort((a, b) => b.installs - a.installs);
return (
-
+
{/* Fixed Header and Navigation */}
-
Omi App Store
-
- Discover our most popular AI-powered applications
-
+
+
+
+
-
-
({
- name,
- count: plugins.length,
- }))}
- />
+
{/* Main Content */}
-
-
+
+
- {/* Category Header */}
-
-
- {getCategoryDisplay(params.category)}
-
- ({categoryPlugins.length})
-
-
-
-
-
- {/* Most Popular Section */}
+
+ {/* New/Recent This Week Section */}
+
+
+ {newOrRecentApps.some((p) => p.installs === 0)
+ ? 'New This Week'
+ : 'Recently Added'}
+
+
+ {newOrRecentApps.map((plugin) => (
+ s.id === plugin.id)}
+ />
+ ))}
+
+
+
+ {/* Most Popular Section - Only show if we have enough apps */}
{mostPopular.length > 0 && (
Most Popular
-
+
{mostPopular.map((plugin, index) => (
)}
- {/* Highest Rated Section */}
- {highestRated.length > 0 && (
-
- Highest Rated
-
- {highestRated.map((plugin, index) => (
- s.id === plugin.id)}
- index={index + 1}
- />
- ))}
-
-
- )}
-
- {/* Most Recent Section */}
- {mostRecent.length > 0 && (
-
- Most Recent
-
- {mostRecent.map((plugin, index) => (
- s.id === plugin.id)}
- index={index + 1}
- />
- ))}
-
-
- )}
+ {/* All Apps Section */}
+
+ All Apps
+
+ {allApps.map((plugin, index) => (
+ s.id === plugin.id)}
+ index={index + 1}
+ />
+ ))}
+
+
diff --git a/frontend/src/app/apps/components/app-action-button/index.tsx b/frontend/src/app/apps/components/app-action-button/index.tsx
new file mode 100644
index 000000000..399aa9444
--- /dev/null
+++ b/frontend/src/app/apps/components/app-action-button/index.tsx
@@ -0,0 +1,26 @@
+/* eslint-disable prettier/prettier */
+import { ArrowRight } from 'lucide-react';
+import { Button } from '@/src/components/ui/button';
+import Link from 'next/link';
+
+interface AppActionButtonProps {
+ link: string;
+ className?: string;
+}
+
+export function AppActionButton({ link, className = '' }: AppActionButtonProps) {
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/apps/components/app-header/index.tsx b/frontend/src/app/apps/components/app-header/index.tsx
new file mode 100644
index 000000000..9c84c2d2b
--- /dev/null
+++ b/frontend/src/app/apps/components/app-header/index.tsx
@@ -0,0 +1,103 @@
+/* eslint-disable prettier/prettier */
+import { Brain, Cpu, Bell, Plug2, MessageSquare, Info } from 'lucide-react';
+import { cn } from '@/src/lib/utils';
+import type { Plugin } from '../types';
+
+interface AppHeaderProps {
+ plugin: Plugin;
+}
+
+const getCapabilityColor = (capability: string): string => {
+ const colors: Record
= {
+ 'ai-powered': 'bg-indigo-500/15 text-indigo-300',
+ memories: 'bg-rose-500/15 text-rose-300',
+ notification: 'bg-emerald-500/15 text-emerald-300',
+ integration: 'bg-sky-500/15 text-sky-300',
+ chat: 'bg-violet-500/15 text-violet-300',
+ };
+ return colors[capability.toLowerCase()] ?? 'bg-gray-700/20 text-gray-300';
+};
+
+const formatCapabilityName = (capability: string): string => {
+ const nameMap: Record = {
+ memories: 'memories',
+ external_integration: 'integration',
+ proactive_notification: 'notification',
+ chat: 'chat',
+ };
+ return nameMap[capability.toLowerCase()] ?? capability;
+};
+
+const getCapabilityIcon = (capability: string) => {
+ const icons: Record = {
+ 'ai-powered': Brain,
+ memories: Cpu,
+ notification: Bell,
+ integration: Plug2,
+ chat: MessageSquare,
+ };
+ return icons[capability.toLowerCase()] ?? Info;
+};
+
+export function AppHeader({ plugin }: AppHeaderProps) {
+ return (
+
+
+
+
+
+
+
+
{plugin.name}
+
by {plugin.author}
+
+ {/* Capability Pills */}
+
+ {Array.from(plugin.capabilities).map((cap) => {
+ const formattedCap = formatCapabilityName(cap);
+ const Icon = getCapabilityIcon(formattedCap);
+ return (
+
+
+ {formattedCap}
+
+ );
+ })}
+
+
+ {/* Store Buttons */}
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/apps/components/app-list.tsx b/frontend/src/app/apps/components/app-list.tsx
index a4cce6a75..10507857e 100644
--- a/frontend/src/app/apps/components/app-list.tsx
+++ b/frontend/src/app/apps/components/app-list.tsx
@@ -1,10 +1,10 @@
import envConfig from '@/src/constants/envConfig';
import { FeaturedPluginCard } from './plugin-card/featured';
import { CompactPluginCard } from './plugin-card/compact';
-import { CategoryNav } from './category-nav';
import { CategoryHeader } from './category-header';
import type { Plugin, PluginStat } from './types';
import { ChevronRight } from 'lucide-react';
+import { ScrollableCategoryNav } from './scrollable-category-nav';
async function getPluginsData() {
const [pluginsResponse, statsResponse] = await Promise.all([
@@ -19,9 +19,31 @@ async function getPluginsData() {
),
]);
- const plugins = (await pluginsResponse.json()) as Plugin[];
- const stats = (await statsResponse.json()) as PluginStat[];
+ const rawPlugins = await pluginsResponse.json();
+ if (process.env.NODE_ENV === 'development') {
+ console.log('Sample raw plugin data:', {
+ name: rawPlugins[0]?.name,
+ created_at: rawPlugins[0]?.created_at,
+ });
+ }
+
+ const plugins = rawPlugins.map((plugin: any) => {
+ const { created_at, capabilities, ...rest } = plugin;
+ return {
+ ...rest,
+ created_at,
+ capabilities: new Set(capabilities),
+ };
+ }) as Plugin[];
+
+ if (process.env.NODE_ENV === 'development') {
+ console.log('Sample transformed plugin:', {
+ name: plugins[0]?.name,
+ created_at: plugins[0]?.created_at,
+ });
+ }
+ const stats = (await statsResponse.json()) as PluginStat[];
return { plugins, stats };
}
@@ -40,6 +62,16 @@ export default async function AppList() {
// Sort plugins by different criteria
const mostPopular = [...plugins].sort((a, b) => b.installs - a.installs).slice(0, 9);
+ // Get integration apps
+ const integrationApps = [...plugins]
+ .filter((plugin) => plugin.capabilities.has('external_integration'))
+ .sort((a, b) => b.installs - a.installs)
+ .slice(0, 9);
+
+ const totalIntegrationApps = plugins.filter((plugin) =>
+ plugin.capabilities.has('external_integration'),
+ ).length;
+
// Group plugins by category and sort by installs
const groupedPlugins = plugins.reduce((acc, plugin) => {
const category = plugin.category;
@@ -61,7 +93,7 @@ export default async function AppList() {
return (
{/* Fixed Header and Navigation */}
-
+
Omi App Store
@@ -74,22 +106,17 @@ export default async function AppList() {
- ({
- name,
- count: plugins.length,
- }))}
- />
+
{/* Main Content */}
-
+
-
+
{/* New This Week Section */}
@@ -123,11 +150,39 @@ export default async function AppList() {
+ {/* Integration Apps Section */}
+ {integrationApps.length > 0 && (
+
+
+
Integration Apps
+ {totalIntegrationApps > 9 && (
+
+ See all
+
+
+ )}
+
+
+ {integrationApps.map((plugin, index) => (
+ s.id === plugin.id)}
+ index={index + 1}
+ />
+ ))}
+
+
+ )}
+
{/* Category Sections */}
{Object.entries(sortedCategories).map(([category, plugins]) => (
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/apps/components/category-breadcrumb/index.tsx b/frontend/src/app/apps/components/category-breadcrumb/index.tsx
new file mode 100644
index 000000000..643910c1c
--- /dev/null
+++ b/frontend/src/app/apps/components/category-breadcrumb/index.tsx
@@ -0,0 +1,31 @@
+/* eslint-disable prettier/prettier */
+'use client';
+
+import Link from 'next/link';
+import { ChevronRight } from 'lucide-react';
+import { getCategoryIcon, getCategoryMetadata } from '../../utils/category';
+
+interface CategoryBreadcrumbProps {
+ category: string;
+}
+
+export function CategoryBreadcrumb({ category }: CategoryBreadcrumbProps) {
+ const metadata = getCategoryMetadata(category);
+ const Icon = getCategoryIcon(category);
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/apps/components/category-header/index.tsx b/frontend/src/app/apps/components/category-header/index.tsx
index 4e7334e24..516a6624b 100644
--- a/frontend/src/app/apps/components/category-header/index.tsx
+++ b/frontend/src/app/apps/components/category-header/index.tsx
@@ -1,17 +1,32 @@
'use client';
-import { getCategoryDisplay } from '../../utils/category';
+import { getCategoryMetadata } from '../../utils/category';
interface CategoryHeaderProps {
category: string;
- pluginCount: number;
+ totalApps: number;
}
-export function CategoryHeader({ category, pluginCount }: CategoryHeaderProps) {
+export function CategoryHeader({ category, totalApps }: CategoryHeaderProps) {
+ const metadata = getCategoryMetadata(category);
+ const Icon = metadata.icon;
+
return (
-
- {getCategoryDisplay(category)}
- ({pluginCount})
-
+
+
+
+
+
+
+
+ {metadata.displayName}
+
+ ({totalApps})
+
+
+
{metadata.description}
+
+
+
);
}
diff --git a/frontend/src/app/apps/components/new-badge.tsx b/frontend/src/app/apps/components/new-badge.tsx
new file mode 100644
index 000000000..c547cee5a
--- /dev/null
+++ b/frontend/src/app/apps/components/new-badge.tsx
@@ -0,0 +1,46 @@
+/* eslint-disable prettier/prettier */
+'use client';
+
+import { type Plugin } from './types';
+
+// Utility function to check if an app is new (within 7 days)
+export function isNewApp(plugin: Plugin): boolean {
+ if (!plugin.created_at) return false;
+ try {
+ const creationDate = new Date(plugin.created_at);
+ const now = new Date();
+ // Validate the date
+ if (isNaN(creationDate.getTime())) return false;
+ const diffInDays = Math.floor(
+ (now.getTime() - creationDate.getTime()) / (1000 * 60 * 60 * 24)
+ );
+ // Add debug logging in development
+ if (process.env.NODE_ENV === 'development') {
+ console.log('Plugin:', plugin.name);
+ console.log('Creation date:', plugin.created_at);
+ console.log('Diff in days:', diffInDays);
+ }
+
+ return diffInDays <= 7;
+ } catch (e) {
+ console.error('Error parsing date for plugin:', plugin.name, e);
+ return false;
+ }
+}
+
+interface NewBadgeProps {
+ plugin: Plugin;
+ className?: string;
+}
+
+export function NewBadge({ plugin, className = '' }: NewBadgeProps) {
+ if (!isNewApp(plugin)) return null;
+
+ return (
+
+ NEW
+
+ );
+}
diff --git a/frontend/src/app/apps/components/plugin-card/compact.tsx b/frontend/src/app/apps/components/plugin-card/compact.tsx
index 30318e305..1279f5a5f 100644
--- a/frontend/src/app/apps/components/plugin-card/compact.tsx
+++ b/frontend/src/app/apps/components/plugin-card/compact.tsx
@@ -3,6 +3,7 @@
import { Star, Download } from 'lucide-react';
import Link from 'next/link';
import type { Plugin, PluginStat } from '../types';
+import { NewBadge } from '../new-badge';
export interface CompactPluginCardProps {
plugin: Plugin;
@@ -34,8 +35,11 @@ export function CompactPluginCard({ plugin, index }: CompactPluginCardProps) {
{/* Content */}
- {/* Title */}
-
{plugin.name}
+ {/* Title and NEW badge */}
+
+
{plugin.name}
+
+
{/* Author and Stats Row */}
diff --git a/frontend/src/app/apps/components/plugin-card/featured.tsx b/frontend/src/app/apps/components/plugin-card/featured.tsx
index 9f5b18b2e..368dfb033 100644
--- a/frontend/src/app/apps/components/plugin-card/featured.tsx
+++ b/frontend/src/app/apps/components/plugin-card/featured.tsx
@@ -3,6 +3,7 @@
import { Star, Download } from 'lucide-react';
import Link from 'next/link';
import type { Plugin, PluginStat } from '../types';
+import { NewBadge } from '../new-badge';
export interface FeaturedPluginCardProps {
plugin: Plugin;
@@ -33,10 +34,13 @@ export function FeaturedPluginCard({ plugin, hideStats }: FeaturedPluginCardProp
{/* Content */}
- {/* Title */}
-
- {plugin.name}
-
+ {/* Title and NEW badge */}
+
+
+ {plugin.name}
+
+
+
{/* Author Row */}
diff --git a/frontend/src/app/apps/components/scrollable-category-nav/index.tsx b/frontend/src/app/apps/components/scrollable-category-nav/index.tsx
new file mode 100644
index 000000000..1f508d803
--- /dev/null
+++ b/frontend/src/app/apps/components/scrollable-category-nav/index.tsx
@@ -0,0 +1,91 @@
+/* eslint-disable prettier/prettier */
+'use client';
+
+import Link from 'next/link';
+import { useRef, useEffect } from 'react';
+import { ChevronLeft, ChevronRight } from 'lucide-react';
+import { categoryMetadata, type CategoryMetadata } from '../../utils/category';
+
+interface ScrollableCategoryNavProps {
+ currentCategory: string;
+}
+
+export function ScrollableCategoryNav({ currentCategory }: ScrollableCategoryNavProps) {
+ const scrollContainerRef = useRef
(null);
+ const categories = Object.values(categoryMetadata);
+
+ // Scroll to active category on mount
+ useEffect(() => {
+ const container = scrollContainerRef.current;
+ const activeItem = container?.querySelector('[data-active="true"]');
+
+ if (container && activeItem) {
+ const containerWidth = container.offsetWidth;
+ const itemLeft = (activeItem as HTMLElement).offsetLeft;
+ const itemWidth = (activeItem as HTMLElement).offsetWidth;
+
+ // Center the active item
+ container.scrollLeft = itemLeft - containerWidth / 2 + itemWidth / 2;
+ }
+ }, [currentCategory]);
+
+ const scroll = (direction: 'left' | 'right') => {
+ const container = scrollContainerRef.current;
+ if (!container) return;
+
+ const scrollAmount = container.offsetWidth * 0.8;
+ const targetScroll = container.scrollLeft + (direction === 'left' ? -scrollAmount : scrollAmount);
+
+ container.scrollTo({
+ left: targetScroll,
+ behavior: 'smooth'
+ });
+ };
+
+ return (
+
+ {/* Left scroll button */}
+
+
+ {/* Scrollable container */}
+
+ {categories.map((category) => (
+
+
+
+ {category.displayName}
+
+
+ ))}
+
+
+ {/* Right scroll button */}
+
+
+ );
+}
\ No newline at end of file
diff --git a/frontend/src/app/apps/components/types.ts b/frontend/src/app/apps/components/types.ts
index 40d00c318..28914b9e8 100644
--- a/frontend/src/app/apps/components/types.ts
+++ b/frontend/src/app/apps/components/types.ts
@@ -17,6 +17,7 @@ export interface Plugin {
rating_avg: number;
rating_count: number;
capabilities: Set;
+ created_at: string;
}
export interface PluginStat {
diff --git a/frontend/src/app/apps/page.tsx b/frontend/src/app/apps/page.tsx
index 41c15228b..5dfe4ddc3 100644
--- a/frontend/src/app/apps/page.tsx
+++ b/frontend/src/app/apps/page.tsx
@@ -1,15 +1,64 @@
import AppList from './components/app-list';
import { Metadata } from 'next';
+import {
+ getBaseMetadata,
+ generateProductSchema,
+ generateCollectionPageSchema,
+ generateOrganizationSchema,
+ generateBreadcrumbSchema,
+} from './utils/metadata';
+import envConfig from '@/src/constants/envConfig';
+import { Plugin } from './components/types';
+import { ProductBanner } from '../components/product-banner';
-export const metadata: Metadata = {
- title: 'Omi Apps - Discover and Install Apps',
- description: 'Browse and install apps for your Omi device.',
-};
+async function getAppsCount() {
+ const response = await fetch(
+ `${envConfig.API_URL}/v1/approved-apps?include_reviews=true`,
+ { next: { revalidate: 3600 } },
+ );
+ const plugins = (await response.json()) as Plugin[];
+ return plugins.length;
+}
+
+export async function generateMetadata(): Promise {
+ 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,
+ keywords:
+ 'OMI apps, AI apps, voice control apps, wearable apps, productivity apps, health apps, entertainment apps',
+ alternates: {
+ canonical: 'https://omi.me/apps',
+ },
+ robots: {
+ index: true,
+ follow: true,
+ googleBot: {
+ index: true,
+ follow: true,
+ },
+ },
+ verification: {
+ other: {
+ 'structured-data': JSON.stringify([
+ generateCollectionPageSchema(title, description, 'https://omi.me/apps'),
+ generateProductSchema(),
+ generateOrganizationSchema(),
+ generateBreadcrumbSchema(),
+ ]),
+ },
+ },
+ };
+}
export default async function AppsPage() {
return (
+
);
}
diff --git a/frontend/src/app/apps/utils/category.ts b/frontend/src/app/apps/utils/category.ts
index 17086d551..572f03d6f 100644
--- a/frontend/src/app/apps/utils/category.ts
+++ b/frontend/src/app/apps/utils/category.ts
@@ -16,42 +16,213 @@ import {
type LucideIcon,
} from 'lucide-react';
-export const getCategoryDisplay = (category: string): string => {
- const categoryMap: Record = {
- 'social-and-relationships': 'Social & Relationships',
- 'utilities-and-tools': 'Utilities & Tools',
- 'productivity-and-organization': 'Productivity',
- 'conversation-analysis': 'Conversation Insights',
- 'education-and-learning': 'Learning & Education',
- financial: 'Finance',
- other: 'General',
- 'emotional-and-mental-support': 'Mental Wellness',
- 'safety-and-security': 'Security & Safety',
- 'health-and-wellness': 'Health & Fitness',
- 'personality-emulation': 'Persona & AI Chat',
- 'shopping-and-commerce': 'Shopping',
- 'news-and-information': 'News & Info',
- 'entertainment-and-fun': 'Entertainment & Games',
+export interface CategoryMetadata {
+ id: string;
+ displayName: string;
+ description: string;
+ icon: LucideIcon;
+ theme: CategoryTheme;
+}
+
+export interface CategoryTheme {
+ primary: string;
+ secondary: string;
+ accent: string;
+ background: string;
+}
+
+export const categoryMetadata: Record = {
+ 'productivity-and-organization': {
+ id: 'productivity-and-organization',
+ displayName: 'Productivity',
+ description: 'Tools to enhance your productivity and organization',
+ icon: Briefcase,
+ theme: {
+ primary: 'text-indigo-500',
+ secondary: 'text-indigo-400',
+ accent: 'bg-indigo-500/15',
+ background: 'bg-indigo-500/5',
+ },
+ },
+ 'conversation-analysis': {
+ id: 'conversation-analysis',
+ displayName: 'Conversation Insights',
+ description: 'Analyze and improve your conversations',
+ icon: MessageSquare,
+ theme: {
+ primary: 'text-violet-500',
+ secondary: 'text-violet-400',
+ accent: 'bg-violet-500/15',
+ background: 'bg-violet-500/5',
+ },
+ },
+ 'education-and-learning': {
+ id: 'education-and-learning',
+ displayName: 'Learning & Education',
+ description: 'Enhance your learning experience',
+ icon: GraduationCap,
+ theme: {
+ primary: 'text-blue-500',
+ secondary: 'text-blue-400',
+ accent: 'bg-blue-500/15',
+ background: 'bg-blue-500/5',
+ },
+ },
+ 'personality-emulation': {
+ id: 'personality-emulation',
+ displayName: 'Persona & AI Chat',
+ description: 'Interact with AI personalities',
+ icon: Brain,
+ theme: {
+ primary: 'text-fuchsia-500',
+ secondary: 'text-fuchsia-400',
+ accent: 'bg-fuchsia-500/15',
+ background: 'bg-fuchsia-500/5',
+ },
+ },
+ 'utilities-and-tools': {
+ id: 'utilities-and-tools',
+ displayName: 'Utilities & Tools',
+ description: 'Useful tools and utilities',
+ icon: Wrench,
+ theme: {
+ primary: 'text-sky-500',
+ secondary: 'text-sky-400',
+ accent: 'bg-sky-500/15',
+ background: 'bg-sky-500/5',
+ },
+ },
+ 'health-and-wellness': {
+ id: 'health-and-wellness',
+ displayName: 'Health & Fitness',
+ description: 'Monitor and improve your health',
+ icon: Heart,
+ theme: {
+ primary: 'text-rose-500',
+ secondary: 'text-rose-400',
+ accent: 'bg-rose-500/15',
+ background: 'bg-rose-500/5',
+ },
+ },
+ 'safety-and-security': {
+ id: 'safety-and-security',
+ displayName: 'Security & Safety',
+ description: 'Protect and secure your data',
+ icon: Shield,
+ theme: {
+ primary: 'text-emerald-500',
+ secondary: 'text-emerald-400',
+ accent: 'bg-emerald-500/15',
+ background: 'bg-emerald-500/5',
+ },
+ },
+ 'news-and-information': {
+ id: 'news-and-information',
+ displayName: 'News & Info',
+ description: 'Stay informed and up-to-date',
+ icon: Newspaper,
+ theme: {
+ primary: 'text-amber-500',
+ secondary: 'text-amber-400',
+ accent: 'bg-amber-500/15',
+ background: 'bg-amber-500/5',
+ },
+ },
+ 'social-and-relationships': {
+ id: 'social-and-relationships',
+ displayName: 'Social & Relationships',
+ description: 'Enhance your social interactions',
+ icon: Users,
+ theme: {
+ primary: 'text-pink-500',
+ secondary: 'text-pink-400',
+ accent: 'bg-pink-500/15',
+ background: 'bg-pink-500/5',
+ },
+ },
+ financial: {
+ id: 'financial',
+ displayName: 'Finance',
+ description: 'Manage your finances',
+ icon: DollarSign,
+ theme: {
+ primary: 'text-green-500',
+ secondary: 'text-green-400',
+ accent: 'bg-green-500/15',
+ background: 'bg-green-500/5',
+ },
+ },
+ 'entertainment-and-fun': {
+ id: 'entertainment-and-fun',
+ displayName: 'Entertainment & Games',
+ description: 'Have fun and stay entertained',
+ icon: Gamepad2,
+ theme: {
+ primary: 'text-orange-500',
+ secondary: 'text-orange-400',
+ accent: 'bg-orange-500/15',
+ background: 'bg-orange-500/5',
+ },
+ },
+ 'shopping-and-commerce': {
+ id: 'shopping-and-commerce',
+ displayName: 'Shopping',
+ description: 'Shop and manage purchases',
+ icon: ShoppingBag,
+ theme: {
+ primary: 'text-teal-500',
+ secondary: 'text-teal-400',
+ accent: 'bg-teal-500/15',
+ background: 'bg-teal-500/5',
+ },
+ },
+ integration: {
+ id: 'integration',
+ displayName: 'Integration Apps',
+ description: 'Connect with external services',
+ icon: Globe,
+ theme: {
+ primary: 'text-cyan-500',
+ secondary: 'text-cyan-400',
+ accent: 'bg-cyan-500/15',
+ background: 'bg-cyan-500/5',
+ },
+ },
+ other: {
+ id: 'other',
+ displayName: 'General',
+ description: 'Other useful applications',
+ icon: Sparkles,
+ theme: {
+ primary: 'text-purple-500',
+ secondary: 'text-purple-400',
+ accent: 'bg-purple-500/15',
+ background: 'bg-purple-500/5',
+ },
+ },
+};
+
+export function getCategoryMetadata(category: string): CategoryMetadata {
+ return categoryMetadata[category] || categoryMetadata.other;
+}
+
+export function getAdjacentCategories(currentCategory: string): {
+ prev?: string;
+ next?: string;
+} {
+ const categories = Object.keys(categoryMetadata);
+ const currentIndex = categories.indexOf(currentCategory);
+
+ return {
+ prev: currentIndex > 0 ? categories[currentIndex - 1] : undefined,
+ next: currentIndex < categories.length - 1 ? categories[currentIndex + 1] : undefined,
};
- return categoryMap[category] ?? category;
+}
+
+export const getCategoryDisplay = (category: string): string => {
+ return getCategoryMetadata(category).displayName;
};
export const getCategoryIcon = (category: string): LucideIcon => {
- const iconMap: Record = {
- 'productivity-and-organization': Briefcase,
- 'conversation-analysis': MessageSquare,
- 'education-and-learning': GraduationCap,
- 'personality-emulation': Brain,
- 'utilities-and-tools': Wrench,
- 'health-and-wellness': Heart,
- 'safety-and-security': Shield,
- 'news-and-information': Newspaper,
- 'social-and-relationships': Users,
- financial: DollarSign,
- 'entertainment-and-fun': Gamepad2,
- 'shopping-and-commerce': ShoppingBag,
- 'travel-and-exploration': Globe,
- other: Sparkles,
- };
- return iconMap[category] ?? Sparkles;
+ return getCategoryMetadata(category).icon;
};
diff --git a/frontend/src/app/apps/utils/metadata.ts b/frontend/src/app/apps/utils/metadata.ts
new file mode 100644
index 000000000..e74a402d3
--- /dev/null
+++ b/frontend/src/app/apps/utils/metadata.ts
@@ -0,0 +1,227 @@
+import { Metadata } from 'next';
+
+export interface CategoryMetadata {
+ title: string;
+ description: string;
+ keywords: string[];
+}
+
+export const categoryMetadata: Record = {
+ productivity: {
+ title: 'Productivity Apps for OMI Necklace',
+ description:
+ 'Enhance your daily workflow with OMI productivity apps. From voice-controlled task management to AI-powered note-taking, transform how you work with hands-free efficiency.',
+ keywords: [
+ 'productivity apps',
+ 'task management',
+ 'note-taking',
+ 'voice control',
+ 'AI assistant',
+ ],
+ },
+ entertainment: {
+ title: 'Entertainment Apps for OMI Necklace',
+ description:
+ 'Discover entertainment apps for your OMI Necklace. Enjoy music, games, and interactive experiences designed for voice control and ambient computing.',
+ keywords: [
+ 'entertainment apps',
+ 'music',
+ 'games',
+ 'interactive experiences',
+ 'voice control',
+ ],
+ },
+ health: {
+ title: 'Health & Wellness Apps for OMI Necklace',
+ description:
+ 'Take control of your wellness journey with OMI health apps. Track fitness, monitor health metrics, and get AI-powered wellness insights through your wearable companion.',
+ keywords: [
+ 'health apps',
+ 'wellness tracking',
+ 'fitness monitoring',
+ 'health metrics',
+ 'AI wellness',
+ 'wearable health',
+ 'OMI apps',
+ 'digital health companion',
+ ],
+ },
+ social: {
+ title: 'Social Apps for OMI Necklace',
+ description:
+ 'Stay connected with OMI social apps. Experience new ways to communicate, share, and interact with friends and family through your AI-powered necklace.',
+ keywords: [
+ 'social apps',
+ 'communication',
+ 'sharing',
+ 'social interaction',
+ 'voice messaging',
+ ],
+ },
+ integration: {
+ title: 'Integration Apps for OMI Necklace',
+ description:
+ 'Connect your digital world with OMI integration apps. Seamlessly control smart home devices, sync with your favorite services, and automate your life.',
+ keywords: [
+ 'integration apps',
+ 'smart home',
+ 'automation',
+ 'device control',
+ 'service integration',
+ ],
+ },
+};
+
+const productInfo = {
+ name: 'OMI Necklace',
+ description: 'AI-powered wearable necklace. Real-time AI voice assistant.',
+ price: '69.99',
+ currency: 'USD',
+ url: 'https://www.omi.me/products/friend-dev-kit-2',
+};
+
+const appStoreInfo = {
+ ios: 'https://apps.apple.com/us/app/friend-ai-wearable/id6502156163',
+ android: 'https://play.google.com/store/apps/details?id=com.friend.ios',
+};
+
+export function generateBreadcrumbSchema(category?: string) {
+ const breadcrumbList = {
+ '@context': 'https://schema.org',
+ '@type': 'BreadcrumbList',
+ itemListElement: [
+ {
+ '@type': 'ListItem',
+ position: 1,
+ name: 'Home',
+ item: 'https://omi.me',
+ },
+ {
+ '@type': 'ListItem',
+ position: 2,
+ name: 'Apps',
+ item: 'https://omi.me/apps',
+ },
+ ],
+ };
+
+ if (category) {
+ breadcrumbList.itemListElement.push({
+ '@type': 'ListItem',
+ position: 3,
+ name: categoryMetadata[category]?.title || category,
+ item: `https://omi.me/apps/category/${category}`,
+ });
+ }
+
+ return breadcrumbList;
+}
+
+export function generateProductSchema() {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Product',
+ name: productInfo.name,
+ description: productInfo.description,
+ brand: {
+ '@type': 'Brand',
+ name: 'OMI',
+ },
+ offers: {
+ '@type': 'Offer',
+ price: productInfo.price,
+ priceCurrency: productInfo.currency,
+ availability: 'https://schema.org/InStock',
+ url: productInfo.url,
+ },
+ 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,
+) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'CollectionPage',
+ name: title,
+ description: description,
+ url: url,
+ isPartOf: {
+ '@type': 'WebSite',
+ name: 'OMI Apps Marketplace',
+ url: 'https://omi.me/apps',
+ },
+ };
+}
+
+export function generateOrganizationSchema() {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'Organization',
+ name: 'OMI',
+ url: 'https://omi.me',
+ sameAs: [appStoreInfo.ios, appStoreInfo.android],
+ };
+}
+
+export function generateAppListSchema(apps: any[]) {
+ return {
+ '@context': 'https://schema.org',
+ '@type': 'ItemList',
+ itemListElement: apps.map((app, index) => ({
+ '@type': 'ListItem',
+ position: index + 1,
+ item: {
+ '@type': 'SoftwareApplication',
+ name: app.name,
+ description: app.description,
+ applicationCategory: app.category,
+ operatingSystem: 'iOS, Android',
+ offers: {
+ '@type': 'Offer',
+ price: '0',
+ priceCurrency: 'USD',
+ },
+ },
+ })),
+ };
+}
+
+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',
+ },
+ other: {
+ 'apple-itunes-app': `app-id=6502156163`,
+ 'google-play-app': `app-id=com.friend.ios`,
+ },
+ };
+}
diff --git a/frontend/src/app/components/product-banner/index.tsx b/frontend/src/app/components/product-banner/index.tsx
new file mode 100644
index 000000000..b909364c2
--- /dev/null
+++ b/frontend/src/app/components/product-banner/index.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import { useState } from 'react';
+import Image from 'next/image';
+import { PRODUCT_INFO } from './types';
+import { cn } from '@/src/lib/utils';
+
+interface ProductBannerProps {
+ variant?: 'detail' | 'floating' | 'category';
+ className?: string;
+ appName?: string;
+ category?: string;
+}
+
+export function ProductBanner({
+ variant = 'detail',
+ className,
+ appName,
+ category,
+}: ProductBannerProps) {
+ const [isHovered, setIsHovered] = useState(false);
+
+ const renderContent = () => {
+ switch (variant) {
+ case 'detail':
+ return (
+
+
+
+
+
+
+
+
+
+ Experience {appName} with {PRODUCT_INFO.name}
+
+
+ AI-Powered Voice Assistant - {PRODUCT_INFO.shipping}
+
+
+
+
+
+
+ );
+
+ case 'floating':
+ return (
+
+
+
+
+
+
+
+
+ {PRODUCT_INFO.name}
+
+
{PRODUCT_INFO.price}
+
+
+
+
+
+ );
+
+ case 'category':
+ return (
+
+
+
+
+
+
+
+
+
+ Enhance your {category} experience
+
+
+ {PRODUCT_INFO.name} - {PRODUCT_INFO.price}
+
+
+
+
+ Order Now
+
+
+
+ );
+ }
+ };
+
+ return (
+ setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ >
+ {renderContent()}
+
+ );
+}
diff --git a/frontend/src/app/components/product-banner/types.ts b/frontend/src/app/components/product-banner/types.ts
new file mode 100644
index 000000000..41580d868
--- /dev/null
+++ b/frontend/src/app/components/product-banner/types.ts
@@ -0,0 +1,30 @@
+export interface ProductBannerBase {
+ className?: string;
+ onClick?: () => void;
+ productUrl?: string;
+}
+
+export interface DetailBannerProps extends ProductBannerBase {
+ appName: string;
+ appCategory: string;
+}
+
+export interface FloatingBannerProps extends ProductBannerBase {
+ showShipping?: boolean;
+}
+
+export interface CategoryBannerProps extends ProductBannerBase {
+ category: string;
+ appsCount: number;
+}
+
+export const PRODUCT_INFO = {
+ name: 'OMI Necklace',
+ price: '$69.99',
+ url: 'https://www.omi.me/products/friend-dev-kit-2',
+ shipping: 'Ships Worldwide',
+ images: {
+ primary: '/omi_1.webp',
+ secondary: '/omi_2.webp',
+ },
+} as const;
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index c7ff68f92..f021f0bcb 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -1,44 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
-/*
-:root {
- --foreground-rgb: 0, 0, 0;
- --background-start-rgb: 214, 219, 220;
- --background-end-rgb: 255, 255, 255;
-} */
-
-/* @media (prefers-color-scheme: dark) {
- :root {
- --foreground-rgb: 255, 255, 255;
- --background-start-rgb: 0, 0, 0;
- --background-end-rgb: 0, 0, 0;
- }
-}
@layer utilities {
- .text-balance {
- text-wrap: balance;
+ .no-scrollbar::-webkit-scrollbar {
+ display: none;
}
-} */
-
-input[type='search']::-webkit-search-cancel-button {
- -webkit-appearance: none;
- appearance: none;
-}
-
-::selection {
- background-color: #ffffff;
- /* Color de fondo de la selección */
- color: #000000;
- /* Color del texto seleccionado */
-}
-body {
- background: #181818;
- color: rgba(var(--foreground-rgb), 1);
-}
-@layer base {
- :root {
- --radius: 0.5rem;
+
+ .no-scrollbar {
+ -ms-overflow-style: none;
+ scrollbar-width: none;
}
}
diff --git a/frontend/src/components/shared/footer.tsx b/frontend/src/components/shared/footer.tsx
index e4cf843ae..b99648746 100644
--- a/frontend/src/components/shared/footer.tsx
+++ b/frontend/src/components/shared/footer.tsx
@@ -3,7 +3,7 @@ import Image from 'next/image';
export default function Footer() {
return (
-