Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/api/src/routers/_app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { projectRouter } from "./projects.js";
import { authRouter } from "./auth.js";
import { paymentRouter } from "./payment.js";
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Remove unused import.

The paymentRouter import is no longer used in the appRouter after being replaced with newsletterRouter. This is dead code that should be removed.

Apply this diff to remove the unused import:

-import { paymentRouter } from "./payment.js";
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
import { paymentRouter } from "./payment.js";
🤖 Prompt for AI Agents
In apps/api/src/routers/_app.ts around line 6, the import "paymentRouter" is
unused after replacing it with "newsletterRouter"; remove the unused import
statement from the file (delete the import line for paymentRouter) and verify
there are no remaining references to paymentRouter in the file or exports so the
file compiles and lints cleanly.

import { z } from "zod";
import { newsletterRouter } from "./newsletter.js";

const testRouter = router({
test: publicProcedure
Expand All @@ -20,7 +21,7 @@ export const appRouter = router({
user: userRouter,
project: projectRouter,
auth: authRouter,
payment: paymentRouter,
newsletter: newsletterRouter,
});

export type AppRouter = typeof appRouter;
13 changes: 13 additions & 0 deletions apps/api/src/routers/newsletter.ts
Original file line number Diff line number Diff line change
@@ -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)),
});
45 changes: 45 additions & 0 deletions apps/api/src/services/newsletter.service.ts
Original file line number Diff line number Diff line change
@@ -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" },
},
},
});
},
};
4 changes: 4 additions & 0 deletions apps/web/next.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@ const nextConfig = {
protocol: "https",
hostname: "avatars.githubusercontent.com",
},
{
protocol: "https",
hostname: "assets.opensox.dev",
},
],
},
};
Expand Down
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Binary file added apps/web/public/images/Screenshot-2025-10-22.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/web/public/images/welcome.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 changes: 3 additions & 3 deletions apps/web/src/app/(main)/dashboard/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ export default function DashboardLayout({
<div className="flex w-full h-16">
<DashboardHeader></DashboardHeader>
</div>
<div className="flex flex-row w-full">
<div className="flex flex-row w-full overflow-hidden">
{showFilters && <FiltersContainer></FiltersContainer>}
<aside
className={`w-48 md:w-[40%] xl:w-[20%] ${showSidebar ? "block relative" : "hidden"} xl:block`}
className={`flex-shrink-0 w-48 md:w-[40%] xl:w-[20%] ${showSidebar ? "block relative" : "hidden"} xl:block overflow-y-auto`}
>
<Sidebar></Sidebar>
</aside>
<main className="flex-grow">{children}</main>
<main className="flex-grow overflow-y-auto">{children}</main>
</div>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 (
<div className="space-y-8">
{[1, 2, 3].map((i) => (
<div key={i} className="space-y-3 animate-pulse">
<div className="h-8 w-48 rounded-lg bg-ox-black-2" />
<div className="space-y-2">
<div className="h-4 w-full rounded bg-ox-black-2" />
<div className="h-4 w-5/6 rounded bg-ox-black-2" />
</div>
</div>
))}
</div>
);
}

function NavigationButtons() {
return (
<div className="flex gap-3 flex-wrap">
<Link
href="/dashboard/newsletters"
className="rounded-full border border-ox-purple px-6 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
Home
</Link>
<Link
href="/pricing"
className="rounded-full border border-ox-purple px-6 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
Pricing
</Link>
<a
href="https://discord.gg/opensox"
target="_blank"
rel="noopener noreferrer"
className="rounded-full border border-ox-purple px-6 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
Community
</a>
</div>
);
}

export function NewsletterDetailClient({ slug, fallback }: NewsletterDetailClientProps) {
const { data, isLoading } = useNewsletterDetail(slug);
const issue = data ?? fallback;

if (!issue) return null;

return (
<div className="flex flex-col gap-6 p-4 xl:p-6">
{/* @ts-ignore */}
<NewsletterHero issue={issue} shareButton={<EngagementBar slug={slug} />} />
<NavigationButtons />
<Suspense fallback={<SectionsSkeleton />}>
{/* @ts-ignore */}
<LazyNewsletterSectionRenderer sections={issue.sections} />
</Suspense>
</div>
);
}
19 changes: 19 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/[slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 <NewsletterDetailClient slug={slug} fallback={fallback} />;
}
49 changes: 49 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/engagementBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<button
onClick={handleShare}
className="rounded-full border border-ox-purple px-4 py-2 text-xs font-semibold uppercase tracking-wide text-ox-purple transition hover:bg-ox-purple hover:text-ox-white"
>
{copied ? "Copied!" : "Share"}
</button>
);
}
103 changes: 103 additions & 0 deletions apps/web/src/app/(main)/dashboard/newsletters/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="h-20 animate-pulse rounded-3xl border border-ox-gray bg-ox-black-2" />
);
}

function NewsletterCardList({ issues }: { issues: NewsletterIssue[] }) {
return (
<>
{issues.map((issue: NewsletterIssue) => (
<Suspense key={issue.slug} fallback={<CardSkeleton />}>
<LazyNewsletterCard issue={issue} />
</Suspense>
))}
</>
);
}

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<NewsletterIssue[]>(() => {
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 (
<div className="flex flex-col gap-6 p-4 xl:p-6">
<div className="rounded-3xl border border-ox-gray bg-ox-black-1 px-5 py-6 shadow-sm sm:px-7">
<h1 className="text-center text-3xl font-semibold text-ox-white md:text-4xl">Newsletter</h1>
{query && (
<div className="mt-4 flex w-full max-w-lg items-center rounded-full border border-ox-gray bg-ox-black-2 px-4 py-2">
<input
type="search"
value={query}
onChange={(e) => 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"
/>
</div>
)}
</div>
<section className="grid gap-3">
{isLoading ? (
<SkeletonList />
) : filteredIssues.length > 0 ? (
<NewsletterCardList issues={filteredIssues} />
) : (
<EmptyState query={query} />
)}
</section>
</div>
);
}

function SkeletonList() {
return (
<div className="space-y-3">
{[1, 2, 3].map((key) => (
<div
key={key}
className="h-32 animate-pulse rounded-3xl border border-ox-gray bg-ox-black-2"
/>
))}
</div>
);
}

function EmptyState({ query }: { query: string }) {
return (
<div className="rounded-3xl border border-dashed border-ox-gray bg-ox-black-1 p-10 text-center">
<h2 className="text-lg font-semibold text-ox-white">No matches found</h2>
<p className="mt-2 text-sm text-ox-gray-light">
We couldn’t find any newsletters for “{query}”. Try a different keyword or clear the search
filter.
</p>
</div>
);
}
4 changes: 3 additions & 1 deletion apps/web/src/app/SessionWrapper.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,7 @@ export function SessionWrapper({
children: ReactNode;
session: Session | null;
}) {
return <SessionProvider session={session}>{children}</SessionProvider>;
return (
<SessionProvider session={session}>{children}</SessionProvider>
);
}
Loading