diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..d2230743 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "apps/web/src/content/newsletters-premium"] + path = apps/web/src/content/newsletters-premium + url = git@github.com:apsinghdev/opensox-newsletters-premium.git diff --git a/apps/web/.gitignore b/apps/web/.gitignore index 26b002aa..90975c82 100644 --- a/apps/web/.gitignore +++ b/apps/web/.gitignore @@ -38,3 +38,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +src/content/newsletters-premium/ \ No newline at end of file diff --git a/apps/web/next.config.js b/apps/web/next.config.js index e1cb010e..dc6d8137 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -19,7 +19,6 @@ const nextConfig = { experimental: { optimizePackageImports: ['lucide-react', '@heroicons/react'], }, - swcMinify: true, }; module.exports = nextConfig; \ No newline at end of file diff --git a/apps/web/package.json b/apps/web/package.json index 77494fc3..06ca9e96 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "dev": "next dev", - "build": "next build", + "build": "bash ./scripts/init-submodules.sh && next build", "start": "next start", "lint": "next lint" }, @@ -15,7 +15,7 @@ "@radix-ui/react-checkbox": "^1.1.2", "@radix-ui/react-label": "^2.1.0", "@radix-ui/react-radio-group": "^1.2.1", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-slot": "^1.2.3", "@tanstack/react-query": "^5.90.2", "@trpc/client": "^11.6.0", "@trpc/react-query": "^11.6.0", @@ -27,7 +27,9 @@ "dompurify": "^3.3.0", "framer-motion": "^11.15.0", "geist": "^1.5.1", + "gray-matter": "^4.0.3", "lucide-react": "^0.456.0", + "marked": "^17.0.0", "next": "15.5.3", "next-auth": "^4.24.11", "next-themes": "^0.4.3", @@ -43,6 +45,7 @@ }, "devDependencies": { "@types/dompurify": "^3.2.0", + "@tailwindcss/line-clamp": "^0.4.4", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", diff --git a/apps/web/scripts/init-submodules.sh b/apps/web/scripts/init-submodules.sh new file mode 100755 index 00000000..3608f0c0 --- /dev/null +++ b/apps/web/scripts/init-submodules.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# initialize git submodules during vercel build + +set -e # Exit immediately on any error + +# setup ssh for private submodule +if [ -n "$GIT_SSH_KEY" ]; then + mkdir -p ~/.ssh || { echo "Failed to create ~/.ssh directory" >&2; exit 1; } + printf '%s' "$GIT_SSH_KEY" > ~/.ssh/id_ed25519 || { echo "Failed to write SSH key" >&2; exit 1; } + chmod 600 ~/.ssh/id_ed25519 || { echo "Failed to set SSH key permissions" >&2; exit 1; } + ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null || true +fi + +# initialize and update submodules +git submodule update --init --recursive --remote + +echo "submodules initialized successfully" + diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx new file mode 100644 index 00000000..8bcccfdd --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx @@ -0,0 +1,236 @@ +"use client"; + +import "@/styles/newsletter.css"; + +import { useEffect, useState } from "react"; +import { useParams, useRouter } from "next/navigation"; +import { CalendarIcon, ClockIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSubscription } from "@/hooks/useSubscription"; +import { PremiumUpgradePrompt } from "@/components/newsletters/PremiumUpgradePrompt"; + +interface NewsletterData { + title: string; + date: string; + readTime: string; + content: string; +} + +function NewsletterSkeleton() { + return ( +
+ {/* Header skeleton */} +
+ + +
+ + + +
+
+ + {/* Content skeleton */} +
+ {/* Paragraph 1 */} +
+ + + +
+ + {/* Heading */} + + + {/* Paragraph 2 */} +
+ + + + +
+ + {/* Image placeholder */} + + + {/* Heading */} + + + {/* Paragraph 3 */} +
+ + + +
+ + {/* List items */} +
+ + + +
+ + {/* Final paragraph */} +
+ + + +
+
+
+ ); +} + +export default function NewsletterPage() { + const params = useParams(); + const router = useRouter(); + const slug = params.slug as string; + const [newsletter, setNewsletter] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<'unauthorized' | 'forbidden' | 'not-found' | null>(null); + const { isPaidUser, isLoading: subscriptionLoading } = useSubscription(); + + useEffect(() => { + if (subscriptionLoading) return; + + fetch(`/api/newsletters/${slug}`) + .then(async (res) => { + if (res.status === 401) { + setError('unauthorized'); + setLoading(false); + return null; + } + if (res.status === 403) { + setError('forbidden'); + setLoading(false); + return null; + } + if (!res.ok) { + setError('not-found'); + setLoading(false); + return null; + } + return res.json(); + }) + .then((data) => { + if (data && !data.error) { + setNewsletter(data); + setError(null); + } + setLoading(false); + }) + .catch(() => { + setError('not-found'); + setLoading(false); + }); + }, [slug, subscriptionLoading]); + + if (subscriptionLoading) { + return ( +
+
+ + +
+
+ ); + } + + if (!isPaidUser || error === 'forbidden') { + return ; + } + + if (error === 'unauthorized') { + router.push('/login'); + return null; + } + + if (loading) { + return ( +
+
+ + +
+
+ ); + } + + if (!newsletter) { + return ( +
+
+

Newsletter not found

+

The newsletter you're looking for doesn't exist.

+ +
+
+ ); + } + + return ( +
+
+ {/* Back Button */} + + +
+ {/* Header */} +
+

+ {newsletter.title} +

+ + {/* Metadata */} +
+ + + {new Date(newsletter.date).toLocaleDateString("en-US", { + month: "short", + day: "numeric", + year: "numeric", + })} + + + + + {newsletter.readTime} + +
+
+ + {/* Content */} +
+
+
+
+ ); +} diff --git a/apps/web/src/app/(main)/dashboard/newsletters/page.tsx b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx new file mode 100644 index 00000000..288dd326 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx @@ -0,0 +1,142 @@ +"use client"; + +import "@/styles/newsletter.css"; + +import { useEffect, useState, useMemo } from "react"; +import { useRouter } from "next/navigation"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useSubscription } from "@/hooks/useSubscription"; +import { Newsletter } from "@/components/newsletters/NewsletterCard"; +import { NewsletterSkeleton } from "@/components/newsletters/NewsletterSkeleton"; +import { PremiumUpgradePrompt } from "@/components/newsletters/PremiumUpgradePrompt"; +import { NewsletterFilters, TimeFilter, SortFilter } from "@/components/newsletters/NewsletterFilters"; +import { NewsletterPagination } from "@/components/newsletters/NewsletterPagination"; +import { NewsletterList } from "@/components/newsletters/NewsletterList"; +import { useNewsletterFilters } from "@/hooks/useNewsletterFilters"; + +export default function NewslettersPage() { + const router = useRouter(); + const [newsletters, setNewsletters] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<'unauthorized' | 'forbidden' | null>(null); + const [searchQuery, setSearchQuery] = useState(""); + const [timeFilter, setTimeFilter] = useState("all"); + const [sortFilter, setSortFilter] = useState("newest"); + const [currentPage, setCurrentPage] = useState(1); + const { isPaidUser, isLoading: subscriptionLoading } = useSubscription(); + + const itemsPerPage = 5; + + useEffect(() => { + if (subscriptionLoading) return; + + fetch("/api/newsletters") + .then(async (res) => { + if (res.status === 401) { + setError('unauthorized'); + setLoading(false); + return null; + } + if (res.status === 403) { + setError('forbidden'); + setLoading(false); + return null; + } + if (!res.ok) { + setLoading(false); + return null; + } + return res.json(); + }) + .then((data) => { + if (data) { + setNewsletters(data); + } + setLoading(false); + }) + .catch(() => setLoading(false)); + }, [subscriptionLoading]); + + const filteredNewsletters = useNewsletterFilters(newsletters, searchQuery, timeFilter); + + // Apply sorting + const sortedNewsletters = useMemo(() => { + const sorted = [...filteredNewsletters]; + sorted.sort((a, b) => { + const dateA = new Date(a.date).getTime(); + const dateB = new Date(b.date).getTime(); + return sortFilter === "newest" ? dateB - dateA : dateA - dateB; + }); + return sorted; + }, [filteredNewsletters, sortFilter]); + + const totalPages = Math.ceil(sortedNewsletters.length / itemsPerPage); + const paginatedNewsletters = useMemo(() => { + const startIndex = (currentPage - 1) * itemsPerPage; + return sortedNewsletters.slice(startIndex, startIndex + itemsPerPage); + }, [sortedNewsletters, currentPage]); + + // Reset to page 1 when filters change + useEffect(() => { + setCurrentPage(1); + }, [searchQuery, timeFilter, sortFilter]); + + if (subscriptionLoading) { + return ( +
+
+ + + +
+
+ ); + } + + if (error === 'unauthorized') { + router.push('/login'); + return null; + } + + if (!isPaidUser || error === 'forbidden') { + return ; + } + + return ( +
+
+
+

+ Newsletter +

+

+ Stay updated with our latest insights and stories +

+
+ + + +
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/app/api/newsletters/[slug]/route.ts b/apps/web/src/app/api/newsletters/[slug]/route.ts new file mode 100644 index 00000000..da59db68 --- /dev/null +++ b/apps/web/src/app/api/newsletters/[slug]/route.ts @@ -0,0 +1,102 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { marked } from "marked"; +import { getServerSession } from "next-auth"; +import { authConfig } from "@/lib/auth/config"; +import { createAuthenticatedClient } from "@/lib/trpc-server"; + +// Configure marked for rich markdown support +marked.setOptions({ + gfm: true, // GitHub Flavored Markdown: tables, task lists, etc. + breaks: true, // Line breaks +}); + +// Cache individual newsletters +const newsletterCache = new Map(); +// cache longer in production since newsletter content changes infrequently +const CACHE_DURATION = process.env.NODE_ENV === "production" ? 3600000 : 60000; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string }> } +) { + // Authenticate user + const session = await getServerSession(authConfig); + + if (!session || !session.user?.email) { + return NextResponse.json( + { error: "Unauthorized - Please sign in" }, + { status: 401 } + ); + } + + // Verify paid subscription + try { + const trpc = createAuthenticatedClient(session); + const subscriptionStatus = await ( + trpc.user as any + ).subscriptionStatus.query(); + + if (!subscriptionStatus.isPaidUser) { + return NextResponse.json( + { error: "Forbidden - Premium subscription required" }, + { status: 403 } + ); + } + } catch (error) { + console.error("Error checking subscription:", error); + return NextResponse.json( + { error: "Failed to verify subscription status" }, + { status: 500 } + ); + } + + const { slug } = await params; + const now = Date.now(); + const cached = newsletterCache.get(slug); + + if (cached && now - cached.time < CACHE_DURATION) { + return NextResponse.json(cached.data); + } + + // read from premium directory for paid users + const newslettersDir = path.join( + process.cwd(), + "apps/web/src/content/newsletters-premium" + ); + const filePath = path.join(newslettersDir, `${slug}.md`); + + try { + if (!fs.existsSync(filePath)) { + return NextResponse.json( + { error: "Newsletter not found" }, + { status: 404 } + ); + } + + const fileContent = fs.readFileSync(filePath, "utf8"); + const { data, content } = matter(fileContent); + + // Render markdown (supports headings, links, lists, code blocks, images, tables, etc.) + const htmlContent = marked.parse(content); + + const result = { + title: data.title || "Untitled", + date: data.date || new Date().toISOString(), + readTime: data.readTime || "5 min read", + content: htmlContent, + }; + + newsletterCache.set(slug, { data: result, time: now }); + + return NextResponse.json(result); + } catch (error) { + console.error("Error reading newsletter:", error); + return NextResponse.json( + { error: "Newsletter not found" }, + { status: 404 } + ); + } +} diff --git a/apps/web/src/app/api/newsletters/route.ts b/apps/web/src/app/api/newsletters/route.ts new file mode 100644 index 00000000..c7bb852a --- /dev/null +++ b/apps/web/src/app/api/newsletters/route.ts @@ -0,0 +1,101 @@ +import { NextResponse } from "next/server"; +import fs from "fs"; +import path from "path"; +import matter from "gray-matter"; +import { getServerSession } from "next-auth"; +import { authConfig } from "@/lib/auth/config"; +import { createAuthenticatedClient } from "@/lib/trpc-server"; + +// Cache newsletters in memory for faster subsequent loads +let cachedNewsletters: any[] | null = null; +let lastCacheTime = 0; +// cache longer in production since newsletter content changes infrequently +const CACHE_DURATION = + process.env.NODE_ENV === "production" + ? 3600000 // 1 hour in production + : 60000; // 1 minute in dev + +export async function GET() { + // Authenticate user + const session = await getServerSession(authConfig); + + if (!session || !session.user?.email) { + return NextResponse.json( + { error: "Unauthorized - Please sign in" }, + { status: 401 } + ); + } + + // Verify paid subscription + try { + const trpc = createAuthenticatedClient(session); + const subscriptionStatus = await ( + trpc.user as any + ).subscriptionStatus.query(); + + if (!subscriptionStatus.isPaidUser) { + return NextResponse.json( + { error: "Forbidden - Premium subscription required" }, + { status: 403 } + ); + } + } catch (error) { + console.error("Error checking subscription:", error); + return NextResponse.json( + { error: "Failed to verify subscription status" }, + { status: 500 } + ); + } + + const now = Date.now(); + + // Return cached data if available and fresh + if (cachedNewsletters && now - lastCacheTime < CACHE_DURATION) { + return NextResponse.json(cachedNewsletters); + } + + // read from premium directory for paid users + const newslettersDir = path.join( + process.cwd(), + "apps/web/src/content/newsletters-premium" + ); + + try { + if (!fs.existsSync(newslettersDir)) { + fs.mkdirSync(newslettersDir, { recursive: true }); + return NextResponse.json([]); + } + + const files = fs.readdirSync(newslettersDir); + + const newsletters = files + .filter((file) => file.endsWith(".md")) + .map((file) => { + const filePath = path.join(newslettersDir, file); + const fileContent = fs.readFileSync(filePath, "utf8"); + const { data } = matter(fileContent); + + return { + id: file.replace(".md", ""), + title: data.title || "Untitled", + date: data.date || new Date().toISOString(), + excerpt: data.excerpt || "", + readTime: data.readTime || "5 min read", + description: data.description || data.excerpt || "", + }; + }) + .sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()); + + // Update cache + cachedNewsletters = newsletters; + lastCacheTime = now; + + return NextResponse.json(newsletters); + } catch (error) { + console.error("Error reading newsletters:", error); + return NextResponse.json( + { error: "Failed to read newsletters" }, + { status: 500 } + ); + } +} diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css index a67064b2..244508f1 100644 --- a/apps/web/src/app/globals.css +++ b/apps/web/src/app/globals.css @@ -56,9 +56,7 @@ --chart-4: 280 65% 60%; --chart-5: 340 75% 55%; } -} -@layer base { * { @apply border-border; } @@ -72,4 +70,5 @@ html { scroll-behavior: smooth; -} \ No newline at end of file +} + diff --git a/apps/web/src/components/dashboard/Sidebar.tsx b/apps/web/src/components/dashboard/Sidebar.tsx index 26aa440e..4257a398 100644 --- a/apps/web/src/components/dashboard/Sidebar.tsx +++ b/apps/web/src/components/dashboard/Sidebar.tsx @@ -1,9 +1,9 @@ "use client"; -import React, { useState } from "react"; +import React, { useState, useEffect } from "react"; import Link from "next/link"; import SidebarItem from "../sidebar/SidebarItem"; -import { useRouter } from "next/navigation"; +import { useRouter, usePathname } from "next/navigation"; import { IconWrapper } from "../ui/IconWrapper"; import { motion, AnimatePresence } from "framer-motion"; import { @@ -15,6 +15,10 @@ import { StarIcon, DocumentTextIcon, Cog6ToothIcon, + NewspaperIcon, + Squares2X2Icon, + ChevronDownIcon, + LockClosedIcon, } from "@heroicons/react/24/outline"; import { useShowSidebar } from "@/store/useShowSidebar"; import { signOut, useSession } from "next-auth/react"; @@ -23,7 +27,15 @@ import { useSubscription } from "@/hooks/useSubscription"; import { OpensoxProBadge } from "../sheet/OpensoxProBadge"; import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react"; -const SIDEBAR_ROUTES = [ +type RouteConfig = { + path: string; + label: string; + icon: React.ReactNode; + badge?: string; // optional badge text (e.g., "New", "Beta") +}; + +// free features only +const FREE_ROUTES: RouteConfig[] = [ { path: "/dashboard/home", label: "Home", @@ -41,22 +53,53 @@ const SIDEBAR_ROUTES = [ }, ]; +// premium features under Opensox Pro +const PREMIUM_ROUTES: RouteConfig[] = [ + { + path: "/dashboard/pro/dashboard", + label: "Dashboard", + icon: , + badge: "New", + }, + { + path: "/dashboard/newsletters", + label: "Newsletter", + icon: , + badge: "New", + }, +]; + export default function Sidebar({ overlay = false }: { overlay?: boolean }) { const { setShowSidebar, isCollapsed, toggleCollapsed } = useShowSidebar(); const router = useRouter(); + const pathname = usePathname(); const { isPaidUser } = useSubscription(); + const [proSectionExpanded, setProSectionExpanded] = useState(true); + + // auto-expand pro section if user is on a premium route + useEffect(() => { + if (isPaidUser) { + const isOnPremiumRoute = PREMIUM_ROUTES.some((route) => { + return pathname === route.path || pathname.startsWith(`${route.path}/`); + }); + if (isOnPremiumRoute) { + setProSectionExpanded(true); + } + } + }, [pathname, isPaidUser]); const reqFeatureHandler = () => { window.open("https://github.com/apsinghdev/opensox/issues", "_blank"); }; - const proClickHandler = () => { + const handleProSectionClick = () => { if (isPaidUser) { - router.push("/dashboard/pro/dashboard"); + setProSectionExpanded(!proSectionExpanded); } else { router.push("/pricing"); } }; + const desktopWidth = isCollapsed ? 80 : 288; const mobileWidth = desktopWidth; @@ -110,47 +153,261 @@ export default function Sidebar({ overlay = false }: { overlay?: boolean }) { -
- {SIDEBAR_ROUTES.map((route) => { +
+ {/* free features section */} + {FREE_ROUTES.map((route) => { + const isActive = + pathname === route.path || pathname.startsWith(`${route.path}/`); return ( - +
+ + {route.icon} + + {!isCollapsed && ( +
+

+ {route.label} +

+ {route.badge && ( + + {route.badge} + + )} +
+ )} +
); })} + + {/* divider */} + {!isCollapsed && ( +
+
+
+ )} + + {/* premium section */} + {!isCollapsed ? ( +
+ {(() => { + const isPremiumRouteActive = PREMIUM_ROUTES.some( + (route) => + pathname === route.path || + pathname.startsWith(`${route.path}/`) + ); + const newFeaturesCount = PREMIUM_ROUTES.filter( + (route) => route.badge + ).length; + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleProSectionClick(); + } + }} + > +
+ + + +
+

+ Opensox Pro +

+ + {newFeaturesCount > 0 && ( + + {newFeaturesCount} + + )} +
+
+ {isPaidUser && ( + + )} +
+ ); + })()} + + {/* premium sub-items (only show if paid user and expanded) */} + {isPaidUser && proSectionExpanded && ( + + {PREMIUM_ROUTES.map((route) => { + const isActive = + pathname === route.path || + pathname.startsWith(`${route.path}/`); + return ( + +
+ + {route.icon} + +
+

+ {route.label} +

+ {route.badge && ( + + {route.badge} + + )} +
+
+ + ); + })} +
+ )} + + {/* free user: show locked preview */} + {!isPaidUser && ( + + {PREMIUM_ROUTES.map((route) => ( +
router.push("/pricing")} + className="w-full h-[44px] flex items-center rounded-md cursor-pointer transition-colors px-2 gap-3 opacity-50 hover:opacity-75 group" + role="button" + tabIndex={0} + aria-label={`${route.label} - Upgrade to Pro`} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + router.push("/pricing"); + } + }} + > + + {route.icon} + +
+

+ {route.label} +

+ {route.badge && ( + + {route.badge} + + )} +
+
+ +
+
+ ))} +
+ )} +
+ ) : ( + // collapsed sidebar: show icon only +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + handleProSectionClick(); + } + }} + > + +
+ )} + + {/* divider */} + {!isCollapsed && ( +
+
+
+ )} + + {/* utility features */} } collapsed={isCollapsed} /> - {!isCollapsed && !isPaidUser ? ( -
- - - -
-

- Opensox Pro -

- -
-
- ) : ( - } - collapsed={isCollapsed} - /> - )}
{/* Bottom profile */} diff --git a/apps/web/src/components/newsletters/NewsletterCard.tsx b/apps/web/src/components/newsletters/NewsletterCard.tsx new file mode 100644 index 00000000..4c5be3a8 --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterCard.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; + +export interface Newsletter { + id: string; + title: string; + description: string; + excerpt?: string; + date: string; + readTime: string; + tags?: string[]; +} + +export function NewsletterCard({ newsletter }: { newsletter: Newsletter }) { + return ( + +
+
+
+

+ {newsletter.title} +

+

+ {newsletter.excerpt || newsletter.description} +

+
+
+ {newsletter.date} + + + + + {newsletter.readTime} + +
+
+
+ + ); +} diff --git a/apps/web/src/components/newsletters/NewsletterFilters.tsx b/apps/web/src/components/newsletters/NewsletterFilters.tsx new file mode 100644 index 00000000..024003ff --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterFilters.tsx @@ -0,0 +1,68 @@ +import { Search } from "lucide-react"; + +export type TimeFilter = "all" | "january" | "february" | "march" | "april" | "may" | "june" | "july" | "august" | "september" | "october" | "november" | "december"; +export type SortFilter = "newest" | "oldest"; + +interface NewsletterFiltersProps { + searchQuery: string; + onSearchChange: (query: string) => void; + timeFilter: TimeFilter; + onTimeFilterChange: (filter: TimeFilter) => void; + sortFilter?: SortFilter; + onSortFilterChange?: (filter: SortFilter) => void; +} + +export function NewsletterFilters({ + searchQuery, + onSearchChange, + timeFilter, + onTimeFilterChange, + sortFilter = "newest", + onSortFilterChange, +}: NewsletterFiltersProps) { + return ( +
+
+ + onSearchChange(e.target.value)} + className="w-full bg-[#111111] border border-zinc-800 rounded-lg pl-11 pr-4 py-2.5 text-sm text-white placeholder:text-zinc-500 focus:outline-none focus:border-zinc-700 focus:ring-1 focus:ring-zinc-700 transition-all" + /> +
+ + + + {onSortFilterChange && ( + + )} +
+ ); +} diff --git a/apps/web/src/components/newsletters/NewsletterList.tsx b/apps/web/src/components/newsletters/NewsletterList.tsx new file mode 100644 index 00000000..ef37c431 --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterList.tsx @@ -0,0 +1,38 @@ +import { NewsletterCard, Newsletter } from "./NewsletterCard"; +import { NewsletterSkeleton } from "./NewsletterSkeleton"; + +interface NewsletterListProps { + newsletters: Newsletter[]; + loading: boolean; + hasFilters: boolean; +} + +export function NewsletterList({ newsletters, loading, hasFilters }: NewsletterListProps) { + if (loading) { + return ( +
+ + + +
+ ); + } + + if (newsletters.length === 0) { + return ( +
+

+ {hasFilters ? "No newsletters found matching your filters." : "No newsletters available yet."} +

+
+ ); + } + + return ( +
+ {newsletters.map((newsletter, index) => ( + + ))} +
+ ); +} diff --git a/apps/web/src/components/newsletters/NewsletterPagination.tsx b/apps/web/src/components/newsletters/NewsletterPagination.tsx new file mode 100644 index 00000000..8b6071fc --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterPagination.tsx @@ -0,0 +1,89 @@ +import { + Pagination, + PaginationContent, + PaginationEllipsis, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; + +interface NewsletterPaginationProps { + currentPage: number; + totalPages: number; + onPageChange: (page: number) => void; +} + +export function NewsletterPagination({ + currentPage, + totalPages, + onPageChange, +}: NewsletterPaginationProps) { + if (totalPages <= 1) return null; + + const getPageNumbers = () => { + const pages: (number | string)[] = []; + const maxVisible = 5; + + if (totalPages <= maxVisible) { + return Array.from({ length: totalPages }, (_, i) => i + 1); + } + + pages.push(1); + + if (currentPage > 3) { + pages.push("ellipsis-start"); + } + + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + + for (let i = start; i <= end; i++) { + pages.push(i); + } + + if (currentPage < totalPages - 2) { + pages.push("ellipsis-end"); + } + + pages.push(totalPages); + + return pages; + }; + + return ( + + + + onPageChange(Math.max(1, currentPage - 1))} + className={currentPage === 1 ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + {getPageNumbers().map((page, index) => ( + + {typeof page === "number" ? ( + onPageChange(page)} + isActive={currentPage === page} + className="cursor-pointer" + > + {page} + + ) : ( + + )} + + ))} + + + onPageChange(Math.min(totalPages, currentPage + 1))} + className={currentPage === totalPages ? "pointer-events-none opacity-50" : "cursor-pointer"} + /> + + + + ); +} diff --git a/apps/web/src/components/newsletters/NewsletterSkeleton.tsx b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx new file mode 100644 index 00000000..d2e8bb15 --- /dev/null +++ b/apps/web/src/components/newsletters/NewsletterSkeleton.tsx @@ -0,0 +1,19 @@ +import { Skeleton } from "@/components/ui/skeleton"; + +export function NewsletterSkeleton() { + return ( +
+
+
+ + + +
+
+ + +
+
+
+ ); +} diff --git a/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx b/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx new file mode 100644 index 00000000..b1d67457 --- /dev/null +++ b/apps/web/src/components/newsletters/PremiumUpgradePrompt.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { SparklesIcon, LockClosedIcon } from "@heroicons/react/24/outline"; +import PrimaryButton from "@/components/ui/custom-button"; + +export function PremiumUpgradePrompt() { + const router = useRouter(); + + return ( +
+
+
+
+ +
+
+ +

+ OX Newsletter +

+ +

+ Stay ahead in the open source world. Get curated insights on jobs, funding news, trending projects, upcoming trends, and expert tips. +

+ + router.push("/pricing")} + classname="w-full px-6" + > + + Unlock Premium + +
+
+ ); +} diff --git a/apps/web/src/components/ui/input-group.tsx b/apps/web/src/components/ui/input-group.tsx new file mode 100644 index 00000000..07773992 --- /dev/null +++ b/apps/web/src/components/ui/input-group.tsx @@ -0,0 +1,170 @@ +"use client" + +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" + +function InputGroup({ className, ...props }: React.ComponentProps<"div">) { + return ( +
textarea]:h-auto", + + // Variants based on alignment. + "has-[>[data-align=inline-start]]:[&>input]:pl-2", + "has-[>[data-align=inline-end]]:[&>input]:pr-2", + "has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>[data-align=block-start]]:[&>input]:pb-3", + "has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-end]]:[&>input]:pt-3", + + // Focus state. + "has-[[data-slot=input-group-control]:focus-visible]:ring-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1", + + // Error state. + "has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[[data-slot][aria-invalid=true]]:border-destructive dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40", + + className + )} + {...props} + /> + ) +} + +const inputGroupAddonVariants = cva( + "text-muted-foreground flex h-auto cursor-text select-none items-center justify-center gap-2 py-1.5 text-sm font-medium group-data-[disabled=true]/input-group:opacity-50 [&>kbd]:rounded-[calc(var(--radius)-5px)] [&>svg:not([class*='size-'])]:size-4", + { + variants: { + align: { + "inline-start": + "order-first pl-3 has-[>button]:ml-[-0.45rem] has-[>kbd]:ml-[-0.35rem]", + "inline-end": + "order-last pr-3 has-[>button]:mr-[-0.4rem] has-[>kbd]:mr-[-0.35rem]", + "block-start": + "[.border-b]:pb-3 order-first w-full justify-start px-3 pt-3 group-has-[>input]/input-group:pt-2.5", + "block-end": + "[.border-t]:pt-3 order-last w-full justify-start px-3 pb-3 group-has-[>input]/input-group:pb-2.5", + }, + }, + defaultVariants: { + align: "inline-start", + }, + } +) + +function InputGroupAddon({ + className, + align = "inline-start", + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
{ + if ((e.target as HTMLElement).closest("button")) { + return + } + e.currentTarget.parentElement?.querySelector("input")?.focus() + }} + {...props} + /> + ) +} + +const inputGroupButtonVariants = cva( + "flex items-center gap-2 text-sm shadow-none", + { + variants: { + size: { + xs: "h-6 gap-1 rounded-[calc(var(--radius)-5px)] px-2 has-[>svg]:px-2 [&>svg:not([class*='size-'])]:size-3.5", + sm: "h-8 gap-1.5 rounded-md px-2.5 has-[>svg]:px-2.5", + "icon-xs": + "size-6 rounded-[calc(var(--radius)-5px)] p-0 has-[>svg]:p-0", + "icon-sm": "size-8 p-0 has-[>svg]:p-0", + }, + }, + defaultVariants: { + size: "xs", + }, + } +) + +function InputGroupButton({ + className, + type = "button", + variant = "ghost", + size = "xs", + ...props +}: Omit, "size"> & + VariantProps) { + return ( +