diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ef6a52 --- /dev/null +++ b/.gitignore @@ -0,0 +1,41 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env* + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..053b0fc --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,25 @@ +{ + "editor.formatOnSave": true, + "editor.defaultFormatter": "biomejs.biome", + "editor.codeActionsOnSave": { + "source.organizeImports.biome": "explicit" + }, + "[javascript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[javascriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescript]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[typescriptreact]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[json]": { + "editor.defaultFormatter": "biomejs.biome" + }, + "[jsonc]": { + "editor.defaultFormatter": "biomejs.biome" + } +} diff --git a/README.md b/README.md index 65c6f93..c73d77e 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,117 @@ -# sossoldi-website -Website for Sossoldi, the open-source Flutter app for tracking your net worth, expenses, income, and investments +# Sossoldi Landing Page + +The official landing page for [Sossoldi](https://github.com/RIP-Comm/Sossoldi) - the open-source personal finance app by the Mr. RIP Community. + +## Features + +- **Internationalization**: Full English and Italian support with URL prefix routing (`/en`, `/it`) +- **Modern Design**: Glassmorphism UI with the "Serious Blue" Sossoldi brand palette +- **Animations**: Smooth Framer Motion animations including animated counters and graphs +- **Dark/Light Mode**: System-aware theme switching +- **MDX Blog**: Static blog with full MDX support +- **SEO Optimized**: Static generation for all pages + +## Tech Stack + +- **Framework**: Next.js 16 with App Router +- **Styling**: Tailwind CSS v4 with custom Sossoldi color palette +- **Components**: shadcn/ui (New York style) +- **Animations**: Framer Motion +- **i18n**: next-intl with prefix routing +- **Content**: MDX for blog posts + +## Getting Started + +```bash +# Install dependencies +bun install + +# Run development server +bun dev + +# Build for production +bun run build + +# Start production server +bun start +``` + +## Project Structure + +``` +app/ +├── [locale]/ +│ ├── layout.tsx # Locale-wrapped layout +│ ├── page.tsx # Landing page +│ └── blog/ +│ ├── page.tsx # Blog listing +│ └── [slug]/ # Individual blog posts +├── globals.css # Sossoldi brand colors & styles + +components/ +├── layout/ # Navbar, Footer, MobileNav, LocaleSwitcher +├── sections/ # Hero, Features, FAQ, BlogPreview +├── ui/ # shadcn/ui + custom components +└── providers/ # Theme provider + +content/ +└── blog/ # MDX blog posts + +i18n/ +├── routing.ts # Locale configuration +├── request.ts # next-intl server config +└── navigation.ts # Localized navigation helpers + +messages/ +├── en.json # English translations +└── it.json # Italian translations +``` + +## Redirects + +The middleware handles external redirects: + +| Path | Destination | +|------|-------------| +| `/docs` | GitHub Pages documentation | +| `/contribute` | CONTRIBUTING.md on GitHub | +| `/community` | Discord invite | +| `/repo` | GitHub repository | + +## Brand Colors + +The Sossoldi "Serious Blue" palette: + +- **Primary**: `#0D47A1` (Blue 900) +- **Primary Light**: `#1565C0` (Blue 800) +- **Accent**: `#42A5F5` (Blue 400) +- **Success**: `#4CAF50` (Income/Positive) +- **Destructive**: `#EF5350` (Expense/Negative) + +## Adding Blog Posts + +Create a new `.mdx` file in `content/blog/`: + +```mdx +export const metadata = { + title: "Your Post Title", + publishDate: "2024-12-20", + excerpt: "A brief description of the post.", + author: { + name: "Author Name", + bio: "Short bio", + avatar: "https://github.com/username.png", + }, + tags: ["Tag1", "Tag2"], + seo: { + metaTitle: "SEO Title", + metaDescription: "SEO Description", + }, +}; + +Your MDX content here... +``` + +## License + +Open source, built with ❤️ by the Mr. RIP Community. diff --git a/app/[locale]/blog/[slug]/_queries/get-post.tsx b/app/[locale]/blog/[slug]/_queries/get-post.tsx new file mode 100644 index 0000000..0dcd5a8 --- /dev/null +++ b/app/[locale]/blog/[slug]/_queries/get-post.tsx @@ -0,0 +1,65 @@ +import { promises as fs } from "fs"; +import { MDXContent } from "mdx/types"; +import path from "path"; + +type Author = { + name: string; + bio: string; + avatar: string; +}; + +type SEO = { + metaTitle: string; + metaDescription: string; +}; + +export type PostMetadata = { + title: string; + featuredImage?: string; + publishDate: string; + lastModified?: string; + author: Author; + excerpt: string; + tags: string[]; + seo: SEO; +}; + +export type Post = PostMetadata & { + slug: string; + content: MDXContent; +}; + +export async function getPostSlugs() { + const postsDirectory = path.join(process.cwd(), "content/blog"); + const files = await fs.readdir(postsDirectory); + return files.filter((file) => file.endsWith(".mdx")).map((file) => file.replace(/\.mdx$/, "")); +} + +export async function getPost(slug: string): Promise { + try { + const post = await import(`@/content/blog/${slug}.mdx`); + + return { + slug, + ...post.metadata, + content: post.default, + } as Post; + } catch (error) { + console.error("Error loading blog post:", error); + return null; + } +} + +export async function getAllPosts(): Promise { + const slugs = await getPostSlugs(); + const posts = await Promise.all( + slugs.map(async (slug) => { + const post = await getPost(slug); + return post; + }), + ); + + return posts + .filter((post): post is Post => post !== null) + .sort((a, b) => new Date(b.publishDate).getTime() - new Date(a.publishDate).getTime()); +} diff --git a/app/[locale]/blog/[slug]/page.tsx b/app/[locale]/blog/[slug]/page.tsx new file mode 100644 index 0000000..c6c92b2 --- /dev/null +++ b/app/[locale]/blog/[slug]/page.tsx @@ -0,0 +1,298 @@ +import { notFound } from "next/navigation"; +import { setRequestLocale } from "next-intl/server"; +import { getPost, getPostSlugs } from "./_queries/get-post"; +import Image from "next/image"; +import { Link } from "@/i18n/navigation"; +import { ArrowLeft, Calendar, Clock, Share2 } from "lucide-react"; +import { Navbar } from "@/components/layout/navbar"; +import { Footer } from "@/components/layout/footer"; +import { routing } from "@/i18n/routing"; +import type { Metadata } from "next"; + +export async function generateStaticParams() { + const slugs = await getPostSlugs(); + // Generate params for all locales and slugs + const params: Array<{ locale: string; slug: string }> = []; + for (const locale of routing.locales) { + for (const slug of slugs) { + params.push({ locale, slug }); + } + } + return params; +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}): Promise { + const { locale, slug } = await params; + const post = await getPost(slug); + + if (!post) { + return { + title: "Post Not Found", + description: "The requested blog post could not be found.", + }; + } + + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://sossoldi.com"; + const title = post.seo?.metaTitle || post.title; + const description = post.seo?.metaDescription || post.excerpt; + const imageUrl = post.featuredImage + ? `${baseUrl}${post.featuredImage.startsWith("/") ? "" : "/"}${post.featuredImage}` + : `${baseUrl}/logo.png`; + const postUrl = `${baseUrl}/${locale}/blog/${slug}`; + const publishDate = new Date(post.publishDate).toISOString(); + const modifiedDate = post.lastModified + ? new Date(post.lastModified).toISOString() + : publishDate; + + return { + title, + description, + keywords: post.tags || [], + authors: [{ name: post.author.name }], + publishedTime: publishDate, + modifiedTime: modifiedDate, + alternates: { + languages: Object.fromEntries( + routing.locales.map((loc) => [loc, `${baseUrl}/${loc}/blog/${slug}`]) + ), + canonical: postUrl, + }, + openGraph: { + type: "article", + locale: locale === "it" ? "it_IT" : "en_US", + url: postUrl, + siteName: "Sossoldi", + title, + description, + images: [ + { + url: imageUrl, + width: 1200, + height: 630, + alt: post.title, + }, + ], + publishedTime: publishDate, + modifiedTime: modifiedDate, + authors: [post.author.name], + tags: post.tags || [], + alternateLocale: routing.locales.filter((loc) => loc !== locale), + }, + twitter: { + card: "summary_large_image", + title, + description, + images: [imageUrl], + creator: "@sossoldi", + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + "max-video-preview": -1, + "max-image-preview": "large", + "max-snippet": -1, + }, + }, + }; +} + +export default async function BlogPostPage({ + params, +}: { + params: Promise<{ locale: string; slug: string }>; +}) { + const { locale, slug } = await params; + setRequestLocale(locale); + + const post = await getPost(slug); + + if (!post) { + notFound(); + } + + const Content = post.content; + const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || "https://sossoldi.com"; + const imageUrl = post.featuredImage + ? `${baseUrl}${post.featuredImage.startsWith("/") ? "" : "/"}${post.featuredImage}` + : `${baseUrl}/logo.png`; + + // Structured data for blog post + const structuredData = { + "@context": "https://schema.org", + "@type": "BlogPosting", + headline: post.title, + description: post.excerpt, + image: imageUrl, + datePublished: new Date(post.publishDate).toISOString(), + dateModified: post.lastModified + ? new Date(post.lastModified).toISOString() + : new Date(post.publishDate).toISOString(), + author: { + "@type": "Person", + name: post.author.name, + description: post.author.bio, + image: post.author.avatar + ? `${baseUrl}${post.author.avatar.startsWith("/") ? "" : "/"}${post.author.avatar}` + : undefined, + }, + publisher: { + "@type": "Organization", + name: "Sossoldi", + logo: { + "@type": "ImageObject", + url: `${baseUrl}/logo.png`, + }, + }, + mainEntityOfPage: { + "@type": "WebPage", + "@id": `${baseUrl}/${locale}/blog/${slug}`, + }, + keywords: post.tags?.join(", ") || "", + inLanguage: locale === "it" ? "it" : "en", + articleSection: "Finance", + wordCount: post.excerpt.split(" ").length, // Approximate + }; + + return ( +
+ {/* JSON-LD structured data for SEO */} +