Added ad-walled support, with counters and changed a lot of the current pages look to see funnel people toward that path

This commit is contained in:
Hieuhuy Pham 2025-03-12 00:58:47 -04:00
parent 9b186a462e
commit 906ad8a7a1
12 changed files with 449 additions and 95 deletions

View File

@ -1,10 +1,12 @@
import { fetchBookById } from "@/lib/api";
import { Book, Chapter } from "@/lib/types";
import { formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
import { encodeId, formatDateToMonthDayYear, markdownToHtml } from "@/lib/utils";
import ChapterDropdown from "@/components/ChapterDropdown";
import { Ad } from "@/lib/types";
import { Countdown } from "@/components/Countdown";
import Link from "next/link";
export type paramsType = Promise<{ bookId: string}>;
export type paramsType = Promise<{ bookId: string }>;
export const metadata = {
title: 'Null Translation Group',
@ -25,14 +27,15 @@ export default async function BookPage(props: { params: paramsType }) {
);
}
const {title, author, description, chapters, cover, translator_note, glossary} = book;
const { title, author, description, chapters, cover, translator_note, glossary } = book;
const english_glossary = glossary?.english_english;
const translator_note_html = await markdownToHtml(translator_note);
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
const sorted_chapters: Chapter[] = chapters.sort((a, b) => a.number - b.number);
const current_chapters = sorted_chapters.filter(chapter => new Date(chapter.release_datetime) < new Date());
const next_chapter = sorted_chapters.find((chapter) => new Date(chapter.release_datetime) > new Date());
const next_chapters = sorted_chapters.filter(chapter => new Date(chapter.release_datetime) > new Date())
const cover_media = cover?.at(0);
const recentChapters = sorted_chapters.length > 6 ? sorted_chapters.slice(sorted_chapters.length - 6, sorted_chapters.length) : sorted_chapters;
const recentChapters = sorted_chapters.length > 8 ? sorted_chapters.slice(sorted_chapters.length - 8, sorted_chapters.length - 2) : current_chapters;
return (
<div className="max-w-6xl mx-auto py-10 px-4">
<div className="flex flex-col items-center justify-center">
@ -41,6 +44,7 @@ export default async function BookPage(props: { params: paramsType }) {
className="rounded-lg object-cover w-64 h-96"
/>
<h1 className="text-5xl pb-2 font-bold">{title}</h1>
<h3 className="text-2xl text-green-600 dark:text-green-400 font-bold pb-2"> {book.release_rate} chapters/day</h3>
<a
href={Ad.patreon}
target="_blank"
@ -57,7 +61,7 @@ export default async function BookPage(props: { params: paramsType }) {
<strong>Translator:</strong> Null Translation Group
</p>
<p className="mb-6">Description: {description}</p>
<div className="mb-6" dangerouslySetInnerHTML={{__html: translator_note_html}} />
<div className="mb-6" dangerouslySetInnerHTML={{ __html: translator_note_html }} />
<h2 className="text-3xl font-semibold mb-4">Recent Chapters</h2>
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{recentChapters.map((chapter) => (
@ -77,10 +81,64 @@ export default async function BookPage(props: { params: paramsType }) {
))}
</ul>
<div className="flex items-center justify-between mb-4 pt-4">
<h2 className="text-3xl font-semibold">All Chapters</h2>
<ChapterDropdown chapters={sorted_chapters} bookId={bookId} />
<h2 className="text-3xl font-semibold">Next Chapters</h2>
</div>
{sorted_chapters.map((chapter) => (
{next_chapters.map((chapter) =>
(
<li key={chapter.id+"next_chapters"} className="mb-2 list-none relative">
<a
href={`/books/${bookId}/chapters/${chapter.documentId}`}
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200 relative opacity-50 pointer-events-none"
>
<h3 className="text-xl font-medium">
Chapter {chapter.number}: {chapter.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
<strong>Release Date:</strong>{" "}
{formatDateToMonthDayYear(new Date(chapter.release_datetime))}
</p>
</a>
{/* Overlay */}
<div className="absolute inset-0 bg-gray-400 opacity-50 rounded-lg"></div>
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xl font-bold text-red-500 bg-gray-800 bg-opacity-80 px-4 py-2 rounded-lg shadow-lg">
<Countdown release_datetime={chapter.release_datetime} />
</p>
{chapter.number === next_chapter?.number ? (
<Link
href={'/early?bookId=' + encodeId(bookId)}
className={`
bg-red-500 text-white py-2 px-4 mx-2 rounded
hover:bg-red-600
transition duration-200
`}
>
Unlock Early AdWalled Chapter
</Link>
)
: (
<Link
href={Ad.patreon}
className={`
bg-yellow-500 text-white py-2 px-4 mx-2 rounded
hover:bg-yellow-600
transition duration-200
`}
>
Join Patreon for More Early Chapters
</Link>
)}
</div>
</li>
)
)}
<div className="flex items-center justify-between mb-4 pt-4">
<h2 className="text-3xl font-semibold">All Chapters</h2>
<ChapterDropdown chapters={current_chapters} bookId={bookId} />
</div>
{sorted_chapters.map((chapter) =>
new Date(chapter.release_datetime) < new Date() ? (
<li key={chapter.id} className="mb-2 list-none">
<a
href={`/books/${bookId}/chapters/${chapter.documentId}`}
@ -90,11 +148,58 @@ export default async function BookPage(props: { params: paramsType }) {
Chapter {chapter.number}: {chapter.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
<strong>Release Date:</strong> {formatDateToMonthDayYear(new Date(chapter.release_datetime))}
<strong>Release Date:</strong>{" "}
{formatDateToMonthDayYear(new Date(chapter.release_datetime))}
</p>
</a>
</li>
))}
) : <li key={chapter.id} className="mb-2 list-none relative">
<a
href={`/books/${bookId}/chapters/${chapter.documentId}`}
className="block bg-white dark:bg-gray-800 rounded-lg shadow p-4 hover:shadow-lg transition duration-200 relative opacity-50 pointer-events-none"
>
<h3 className="text-xl font-medium">
Chapter {chapter.number}: {chapter.title}
</h3>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-2">
<strong>Release Date:</strong>{" "}
{formatDateToMonthDayYear(new Date(chapter.release_datetime))}
</p>
</a>
{/* Overlay */}
<div className="absolute inset-0 bg-gray-400 opacity-50 rounded-lg"></div>
<div className="absolute inset-0 flex items-center justify-center">
<p className="text-xl font-bold text-red-500 bg-gray-800 bg-opacity-80 px-4 py-2 rounded-lg shadow-lg">
<Countdown release_datetime={chapter.release_datetime} />
</p>
{chapter.number === next_chapter?.number ? (
<Link
href={'/early?bookId=' + encodeId(bookId)}
className={`
bg-red-500 text-white py-2 px-4 mx-2 rounded
hover:bg-red-600
transition duration-200
`}
>
Unlock Early Ad-Walled Chapter
</Link>
)
: (
<Link
href={Ad.patreon}
className={`
bg-yellow-500 text-white py-2 px-4 mx-2 rounded
hover:bg-yellow-600
transition duration-200
`}
>
Join Patreon for More Early Chapters
</Link>
)}
</div>
</li>
)}
{glossary && (
<div className="mt-8">
<h2 className="text-3xl font-semibold">Glossary</h2>

View File

@ -0,0 +1,58 @@
import React from "react";
import NavigationButtons from "@/components/NavigationButtons";
import ReportButton from "@/components/ReportButton";
import ChapterRenderer from "@/components/ChapterContentRenderer";
import { Chapter } from "@/lib/types";
import { fetchChapterByBookId, fetchGlossaryByBookId } from "@/lib/api";
import { decodeId, markdownToHtml } from "@/lib/utils";
export type paramsType = Promise<{ bookId: string; chapterId: string }>;
export const metadata = {
title: 'Null Translation Group',
description: 'This is the chapter page default description',
};
// Dynamic page component
export default async function ChapterPage(props: { params: paramsType}) {
let { bookId, chapterId } = await props.params;
bookId = decodeId(bookId);
chapterId = decodeId(chapterId);
let chapters: Chapter[];
try{
chapters = await fetchChapterByBookId(bookId, chapterId);
}
catch (error) {
console.error(error);
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Chapter not found !</h1></center>' }}></div>
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
</div>
)
}
const glossary_data = await fetchGlossaryByBookId(bookId);
const english_glossary = glossary_data.english_english;
const sorted_chapters:Chapter[] = chapters.sort((a, b) => a.number - b.number);
const current_chapter = sorted_chapters.find((chapter) => chapter.documentId === chapterId) || null;
const next_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number + 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : "";
const prev_chapter = current_chapter ? sorted_chapters.find((chapter) => chapter.number === current_chapter.number - 1 && new Date(chapter.release_datetime).getTime() <= new Date().getTime())?.documentId || "" : "";
const chapter_content_html = current_chapter ? await markdownToHtml(current_chapter.content) : "";
if(current_chapter === null){
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<div dangerouslySetInnerHTML={{ __html: '<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} /><center><NavigationButtons /><h1> Chapter not found !</h1></center>' }}></div>
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={""} nextChapter={""} />
</div>
)
}
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter} />
<div className="pt-4"></div>
<ChapterRenderer content={chapter_content_html} glossary={english_glossary} />
<NavigationButtons bookId={bookId} documentId={chapterId} prevChapter={prev_chapter} nextChapter={next_chapter}/>
<ReportButton bookId={bookId} chapterId={chapterId} />
</div>
);
}

111
src/app/early/page.tsx Normal file
View File

@ -0,0 +1,111 @@
import { fetchEarlyRelease } from "@/lib/api";
import { Chapter } from "@/lib/types";
import { encodeId } from "@/lib/utils";
import { Ad } from "@/lib/types";
export type searchParams = Promise<{ bookId: string }>;
export const metadata = {
title: 'Null Translation Group',
description: 'Null Translation Group Early Adcess Page',
};
export default async function BookPage({ searchParams }: { searchParams: searchParams }) {
const bookId = (await searchParams).bookId;
let early_chapters: Chapter[] = []
try {
early_chapters = await fetchEarlyRelease();
}
catch {
console.error("Error fetching early chapters")
return (
<div className="prose dark:prose-invert mx-auto p-6 bg-white dark:bg-gray-800 shadow-md rounded-lg mt-4">
<div dangerouslySetInnerHTML={{ __html: '<center><h1> Error fetching early chapters</h1></center>' }}></div>
</div>
)
}
const by_book_title = early_chapters.reduce<{ [key: string]: Chapter[] }>((acc, chapter) => {
if (chapter.book === undefined) {
return acc;
}
else if (!acc[chapter.book.title!]) {
acc[chapter.book.title!] = [];
}
else{
acc[chapter.book.title!].push(chapter);
}
return acc;
}
, {});
for (const book of Object.keys(by_book_title)) {
by_book_title[book].sort((a, b) => a.number - b.number);
by_book_title[book] = by_book_title[book].slice(0, 1);
}
early_chapters = Array.from(Object.values(by_book_title)).flat()
early_chapters.sort((a, b) => a.book?.title.localeCompare(b.book?.title || "") || a.number - b.number);
const early_chapter_from_params = early_chapters.find((chapter) => encodeId(chapter.book?.documentId || "") === bookId) || null;
return (
<div className="max-w-6xl mx-auto py-12 px-6">
<div className="flex flex-col items-center text-center space-y-6">
<h1 className="text-4xl font-bold">Early Adcess Page</h1>
<p className="text-lg text-gray-700 dark:text-gray-300">
Get early access to chapters by completing Google Offerwall tasks. Read ahead by <span className="font-bold">1 chapter !</span>
</p>
<p className="text-lg text-gray-700 dark:text-gray-300">
Tasks vary from watching an ad video to completing surveys and more.
</p>
<p className="text-lg text-gray-700 dark:text-gray-300">
Want to read even further? Join our Patreon to unlock more chapters!
</p>
<a
href={Ad.patreon}
target="_blank"
rel="noopener noreferrer"
className="bg-yellow-500 text-white font-semibold py-3 px-6 rounded-lg shadow-md hover:bg-yellow-600 transition duration-300"
>
Join Our Patreon Read More Ahead!
</a>
</div>
{early_chapter_from_params && <div className="mt-10 mb-6 text-center">
<a
href={`/early/${encodeId(early_chapter_from_params.book?.documentId || "")}/${encodeId(early_chapter_from_params.documentId)}`}
className="block bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition duration-300"
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate">
{early_chapter_from_params.book?.title}
</h3>
<h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 truncate">
Chapter {early_chapter_from_params.number}: {early_chapter_from_params.title}
</h4>
<p className="text-md text-red-600 dark:text-red-400 mt-3 font-semibold">
Click to Unlock Chapter
</p>
</a>
</div>
}
{/* Chapters Grid */}
<ul className="grid gap-6 sm:grid-cols-1 md:grid-cols-2 lg:grid-cols-3 mt-10">
{early_chapters.map((chapter) => (
<li key={chapter.id}>
<a
href={`/early/${encodeId(chapter.book?.documentId || "")}/${encodeId(chapter.documentId)}`}
className="block bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 hover:shadow-xl transition duration-300"
>
<h3 className="text-xl font-semibold text-gray-900 dark:text-white truncate">
{chapter.book?.title}
</h3>
<h4 className="text-lg font-medium text-gray-700 dark:text-gray-300 truncate">
Chapter {chapter.number}: {chapter.title}
</h4>
<p className="text-md text-red-600 dark:text-red-400 mt-3 font-semibold">
Click to Unlock Chapter
</p>
</a>
</li>
))}
</ul>
</div>
);
}

View File

@ -2,6 +2,7 @@ import { formatDateToMonthDayYear } from "@/lib/utils";
import { Chapter, Ad } from "@/lib/types";
import { fetchReleases } from "@/lib/api";
import Link from "next/link";
import { Countdown } from "@/components/Countdown";
export const metadata = {
@ -120,12 +121,9 @@ export default async function ReleasePage() {
<h4 className="text-md font-medium">
Chapter {chapter.number}: {chapter.title}
</h4>
<p className="text-sm">
Release date:{" "}
{formatDateToMonthDayYear(
new Date(chapter.release_datetime)
)}
</p>
<h3 className="text-md font-bold text-red-600 dark:text-red-400">
<Countdown release_datetime={chapter.release_datetime} />
</h3>
</li>
))}
</ul>

View File

@ -0,0 +1,35 @@
"use client";
import {useState, useEffect} from 'react';
export function Countdown({release_datetime}: {release_datetime: string}) {
const calculateTimeLeft = () => {
const now = new Date().getTime();
const releaseTime = new Date(release_datetime).getTime();
const difference = releaseTime - now;
if (difference <= 0) {
return "Released!";
}
const hours = Math.floor((difference / (1000 * 60 * 60)) % 24);
const minutes = Math.floor((difference / (1000 * 60)) % 60);
const seconds = Math.floor((difference / 1000) % 60);
return `${hours}h ${minutes}m ${seconds}s`;
}
const [timeLeft, setTimeLeft] = useState<string>(calculateTimeLeft());
useEffect(() => {
const timer = setTimeout(() => {
setTimeLeft(calculateTimeLeft());
}, 1000);
return () => clearTimeout(timer);
});
return (
<span>{timeLeft}</span>
);
}

View File

@ -11,7 +11,7 @@ const KofiWidget = () => {
if (typeof window !== 'undefined' && window.kofiWidgetOverlay) {
window.kofiWidgetOverlay.draw('nulltranslationgroup', {
'type': 'floating-chat',
'floating-chat.donateButton.text': 'Support me',
'floating-chat.donateButton.text': '',
'floating-chat.donateButton.background-color': '#00b9fe',
'floating-chat.donateButton.text-color': '#fff',
});

View File

@ -34,11 +34,12 @@ export default function Navbar() {
<Link href={"/releases"} className="hover:text-gray-400">
Releases
</Link>
<Link href={Ad.patreon} className="hover:text-gray-400">
<Link href={"/early"} className="hover:text-gray-400">
Early Access
</Link>
<Link href={Ad.patreon} className="hover:text-yellow-400 text-yellow-200 font-semibold">
Patreon
</Link>
</nav>
{/* Mobile Menu Button */}

View File

@ -2,7 +2,8 @@
import React from "react";
import { useRouter } from "next/navigation";
import { Ad } from "@/lib/types";
import Link from "next/link";
import { encodeId } from "@/lib/utils";
interface NavigationButtonsProps {
bookId: string;
@ -21,7 +22,6 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt
router.push(`/books/${bookId}`);
};
return (
<div className="mt-2 flex justify-between">
<button
@ -53,18 +53,16 @@ const NavigationButtons: React.FC<NavigationButtonsProps> = ({ bookId, prevChapt
Next Chapter
</button>
) : (
<a
href={Ad.patreon}
target="_blank"
rel="noopener noreferrer"
<Link
href={'/early?bookId=' + encodeId(bookId)}
className={`
bg-yellow-500 text-white py-2 px-4 rounded
hover:bg-yellow-600
transition duration-200
`}
>
Unreleased Chapters
</a>
Unlock Early Chapter
</Link>
)
}
</div>

View File

@ -13,10 +13,11 @@ const ReportButton: React.FC<ReportButtonProps> = ({ bookId, chapterId }) => {
const [isOpen, setIsOpen] = useState(false)
const [errorType, setErrorType] = useState('')
const [details, setDetails] = useState('')
const [email, setEmail] = useState('')
const handleSubmitReport = async (event: React.FormEvent<HTMLFormElement>) => {
// Implement report submission here
event.preventDefault()
const response = await createReport(errorType,details,bookId,chapterId)
const response = await createReport(errorType,details,bookId,chapterId,email)
//Linting be linting
if (response.status === 201){
alert('Report submitted successfully')
@ -26,11 +27,13 @@ const ReportButton: React.FC<ReportButtonProps> = ({ bookId, chapterId }) => {
}
setErrorType('')
setDetails('')
setEmail('')
setIsOpen(false)
}
const handleExit = (event: React.MouseEvent) => {
setErrorType('')
setDetails('')
setEmail('')
if (modalRef.current && !modalRef.current.contains(event.target as Node)) {
setIsOpen(false)
}
@ -51,6 +54,19 @@ const ReportButton: React.FC<ReportButtonProps> = ({ bookId, chapterId }) => {
<div className="mt-4">
<form className="p-6 rounded-lg max-w-md mx-auto space-y-4"
onSubmit={handleSubmitReport}>
<div>
<label className="block text-md font-semibold mb-1" htmlFor="report_email">
Email :
</label>
<input
id="report_email"
className="block w-full border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
type="email"
placeholder="If you want credit/update"
value={email}
onChange={(e) => setEmail(e.target.value)}>
</input>
</div>
<div>
<label className="block text-md font-semibold mb-1 dark:text-gray-300" htmlFor="error-type">
Select Error Type :
@ -80,6 +96,8 @@ const ReportButton: React.FC<ReportButtonProps> = ({ bookId, chapterId }) => {
onChange={(e) => setDetails(e.target.value)}
className="block w-full h-24 border border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 rounded-lg p-2 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 resize-none overflow-y-auto"/>
</div>
<div className="flex justify-between">
<button
type="submit"

View File

@ -109,10 +109,21 @@ export async function fetchBookChapterLinks(bookId: string): Promise<Book> {
*/
export async function fetchBookById(bookId: string): Promise<Book> {
const currentDateTime = new Date().toISOString();
const nextday = addDays(new Date(), 1).toISOString();
//[chapters][filters][release_datetime][$lte]=${currentDateTime}
const data = await fetchFromAPI<Book>(
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${currentDateTime}&populate=cover`
`/api/books/${bookId}?populate[chapters][filters][release_datetime][$lte]=${nextday}&populate=cover`
);
const stripped_data = []
for (const chapter of data[0].chapters) {
const stripped_chapter = chapter;
if (new Date(chapter.release_datetime).toISOString() > currentDateTime) {
stripped_chapter.content = "";
stripped_chapter.documentId = "";
}
stripped_data.push(stripped_chapter)
}
data[0].chapters = stripped_data;
//I do not know why the hell it refuse to populate glossary only 1 field is allow to be populated after ????????
const glossary = await fetchGlossaryByBookId(bookId);
data[0].glossary = glossary;
@ -140,11 +151,17 @@ export async function fetchEditors(): Promise<Editor[]> {
return data;
}
export async function fetchEarlyRelease(): Promise<Chapter[]> {
const current_datetime = new Date()
const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${current_datetime.toISOString()}&filters[release_datetime][$lte]=${addDays(current_datetime, 1).toISOString()}`);
return data;
}
export type ChapterRelease = { current_chapters: Chapter[], future_chapters: Chapter[] }
export async function fetchReleases(): Promise<{ current_chapters: Chapter[], future_chapters: Chapter[] }> {
const current_datetime = new Date()
const previous_week = subDays(current_datetime, 3);
const next_week = addDays(current_datetime, 3);
const previous_week = subDays(current_datetime, 1);
const next_week = addDays(current_datetime, 1);
const data = await fetchFromAPI<Chapter>(`/api/chapters/?populate[book][fields][0]=title&fields[0]=number&fields[1]=title&fields[2]=release_datetime&filters[release_datetime][$gte]=${previous_week.toISOString()}&filters[release_datetime][$lte]=${next_week.toISOString()}`);
const chapters: Chapter[] = data;
@ -168,13 +185,15 @@ export async function createReport(
details: string,
book_id: string,
chapter_id: string,
email: string
) {
const payload = {
data: {
error_type: error_type,
details: details,
book: book_id,
chapter: chapter_id
chapter: chapter_id,
report_email: email
}
}

View File

@ -65,6 +65,7 @@ export interface Book {
rating: number;
views: number;
readers: number;
release_rate: number;
}
export interface Announcement {

View File

@ -29,3 +29,13 @@ export async function markdownToHtml(markdown: string): Promise<string> {
}
return sanitizedHtml
}
export function encodeId(documentId: string): string {
const salt = "salty_aff"
return Buffer.from(documentId + salt).toString('base64')
}
export function decodeId(encodedId: string): string {
const salt = "salty_aff"
return Buffer.from(encodedId, 'base64').toString().replace(salt, '')
}