diff --git a/apps/api/src/routers/_app.ts b/apps/api/src/routers/_app.ts
index 782b436..57a0581 100644
--- a/apps/api/src/routers/_app.ts
+++ b/apps/api/src/routers/_app.ts
@@ -5,6 +5,7 @@ import { projectRouter } from "./projects.js";
import { authRouter } from "./auth.js";
import { paymentRouter } from "./payment.js";
import { z } from "zod";
+import { newsletterRouter } from "./newsletter.js";
const testRouter = router({
test: publicProcedure
@@ -20,7 +21,7 @@ export const appRouter = router({
user: userRouter,
project: projectRouter,
auth: authRouter,
- payment: paymentRouter,
+ newsletter: newsletterRouter,
});
export type AppRouter = typeof appRouter;
diff --git a/apps/api/src/routers/newsletter.ts b/apps/api/src/routers/newsletter.ts
new file mode 100644
index 0000000..1dc6616
--- /dev/null
+++ b/apps/api/src/routers/newsletter.ts
@@ -0,0 +1,13 @@
+import { z } from "zod";
+import { router, publicProcedure } from "../trpc.js";
+import { newsletterService } from "../services/newsletter.service.js";
+
+export const newsletterRouter = router({
+ list: publicProcedure
+ .input(z.object({ search: z.string().optional() }).optional())
+ .query(async ({ input }) => newsletterService.list(input?.search)),
+
+ bySlug: publicProcedure
+ .input(z.object({ slug: z.string() }))
+ .query(async ({ input }) => newsletterService.bySlug(input.slug)),
+});
\ No newline at end of file
diff --git a/apps/api/src/services/newsletter.service.ts b/apps/api/src/services/newsletter.service.ts
new file mode 100644
index 0000000..ab003f0
--- /dev/null
+++ b/apps/api/src/services/newsletter.service.ts
@@ -0,0 +1,45 @@
+import { Prisma } from "@prisma/client";
+import dbClient from "../prisma.js";
+
+const { prisma } = dbClient;
+
+export const newsletterService = {
+ list: async (search?: string) => {
+ const where: Prisma.NewsletterIssueWhereInput | undefined = search
+ ? {
+ OR: [
+ { title: { contains: search, mode: "insensitive" } },
+ { summary: { contains: search, mode: "insensitive" } },
+ { tags: { hasSome: search.split(" ").filter(Boolean) } },
+ ],
+ }
+ : undefined;
+
+ return prisma.newsletterIssue.findMany({
+ ...(where && { where }),
+ orderBy: { publishedAt: "desc" },
+ select: {
+ id: true,
+ slug: true,
+ title: true,
+ summary: true,
+ publishedAt: true,
+ readTime: true,
+ heroMediaUrl: true,
+ heroMediaType: true,
+ tags: true,
+ },
+ });
+ },
+
+ bySlug: async (slug: string) => {
+ return prisma.newsletterIssue.findUnique({
+ where: { slug },
+ include: {
+ sections: {
+ orderBy: { order: "asc" },
+ },
+ },
+ });
+ },
+};
\ No newline at end of file
diff --git a/apps/web/next.config.js b/apps/web/next.config.js
index fdebc20..cda1bae 100644
--- a/apps/web/next.config.js
+++ b/apps/web/next.config.js
@@ -6,6 +6,10 @@ const nextConfig = {
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
+ {
+ protocol: "https",
+ hostname: "assets.opensox.dev",
+ },
],
},
};
diff --git a/apps/web/package.json b/apps/web/package.json
index 2abd594..789fb1d 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -32,6 +32,7 @@
"posthog-js": "^1.203.1",
"react": "^18.2.0",
"react-dom": "^18.2.0",
+ "react-player": "^3.3.3",
"react-qr-code": "^2.0.18",
"react-tweet": "^3.2.1",
"superjson": "^2.2.5",
diff --git a/apps/web/public/images/Screenshot-2025-10-22.png b/apps/web/public/images/Screenshot-2025-10-22.png
new file mode 100644
index 0000000..c407c73
Binary files /dev/null and b/apps/web/public/images/Screenshot-2025-10-22.png differ
diff --git a/apps/web/public/images/welcome.jpg b/apps/web/public/images/welcome.jpg
new file mode 100644
index 0000000..d466df0
Binary files /dev/null and b/apps/web/public/images/welcome.jpg differ
diff --git a/apps/web/src/app/(main)/dashboard/layout.tsx b/apps/web/src/app/(main)/dashboard/layout.tsx
index 8bb4abb..2bfbd5e 100644
--- a/apps/web/src/app/(main)/dashboard/layout.tsx
+++ b/apps/web/src/app/(main)/dashboard/layout.tsx
@@ -17,14 +17,14 @@ export default function DashboardLayout({
-
+
{showFilters &&
}
-
{children}
+
{children}
);
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/[slug]/NewsletterDetailClient.tsx b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/NewsletterDetailClient.tsx
new file mode 100644
index 0000000..2d25210
--- /dev/null
+++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/NewsletterDetailClient.tsx
@@ -0,0 +1,82 @@
+"use client";
+
+import { Suspense, lazy } from "react";
+import Link from "next/link";
+import { useNewsletterDetail } from "@/app/api/newsletter";
+import { NewsletterIssue } from "@/data/newsletters";
+import { NewsletterHero } from "@/components/newsletter/NewsletterHero";
+import { NewsletterSectionRenderer } from "@/components/newsletter/NewsletterSectionRenderer";
+import { EngagementBar } from "../engagementBar";
+
+const LazyNewsletterSectionRenderer = lazy(() =>
+ import("@/components/newsletter/NewsletterSectionRenderer").then((mod) => ({
+ default: mod.NewsletterSectionRenderer,
+ }))
+);
+
+type NewsletterDetailClientProps = {
+ slug: string;
+ fallback: NewsletterIssue;
+};
+
+function SectionsSkeleton() {
+ return (
+
+ {[1, 2, 3].map((i) => (
+
+ ))}
+
+ );
+}
+
+function NavigationButtons() {
+ return (
+
+ );
+}
+
+export function NewsletterDetailClient({ slug, fallback }: NewsletterDetailClientProps) {
+ const { data, isLoading } = useNewsletterDetail(slug);
+ const issue = data ?? fallback;
+
+ if (!issue) return null;
+
+ return (
+
+ {/* @ts-ignore */}
+ } />
+
+ }>
+ {/* @ts-ignore */}
+
+
+
+ );
+}
\ No newline at end of file
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 0000000..a807743
--- /dev/null
+++ b/apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
@@ -0,0 +1,19 @@
+import { notFound } from "next/navigation";
+import { newsletterIssues } from "@/data/newsletters";
+import { NewsletterDetailClient } from "./NewsletterDetailClient";
+
+interface PageProps {
+ params: Promise<{ slug: string }>;
+}
+
+
+export default async function NewsletterDetail({ params }: PageProps) {
+ const { slug } = await params;
+ const fallback = newsletterIssues.find((issue) => issue.slug === slug);
+
+ if (!fallback) {
+ notFound();
+ }
+
+ return ;
+}
\ No newline at end of file
diff --git a/apps/web/src/app/(main)/dashboard/newsletters/engagementBar.tsx b/apps/web/src/app/(main)/dashboard/newsletters/engagementBar.tsx
new file mode 100644
index 0000000..93e6f0a
--- /dev/null
+++ b/apps/web/src/app/(main)/dashboard/newsletters/engagementBar.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { useCallback, useState } from "react";
+
+interface EngagementBarProps {
+ slug: string;
+}
+
+export type { EngagementBarProps };
+
+export function EngagementBar({ slug }: EngagementBarProps) {
+ const link = `/dashboard/newsletters/${slug}`;
+ const [copied, setCopied] = useState(false);
+
+ const handleShare = useCallback(async () => {
+ if (typeof window === "undefined") return;
+ const absoluteUrl = `${window.location.origin}${link}`;
+
+ // Try native share API first
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ url: absoluteUrl,
+ });
+ return;
+ } catch (error) {
+ console.error("Share failed:", error);
+ }
+ }
+
+ // Fallback to copy to clipboard
+ try {
+ await navigator.clipboard.writeText(absoluteUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.error("Unable to copy link", error);
+ }
+ }, [link]);
+
+ return (
+
+ );
+}
\ No newline at end of file
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 0000000..d1d9f9a
--- /dev/null
+++ b/apps/web/src/app/(main)/dashboard/newsletters/page.tsx
@@ -0,0 +1,103 @@
+"use client";
+
+import { useMemo, useState, Suspense, lazy } from "react";
+import { useNewsletterList } from "@/app/api/newsletter";
+import { newsletterIssues as mockIssues, type NewsletterIssue } from "@/data/newsletters";
+
+const LazyNewsletterCard = lazy(() =>
+ import("@/components/newsletter/NewsletterCard").then((mod) => ({
+ default: mod.NewsletterCard,
+ }))
+);
+
+function CardSkeleton() {
+ return (
+
+ );
+}
+
+function NewsletterCardList({ issues }: { issues: NewsletterIssue[] }) {
+ return (
+ <>
+ {issues.map((issue: NewsletterIssue) => (
+ }>
+
+
+ ))}
+ >
+ );
+}
+
+export default function NewsletterIndex() {
+ const [query, setQuery] = useState("");
+ const { data, isLoading } = useNewsletterList(query) as {
+ data: NewsletterIssue[] | undefined;
+ isLoading: boolean;
+ };
+ const issues = data?.length ? data : mockIssues;
+
+ const filteredIssues = useMemo(() => {
+ const normalized = query.trim().toLowerCase();
+ if (!normalized) return issues as NewsletterIssue[];
+
+ return (issues as NewsletterIssue[]).filter((issue) =>
+ `${issue.title} ${issue.summary}`
+ .toLowerCase()
+ .includes(normalized)
+ );
+ }, [issues, query]);
+
+ return (
+
+
+
Newsletter
+ {query && (
+
+ setQuery(e.target.value)}
+ placeholder="Search newsletters"
+ disabled={isLoading}
+ className="w-full bg-transparent text-sm text-ox-white placeholder:text-ox-gray-light focus:outline-none"
+ />
+
+ )}
+
+
+ {isLoading ? (
+
+ ) : filteredIssues.length > 0 ? (
+
+ ) : (
+
+ )}
+
+
+ );
+}
+
+function SkeletonList() {
+ return (
+
+ {[1, 2, 3].map((key) => (
+
+ ))}
+
+ );
+}
+
+function EmptyState({ query }: { query: string }) {
+ return (
+
+
No matches found
+
+ We couldn’t find any newsletters for “{query}”. Try a different keyword or clear the search
+ filter.
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/app/SessionWrapper.tsx b/apps/web/src/app/SessionWrapper.tsx
index 43e406e..8f6be48 100644
--- a/apps/web/src/app/SessionWrapper.tsx
+++ b/apps/web/src/app/SessionWrapper.tsx
@@ -11,5 +11,7 @@ export function SessionWrapper({
children: ReactNode;
session: Session | null;
}) {
- return {children};
+ return (
+ {children}
+ );
}
diff --git a/apps/web/src/app/api/newsletter.ts b/apps/web/src/app/api/newsletter.ts
new file mode 100644
index 0000000..c1e5182
--- /dev/null
+++ b/apps/web/src/app/api/newsletter.ts
@@ -0,0 +1,25 @@
+"use client";
+
+import { newsletterIssues } from "@/data/newsletters";
+import { trpc } from "@/lib/trpc";
+
+export const useNewsletterList = (search?: string) => {
+ // Use mock data immediately for instant load
+ const filteredData = search
+ ? newsletterIssues.filter((issue) =>
+ `${issue.title} ${issue.summary}`
+ .toLowerCase()
+ .includes(search.toLowerCase())
+ )
+ : newsletterIssues;
+
+ return {
+ data: filteredData,
+ isLoading: false,
+ error: null,
+ };
+};
+
+export const useNewsletterDetail = (slug: string) => {
+ return trpc.newsletter.bySlug.useQuery({ slug }, { enabled: Boolean(slug) });
+};
\ No newline at end of file
diff --git a/apps/web/src/app/globals.css b/apps/web/src/app/globals.css
index 6ae57e0..b4db592 100644
--- a/apps/web/src/app/globals.css
+++ b/apps/web/src/app/globals.css
@@ -73,4 +73,5 @@
html {
scroll-behavior: smooth;
+ scrollbar-gutter: stable;
}
\ 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 d9f3156..f2ebcc4 100644
--- a/apps/web/src/components/dashboard/Sidebar.tsx
+++ b/apps/web/src/components/dashboard/Sidebar.tsx
@@ -9,18 +9,22 @@ import { useShowSidebar } from "@/store/useShowSidebar";
import { signOut } from "next-auth/react";
const SIDEBAR_ROUTES = [
- {
- path: "/dashboard/home",
- label: "Home",
+ {
+ path: "/dashboard/home",
+ label: "Home",
},
- {
- path: "/dashboard/projects",
- label: "Projects",
+ {
+ path: "/dashboard/projects",
+ label: "Projects",
+ },
+ {
+ path: "/dashboard/newsletters",
+ label: "Newsletter",
},
];
const getSidebarLinkClassName = (currentPath: string, routePath: string) => {
- const isActive = currentPath === routePath;
+ const isActive = currentPath.startsWith(routePath);
return `${isActive ? "text-ox-purple" : "text-ox-white"}`;
};
diff --git a/apps/web/src/components/newsletter/NewsletterCard.tsx b/apps/web/src/components/newsletter/NewsletterCard.tsx
new file mode 100644
index 0000000..f65e5e0
--- /dev/null
+++ b/apps/web/src/components/newsletter/NewsletterCard.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import Image from "next/image";
+import Link from "next/link";
+import { useCallback, useMemo } from "react";
+import { NewsletterIssue } from "@/data/newsletters";
+
+interface NewsletterCardProps {
+ issue: NewsletterIssue;
+}
+
+export function NewsletterCard({ issue }: NewsletterCardProps) {
+
+ const formattedDate = useMemo(
+ () =>
+ new Date(issue.publishedAt).toLocaleDateString(undefined, {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }),
+ [issue.publishedAt]
+ );
+
+ const sharePath = useMemo(() => `/dashboard/newsletters/${issue.slug}`, [issue.slug]);
+
+ const handleShare = useCallback(
+ async (e: React.MouseEvent) => {
+ e.preventDefault();
+ e.stopPropagation();
+ if (typeof window === "undefined") return;
+ const absoluteUrl = `${window.location.origin}${sharePath}`;
+
+ if (navigator.share) {
+ try {
+ await navigator.share({
+ url: absoluteUrl,
+ });
+ return;
+ } catch {
+ /* fall back to clipboard */
+ }
+ }
+
+ if (navigator.clipboard) {
+ try {
+ await navigator.clipboard.writeText(absoluteUrl);
+ } catch (error) {
+ console.error("Unable to copy link", error);
+ }
+ }
+ },
+ [issue.summary, issue.title, sharePath]
+ );
+
+ return (
+
+
+
+
{issue.title}
+
+ {formattedDate}
+ ·
+ {issue.readTime}
+
+
+
+
+
+ );
+}
+
+function MediaPreview({ issue }: NewsletterCardProps) {
+ if (!issue.heroMediaUrl) {
+ return null;
+ }
+
+ if (issue.heroMediaType === "video") {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/newsletter/NewsletterHeader.tsx b/apps/web/src/components/newsletter/NewsletterHeader.tsx
new file mode 100644
index 0000000..0d4799f
--- /dev/null
+++ b/apps/web/src/components/newsletter/NewsletterHeader.tsx
@@ -0,0 +1,88 @@
+"use client";
+
+import Link from "next/link";
+import { usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import { InputHTMLAttributes } from "react";
+
+const NAV_LINKS = [
+ { href: "/dashboard/home", label: "Home" },
+ { href: "/pricing", label: "Pricing" },
+ { href: "https://discord.gg/nC9x4hef", label: "Community", external: true },
+];
+
+type Props = {
+ title: string;
+ subtitle?: string;
+ searchTerm?: string;
+ onSearch?: (value: string) => void;
+ actions?: React.ReactNode;
+ searchPlaceholder?: string;
+} & Pick, "disabled">;
+
+export function NewsletterHeader({
+ title,
+ subtitle,
+ searchTerm = "",
+ onSearch,
+ actions,
+ disabled,
+ searchPlaceholder = "Search newsletters",
+}: Props) {
+ const pathname = usePathname();
+ const handleSearchChange = (event: React.ChangeEvent) => {
+ onSearch?.(event.target.value);
+ };
+
+ return (
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/newsletter/NewsletterHero.tsx b/apps/web/src/components/newsletter/NewsletterHero.tsx
new file mode 100644
index 0000000..ef4a223
--- /dev/null
+++ b/apps/web/src/components/newsletter/NewsletterHero.tsx
@@ -0,0 +1,38 @@
+"use client";
+
+import { useMemo } from "react";
+import { NewsletterIssue } from "@/data/newsletters";
+
+interface NewsletterHeroProps {
+ issue: NewsletterIssue;
+ shareButton?: React.ReactNode;
+}
+
+export function NewsletterHero({ issue, shareButton }: NewsletterHeroProps) {
+ const formattedDate = useMemo(
+ () =>
+ new Date(issue.publishedAt).toLocaleDateString(undefined, {
+ weekday: "short",
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }),
+ [issue.publishedAt]
+ );
+
+ return (
+
+
+
+
+ {formattedDate}
+ · {issue.readTime}
+
+
{issue.title}
+
{issue.summary}
+
+ {shareButton &&
{shareButton}
}
+
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/components/newsletter/NewsletterSectionRenderer.tsx b/apps/web/src/components/newsletter/NewsletterSectionRenderer.tsx
new file mode 100644
index 0000000..8219dd8
--- /dev/null
+++ b/apps/web/src/components/newsletter/NewsletterSectionRenderer.tsx
@@ -0,0 +1,101 @@
+"use client";
+
+import Image from "next/image";
+import dynamic from "next/dynamic";
+import { NewsletterSection } from "@/data/newsletters";
+
+const ReactPlayer = dynamic(
+ () => import("react-player").then((mod) => mod.default),
+ {
+ ssr: false,
+ loading: () => null,
+ }
+) as any;
+
+interface NewsletterSectionRendererProps {
+ sections: NewsletterSection[];
+}
+
+export function NewsletterSectionRenderer({ sections }: NewsletterSectionRendererProps) {
+ return (
+
+ {sections.map((section, index) => {
+ if (section.type === "text") {
+ return (
+
+ {section.heading}
+ {section.body}
+
+ );
+ }
+
+ if (section.type === "media" && section.src && section.mediaSrc) {
+ return (
+
+
{section.heading}
+
+
{section.body}
+
+ );
+ }
+
+ if ((section.type === "image" || section.type === "video") && section.src) {
+ return (
+
+
{section.heading}
+ {section.type === "video" ? (
+
+
+
+ ) : (
+
+
+
+ )}
+
{section.body}
+
+ );
+ }
+
+ return null;
+ })}
+
+ );
+}
\ No newline at end of file
diff --git a/apps/web/src/data/newsletters.ts b/apps/web/src/data/newsletters.ts
new file mode 100644
index 0000000..09d887b
--- /dev/null
+++ b/apps/web/src/data/newsletters.ts
@@ -0,0 +1,147 @@
+export type NewsletterSection = {
+ type: "text" | "image" | "video" | "media";
+ heading: string;
+ body: string;
+ src?: string;
+ alt?: string;
+ mediaSrc?: string;
+ mediaType?: "image" | "video";
+ mediaAlt?: string;
+};
+
+export type NewsletterIssue = {
+ slug: string;
+ title: string;
+ summary: string;
+ publishedAt: string;
+ readTime: string;
+ heroMediaUrl: string;
+ heroMediaType: "image" | "video";
+ sections: NewsletterSection[];
+};
+
+export const newsletterIssues: NewsletterIssue[] = [
+ {
+ slug: "newsletter-122-week-1",
+ title: "OpenSox Premium Newsletter #122 – Week 1",
+ summary:
+ "Kick-off highlights covering new OSS bounties, premium AMA recaps, and tool updates.",
+ publishedAt: "2025-09-28",
+ readTime: "6 min read",
+ heroMediaUrl: "/images/Screenshot-2025-10-22.png",
+ heroMediaType: "image",
+ sections: [
+ {
+ type: "text",
+ heading: "Editor Note",
+ body:
+ "We welcomed 120 new premium builders and launched the bounty tracker that lets you filter rewards by language and project health.",
+ },
+ {
+ type: "media",
+ heading: "Project Spotlight & Automation Demo",
+ body:
+ "Glance at the redesigned contribution flow for OpenSox Sheets with live lint checks. Product engineers walk through the new PR triage bot that landed merged PRs 32% faster.",
+ src: "/images/welcome.jpg",
+ alt: "Screenshot of the OpenSox Sheets contribution flow",
+ mediaSrc: "/videos/os-demo.mp4",
+ mediaType: "video",
+ },
+ ],
+ },
+ {
+ slug: "newsletter-122-week-2",
+ title: "OpenSox Premium Newsletter #122 – Week 2",
+ summary:
+ "Contributor success stories, funding alerts, and community playbooks from last week.",
+ publishedAt: "2025-10-05",
+ readTime: "7 min read",
+ heroMediaUrl: "/images/Screenshot-2025-10-22.png",
+ heroMediaType: "image",
+ sections: [
+ {
+ type: "text",
+ heading: "In Case You Missed It",
+ body:
+ "Premium members unlocked three new grants and paired up through the mentor roulette. Catch all of the links in the recap.",
+ },
+ {
+ type: "image",
+ heading: "Photo Recap",
+ body:
+ "Scenes from the Bangalore premium jam featuring lightning talks and code clinics.",
+ src: "https://assets.opensox.dev/newsletters/122-week2-gallery.jpg",
+ alt: "Developers collaborating during the OpenSox Bangalore meetup",
+ },
+ {
+ type: "text",
+ heading: "Contributor Interview",
+ body:
+ "Sneha shares how she automated QA for 30+ repositories and dropped the test runtime by 18 minutes.",
+ },
+ ],
+ },
+ {
+ slug: "newsletter-122-week-3",
+ title: "OpenSox Premium Newsletter #122 – Week 3",
+ summary:
+ "Roadmap deep dives, beta launch previews, and curated resources for premium builders.",
+ publishedAt: "2025-10-12",
+ readTime: "8 min read",
+ heroMediaUrl: "https://assets.opensox.dev/newsletters/122-week3-hero.mp4",
+ heroMediaType: "video",
+ sections: [
+ {
+ type: "text",
+ heading: "Roadmap Deep Dive",
+ body:
+ "Discover what is landing next: initiative tracker rollouts, AI pairing tools, and the observability stack for maintainers.",
+ },
+ {
+ type: "video",
+ heading: "Beta Launch Walkthrough",
+ body:
+ "Product leads screen-share the newest workspace analytics shipping to premium members in November.",
+ src: "https://assets.opensox.dev/newsletters/122-week3-beta.mp4",
+ },
+ {
+ type: "text",
+ heading: "Resource Pack",
+ body:
+ "Slide decks, RFC templates, and reusable GitHub Actions to boost review throughput.",
+ },
+ ],
+ },
+ {
+ slug: "newsletter-122-week-4",
+ title: "OpenSox Premium Newsletter #122 – Week 4",
+ summary:
+ "Month-in-review featuring leaderboards, upcoming events, and premium-only office hours.",
+ publishedAt: "2025-10-19",
+ readTime: "5 min read",
+ heroMediaUrl: "https://assets.opensox.dev/newsletters/122-week4-hero.jpg",
+ heroMediaType: "image",
+ sections: [
+ {
+ type: "text",
+ heading: "Month In Review",
+ body:
+ "We wrapped with 480 merged PRs and featured maintainers who mentored the new cohort.",
+ },
+ {
+ type: "image",
+ heading: "Contributor Leaderboard",
+ body:
+ "Celebrate the top contributors and track how premium streaks unlock extra support hours.",
+ src: "https://assets.opensox.dev/newsletters/122-week4-leaderboard.png",
+ alt: "Leaderboard graphic highlighting top contributors",
+ },
+ {
+ type: "text",
+ heading: "Events Calendar",
+ body:
+ "RSVP for November clinics including roadmap AMAs, live pairing rooms, and design critiques.",
+ },
+ ],
+ },
+];
\ No newline at end of file
diff --git a/apps/web/src/types/next-auth.d.ts b/apps/web/src/types/next-auth.d.ts
index 22a4535..de7edd6 100644
--- a/apps/web/src/types/next-auth.d.ts
+++ b/apps/web/src/types/next-auth.d.ts
@@ -10,4 +10,9 @@ declare module "next-auth/jwt" {
interface JWT {
jwtToken?: string;
}
-}
\ No newline at end of file
+}
+declare module "react-player/lazy" {
+ import ReactPlayer from "react-player";
+
+ export default ReactPlayer;
+}
\ No newline at end of file
diff --git a/apps/web/tailwind.config.ts b/apps/web/tailwind.config.ts
index 8358752..948ca30 100644
--- a/apps/web/tailwind.config.ts
+++ b/apps/web/tailwind.config.ts
@@ -54,7 +54,7 @@ const config: Config = {
"ox-purple": "#9455f4",
"ox-purple-2": "#7A45C3",
"ox-gray": "rgb(75 85 99)",
- "ox-white": "text-slate-400",
+ "ox-white": "#ffffff",
"ox-black-1": "#0E0E10",
"ox-black-2": "#15161A",
},