Skip to content

Commit a87627a

Browse files
authored
Merge pull request #158 from huamanraj/feat_newsletter
feat: newsletter page for pro users #155
2 parents d424df8 + 80086d5 commit a87627a

28 files changed

Lines changed: 1923 additions & 67 deletions

.gitmodules

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
[submodule "apps/web/src/content/newsletters-premium"]
2+
path = apps/web/src/content/newsletters-premium
3+
url = git@github.com:apsinghdev/opensox-newsletters-premium.git

apps/web/.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,5 @@ yarn-error.log*
3838
# typescript
3939
*.tsbuildinfo
4040
next-env.d.ts
41+
42+
src/content/newsletters-premium/

apps/web/next.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ const nextConfig = {
1919
experimental: {
2020
optimizePackageImports: ['lucide-react', '@heroicons/react'],
2121
},
22-
swcMinify: true,
2322
};
2423

2524
module.exports = nextConfig;

apps/web/package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"private": true,
55
"scripts": {
66
"dev": "next dev",
7-
"build": "next build",
7+
"build": "bash ./scripts/init-submodules.sh && next build",
88
"start": "next start",
99
"lint": "next lint"
1010
},
@@ -15,7 +15,7 @@
1515
"@radix-ui/react-checkbox": "^1.1.2",
1616
"@radix-ui/react-label": "^2.1.0",
1717
"@radix-ui/react-radio-group": "^1.2.1",
18-
"@radix-ui/react-slot": "^1.1.0",
18+
"@radix-ui/react-slot": "^1.2.3",
1919
"@tanstack/react-query": "^5.90.2",
2020
"@trpc/client": "^11.6.0",
2121
"@trpc/react-query": "^11.6.0",
@@ -27,7 +27,9 @@
2727
"dompurify": "^3.3.0",
2828
"framer-motion": "^11.15.0",
2929
"geist": "^1.5.1",
30+
"gray-matter": "^4.0.3",
3031
"lucide-react": "^0.456.0",
32+
"marked": "^17.0.0",
3133
"next": "15.5.3",
3234
"next-auth": "^4.24.11",
3335
"next-themes": "^0.4.3",
@@ -43,6 +45,7 @@
4345
},
4446
"devDependencies": {
4547
"@types/dompurify": "^3.2.0",
48+
"@tailwindcss/line-clamp": "^0.4.4",
4649
"@types/node": "^20",
4750
"@types/react": "^18",
4851
"@types/react-dom": "^18",
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/bin/bash
2+
# initialize git submodules during vercel build
3+
4+
set -e # Exit immediately on any error
5+
6+
# setup ssh for private submodule
7+
if [ -n "$GIT_SSH_KEY" ]; then
8+
mkdir -p ~/.ssh || { echo "Failed to create ~/.ssh directory" >&2; exit 1; }
9+
printf '%s' "$GIT_SSH_KEY" > ~/.ssh/id_ed25519 || { echo "Failed to write SSH key" >&2; exit 1; }
10+
chmod 600 ~/.ssh/id_ed25519 || { echo "Failed to set SSH key permissions" >&2; exit 1; }
11+
ssh-keyscan -t ed25519 github.com >> ~/.ssh/known_hosts 2>/dev/null || true
12+
fi
13+
14+
# initialize and update submodules
15+
git submodule update --init --recursive --remote
16+
17+
echo "submodules initialized successfully"
18+
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
"use client";
2+
3+
import "@/styles/newsletter.css";
4+
5+
import { useEffect, useState } from "react";
6+
import { useParams, useRouter } from "next/navigation";
7+
import { CalendarIcon, ClockIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
8+
import { Skeleton } from "@/components/ui/skeleton";
9+
import { useSubscription } from "@/hooks/useSubscription";
10+
import { PremiumUpgradePrompt } from "@/components/newsletters/PremiumUpgradePrompt";
11+
12+
interface NewsletterData {
13+
title: string;
14+
date: string;
15+
readTime: string;
16+
content: string;
17+
}
18+
19+
function NewsletterSkeleton() {
20+
return (
21+
<div className="w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8">
22+
{/* Header skeleton */}
23+
<div className="mb-8 pb-6 border-b border-zinc-800">
24+
<Skeleton className="h-8 w-full max-w-3xl mb-4 bg-zinc-800" />
25+
<Skeleton className="h-8 w-3/4 mb-6 bg-zinc-800" />
26+
<div className="flex flex-wrap items-center gap-3">
27+
<Skeleton className="h-4 w-32 bg-zinc-800" />
28+
<Skeleton className="h-4 w-4 bg-zinc-800 rounded-full" />
29+
<Skeleton className="h-4 w-24 bg-zinc-800" />
30+
</div>
31+
</div>
32+
33+
{/* Content skeleton */}
34+
<div className="space-y-6">
35+
{/* Paragraph 1 */}
36+
<div className="space-y-3">
37+
<Skeleton className="h-4 w-full bg-zinc-800" />
38+
<Skeleton className="h-4 w-full bg-zinc-800" />
39+
<Skeleton className="h-4 w-11/12 bg-zinc-800" />
40+
</div>
41+
42+
{/* Heading */}
43+
<Skeleton className="h-7 w-2/3 bg-zinc-800 mt-8" />
44+
45+
{/* Paragraph 2 */}
46+
<div className="space-y-3">
47+
<Skeleton className="h-4 w-full bg-zinc-800" />
48+
<Skeleton className="h-4 w-full bg-zinc-800" />
49+
<Skeleton className="h-4 w-10/12 bg-zinc-800" />
50+
<Skeleton className="h-4 w-full bg-zinc-800" />
51+
</div>
52+
53+
{/* Image placeholder */}
54+
<Skeleton className="h-64 w-full bg-zinc-800 rounded-lg mt-6" />
55+
56+
{/* Heading */}
57+
<Skeleton className="h-7 w-1/2 bg-zinc-800 mt-8" />
58+
59+
{/* Paragraph 3 */}
60+
<div className="space-y-3">
61+
<Skeleton className="h-4 w-full bg-zinc-800" />
62+
<Skeleton className="h-4 w-full bg-zinc-800" />
63+
<Skeleton className="h-4 w-9/12 bg-zinc-800" />
64+
</div>
65+
66+
{/* List items */}
67+
<div className="space-y-2 mt-4">
68+
<Skeleton className="h-4 w-5/6 bg-zinc-800" />
69+
<Skeleton className="h-4 w-4/5 bg-zinc-800" />
70+
<Skeleton className="h-4 w-11/12 bg-zinc-800" />
71+
</div>
72+
73+
{/* Final paragraph */}
74+
<div className="space-y-3 mt-6">
75+
<Skeleton className="h-4 w-full bg-zinc-800" />
76+
<Skeleton className="h-4 w-full bg-zinc-800" />
77+
<Skeleton className="h-4 w-3/4 bg-zinc-800" />
78+
</div>
79+
</div>
80+
</div>
81+
);
82+
}
83+
84+
export default function NewsletterPage() {
85+
const params = useParams();
86+
const router = useRouter();
87+
const slug = params.slug as string;
88+
const [newsletter, setNewsletter] = useState<NewsletterData | null>(null);
89+
const [loading, setLoading] = useState(true);
90+
const [error, setError] = useState<'unauthorized' | 'forbidden' | 'not-found' | null>(null);
91+
const { isPaidUser, isLoading: subscriptionLoading } = useSubscription();
92+
93+
useEffect(() => {
94+
if (subscriptionLoading) return;
95+
96+
fetch(`/api/newsletters/${slug}`)
97+
.then(async (res) => {
98+
if (res.status === 401) {
99+
setError('unauthorized');
100+
setLoading(false);
101+
return null;
102+
}
103+
if (res.status === 403) {
104+
setError('forbidden');
105+
setLoading(false);
106+
return null;
107+
}
108+
if (!res.ok) {
109+
setError('not-found');
110+
setLoading(false);
111+
return null;
112+
}
113+
return res.json();
114+
})
115+
.then((data) => {
116+
if (data && !data.error) {
117+
setNewsletter(data);
118+
setError(null);
119+
}
120+
setLoading(false);
121+
})
122+
.catch(() => {
123+
setError('not-found');
124+
setLoading(false);
125+
});
126+
}, [slug, subscriptionLoading]);
127+
128+
if (subscriptionLoading) {
129+
return (
130+
<div className="w-full h-full overflow-auto">
131+
<div className="w-full max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
132+
<Skeleton className="h-8 w-32 mb-6 bg-zinc-800" />
133+
<NewsletterSkeleton />
134+
</div>
135+
</div>
136+
);
137+
}
138+
139+
if (!isPaidUser || error === 'forbidden') {
140+
return <PremiumUpgradePrompt />;
141+
}
142+
143+
if (error === 'unauthorized') {
144+
router.push('/login');
145+
return null;
146+
}
147+
148+
if (loading) {
149+
return (
150+
<div className="w-full h-full overflow-auto">
151+
<div className="w-full max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
152+
<Skeleton className="h-8 w-32 mb-6 bg-zinc-800" />
153+
<NewsletterSkeleton />
154+
</div>
155+
</div>
156+
);
157+
}
158+
159+
if (!newsletter) {
160+
return (
161+
<div className="w-full h-full flex items-center justify-center px-4">
162+
<div className="text-center">
163+
<h2 className="text-xl font-semibold text-white mb-2">Newsletter not found</h2>
164+
<p className="text-zinc-400 text-sm mb-4">The newsletter you&apos;re looking for doesn&apos;t exist.</p>
165+
<button
166+
onClick={() => router.push("/dashboard/newsletters")}
167+
className="inline-flex items-center gap-2 px-4 py-2 bg-ox-purple hover:bg-purple-600 text-white text-sm rounded-lg transition-colors"
168+
>
169+
<ArrowLeftIcon className="size-4" />
170+
Back to newsletters
171+
</button>
172+
</div>
173+
</div>
174+
);
175+
}
176+
177+
return (
178+
<div className="w-full h-full overflow-auto">
179+
<div className="w-full max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6 sm:py-8">
180+
{/* Back Button */}
181+
<button
182+
onClick={() => router.back()}
183+
className="inline-flex items-center gap-1.5 text-zinc-400 hover:text-white mb-6 transition-colors text-sm group"
184+
>
185+
<ArrowLeftIcon className="size-4 shrink-0 group-hover:-translate-x-0.5 transition-transform" />
186+
<span>Back</span>
187+
</button>
188+
189+
<article>
190+
{/* Header */}
191+
<header className="mb-8 pb-6 border-b border-zinc-800">
192+
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold text-white mb-4 leading-tight">
193+
{newsletter.title}
194+
</h1>
195+
196+
{/* Metadata */}
197+
<div className="flex flex-wrap items-center gap-3 text-sm text-zinc-400">
198+
<span className="flex items-center gap-1.5">
199+
<CalendarIcon className="size-4 shrink-0" />
200+
{new Date(newsletter.date).toLocaleDateString("en-US", {
201+
month: "short",
202+
day: "numeric",
203+
year: "numeric",
204+
})}
205+
</span>
206+
<span className="text-zinc-600"></span>
207+
<span className="flex items-center gap-1.5">
208+
<ClockIcon className="size-4 shrink-0" />
209+
{newsletter.readTime}
210+
</span>
211+
</div>
212+
</header>
213+
214+
{/* Content */}
215+
<div
216+
className="newsletter-content prose prose-invert max-w-none
217+
prose-headings:text-white prose-headings:font-semibold
218+
prose-h2:text-xl prose-h2:sm:text-2xl prose-h2:mt-8 prose-h2:mb-3
219+
prose-h3:text-lg prose-h3:sm:text-xl prose-h3:mt-6 prose-h3:mb-2
220+
prose-p:text-zinc-300 prose-p:leading-relaxed prose-p:mb-4
221+
prose-a:text-ox-purple prose-a:no-underline hover:prose-a:text-purple-400
222+
prose-strong:text-white prose-strong:font-medium
223+
prose-ul:text-zinc-300 prose-ol:text-zinc-300
224+
prose-li:my-1
225+
prose-blockquote:border-l-2 prose-blockquote:border-ox-purple prose-blockquote:pl-4
226+
prose-blockquote:italic prose-blockquote:text-zinc-400
227+
prose-code:text-ox-purple prose-code:bg-zinc-900 prose-code:px-1.5 prose-code:py-0.5 prose-code:rounded prose-code:text-sm
228+
prose-pre:bg-zinc-900 prose-pre:border prose-pre:border-zinc-800
229+
prose-img:rounded-lg"
230+
dangerouslySetInnerHTML={{ __html: newsletter.content }}
231+
/>
232+
</article>
233+
</div>
234+
</div>
235+
);
236+
}

0 commit comments

Comments
 (0)