Skip to content

Commit

Permalink
chore: Refactor and migrate Paywall to app router, INTER-911, INTER…
Browse files Browse the repository at this point in the history
…-459 (#161)

* chore: move everything to `app` wip

* chore: fix build and tests

* chore: simplify messages

* chore: fix embeds
  • Loading branch information
JuroUhlar authored Sep 23, 2024
1 parent 5dfa010 commit 9ca5f87
Show file tree
Hide file tree
Showing 25 changed files with 197 additions and 313 deletions.
20 changes: 19 additions & 1 deletion e2e/paywall.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { test, expect } from '@playwright/test';
import { assertAlert, blockGoogleTagManager, resetScenarios } from './e2eTestUtils';
import { TEST_IDS } from '../src/client/testIDs';
import { PAYWALL_COPY } from '../src/server/paywall/paywallCopy';
import { PAYWALL_COPY } from '../src/app/paywall/api/copy';

test.beforeEach(async ({ page }) => {
await blockGoogleTagManager(page);
Expand All @@ -26,5 +26,23 @@ test.describe('Paywall', () => {
await articles.nth(2).click();
await assertAlert({ page, text: PAYWALL_COPY.limitReached, severity: 'error' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeHidden();
await page.goBack();

// You can still re-read articles you have already viewed
await articles.first().click();
await assertAlert({ page, text: PAYWALL_COPY.lastArticle, severity: 'warning' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeVisible();
});

test('Rereading already viewed article does not increase view count', async ({ page }) => {
const articles = await page.getByTestId(TEST_IDS.paywall.articleCard);

await articles.first().click();
await assertAlert({ page, text: PAYWALL_COPY.nArticlesRemaining(1), severity: 'warning' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeVisible();
await page.reload();

await assertAlert({ page, text: PAYWALL_COPY.nArticlesRemaining(1), severity: 'warning' });
await expect(page.getByTestId(TEST_IDS.paywall.articleContent)).toBeVisible();
});
});
13 changes: 7 additions & 6 deletions src/pages/paywall/index.tsx → src/app/paywall/Paywall.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
'use client';

import { UseCaseWrapper } from '../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import { ARTICLES } from '../../server/paywall/articles';
import { CustomPageProps } from '../_app';
import { USE_CASES } from '../../client/components/common/content';
import { ArticleCard, ArticleGrid } from '../../client/components/paywall/ArticleGrid';
import { ArticleCard, ArticleGrid } from './components/ArticleGrid';
import { ARTICLES } from './api/articles';

/**
* Main Paywall use case page with article listing
*/
export default function Paywall({ embed }: CustomPageProps) {
export default function Paywall({ embed }: { embed: boolean }) {
const heroArticle = ARTICLES[0];
const gridArticles = ARTICLES.slice(1);
return (
<UseCaseWrapper useCase={USE_CASES.paywall} embed={embed}>
{heroArticle && <ArticleCard article={heroArticle} embed={embed} isHeroArticle />}
<UseCaseWrapper useCase={USE_CASES.paywall}>
{heroArticle && <ArticleCard article={heroArticle} isHeroArticle embed={embed} />}
{gridArticles && <ArticleGrid articles={gridArticles} embed={embed} />}
</UseCaseWrapper>
);
Expand Down
97 changes: 97 additions & 0 deletions src/app/paywall/api/article/[id]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { getAndValidateFingerprintResult, Severity } from '../../../../../server/checks';
import { NextResponse } from 'next/server';
import { ArticleData, ARTICLES } from '../../articles';
import { ArticleViewDbModel } from '../../database';
import { getTodayDateRange } from '../../../../../shared/utils/date';
import { Op } from 'sequelize';
import { PAYWALL_COPY } from '../../copy';

export type ArticleResponse = {
message: string;
severity: Severity;
article?: ArticleData;
remainingViews?: number;
viewedArticles?: number;
};

export type ArticleRequestPayload = {
requestId: string;
};

const ARTICLE_VIEW_LIMIT = 2;

/**
* Fetches article by its ID. Supports paywall logic, which means that we keep track of how many articles were viewed by a given user.
* If a user has exceeded limit of articles that he can view for free, we return an error.
*/
export async function POST(
req: Request,
{ params }: { params: { id: string } },
): Promise<NextResponse<ArticleResponse>> {
const { requestId } = (await req.json()) as ArticleRequestPayload;

// Get the full Identification result from Fingerprint Server API and validate its authenticity
const fingerprintResult = await getAndValidateFingerprintResult({ requestId, req });
if (!fingerprintResult.okay) {
return NextResponse.json({ severity: 'error', message: fingerprintResult.error }, { status: 403 });
}

// Get visitorId from the Server API Identification event
const visitorId = fingerprintResult.data.products?.identification?.data?.visitorId;
if (!visitorId) {
return NextResponse.json({ severity: 'error', message: 'Visitor ID not found.' }, { status: 403 });
}

const articleId = params.id;
const article = ARTICLES.find((article) => article.id === articleId);
if (!article) {
return NextResponse.json({ severity: 'error', message: 'Article not found' }, { status: 404 });
}

// Check how many articles were viewed by this visitor ID today
const oldViewCount = await ArticleViewDbModel.count({
where: { visitorId, timestamp: { [Op.between]: getTodayDateRange() } },
});

// Check if this visitor has already viewed this specific article
const viewedThisArticleBefore = await ArticleViewDbModel.findOne({
where: {
visitorId,
articleId,
timestamp: { [Op.between]: getTodayDateRange() },
},
});

// If the visitor is trying to view a new article beyond the daily limit, return an error
if (oldViewCount >= ARTICLE_VIEW_LIMIT && !viewedThisArticleBefore) {
return NextResponse.json({ severity: 'error', message: PAYWALL_COPY.limitReached }, { status: 403 });
}

// Otherwise, save the article view and return the article
await saveArticleView(articleId, visitorId);
const newViewCount = viewedThisArticleBefore ? oldViewCount : oldViewCount + 1;
const articlesRemaining = ARTICLE_VIEW_LIMIT - newViewCount;
return NextResponse.json({
severity: 'warning',
message: articlesRemaining > 0 ? PAYWALL_COPY.nArticlesRemaining(articlesRemaining) : PAYWALL_COPY.lastArticle,
article,
articlesRemaining,
articlesViewed: newViewCount,
});
}

/**
* Saves article view into the database. If it already exists, we update its timestamp.
*/
async function saveArticleView(articleId: string, visitorId: string) {
const [view, created] = await ArticleViewDbModel.findOrCreate({
where: { articleId, visitorId, timestamp: { [Op.between]: getTodayDateRange() } },
defaults: { articleId, visitorId, timestamp: new Date() },
});

if (!created) {
view.timestamp = new Date();
await view.save();
}
return view;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/**
* Mock articles "database". In the real world, these articles could be fetched from anywhere.
* */

import ArticleHeroSvg from './images/articleHero.svg';
import GenericAvatarImage from './images/genericAvatar.png';

Expand Down
File renamed without changes.
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Attributes, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
import { sequelize } from '../server';
import { sequelize } from '../../../server/server';

interface ArticleViewAttributes
extends Model<InferAttributes<ArticleViewAttributes>, InferCreationAttributes<ArticleViewAttributes>> {
Expand Down
File renamed without changes
Original file line number Diff line number Diff line change
@@ -1,45 +1,43 @@
import { useRouter } from 'next/router';
'use client';

import { Skeleton, SkeletonTypeMap } from '@mui/material';
import { UseCaseWrapper } from '../../../../client/components/common/UseCaseWrapper/UseCaseWrapper';
import { CustomPageProps } from '../../../_app';
import { USE_CASES } from '../../../../client/components/common/content';

import Image from 'next/image';
import styles from '../../paywall.module.scss';
import { Alert } from '../../../../client/components/common/Alert/Alert';
import { ARTICLES } from '../../../../server/paywall/articles';
import { useVisitorData } from '@fingerprintjs/fingerprintjs-pro-react';
import { useQuery } from 'react-query';
import { ArticleResponse } from '../../../api/paywall/article/[id]';
import { TEST_IDS } from '../../../../client/testIDs';
import { ArticleGrid, Byline } from '../../../../client/components/paywall/ArticleGrid';
import { PAYWALL_COPY } from '../../../../server/paywall/paywallCopy';
import { ArticleGrid, Byline } from '../../components/ArticleGrid';
import { BackArrow } from '../../../../client/components/common/BackArrow/BackArrow';
import { ArticleRequestPayload, ArticleResponse } from '../../api/article/[id]/route';
import { ARTICLES } from '../../api/articles';

function ArticleSkeleton({ animation = false }: { animation?: SkeletonTypeMap['props']['animation'] }) {
const skeletons = Array.from({ length: 4 }).map((_, index) => <Skeleton key={index} animation={animation} />);
return <>{skeletons}</>;
}

export default function Article({ embed }: CustomPageProps) {
const router = useRouter();
const articleId = router.query.id;

export function Article({ articleId, embed }: { articleId: string; embed: boolean }) {
const { getData: getVisitorData } = useVisitorData({
ignoreCache: true,
});

const { data: articleData } = useQuery<ArticleResponse>(['GET_ARTICLE_QUERY', articleId], async () => {
const { requestId, visitorId } = await getVisitorData();
return await (
await fetch(`/api/paywall/article/${articleId}`, {
const { data: articleData, error: articleError } = useQuery<ArticleRequestPayload, Error, ArticleResponse>(
['GET_ARTICLE_QUERY', articleId],
async () => {
const { requestId } = await getVisitorData();
const response = await fetch(`/paywall/api/article/${articleId}`, {
method: 'POST',
body: JSON.stringify({ requestId, visitorId }),
})
).json();
});
body: JSON.stringify({ requestId } satisfies ArticleRequestPayload),
});
return await response.json();
},
);

const { article, remainingViews } = articleData?.data ?? {};
const { article } = articleData ?? {};
const returnUrl = `/paywall${embed ? '/embed' : ''}`;
const relatedArticles = ARTICLES.filter((article) => article.id !== articleId).slice(0, 4);

Expand All @@ -48,14 +46,8 @@ export default function Article({ embed }: CustomPageProps) {
<div className={styles.articleContainer}>
<BackArrow as='Link' href={returnUrl} label='Back to articles' testId={TEST_IDS.paywall.goBack} />
{!articleData && <ArticleSkeleton animation='wave' />}
{articleData && articleData.message && articleData.severity !== 'success' && (
<Alert severity={articleData.severity}>{articleData.message}</Alert>
)}
{articleData && articleData.severity === 'success' && remainingViews !== undefined && (
<Alert severity='warning'>
{remainingViews > 0 ? PAYWALL_COPY.nArticlesRemaining(remainingViews) : PAYWALL_COPY.lastArticle}
</Alert>
)}
{articleData && articleData.message && <Alert severity={articleData.severity}>{articleData.message}</Alert>}
{articleError && <Alert severity='error'>{articleError.message}</Alert>}
{article && (
<div className={styles.article} data-testid={TEST_IDS.paywall.articleContent}>
<Image src={article.image} alt={article.title} sizes='100vw' className={styles.articleImage} />
Expand Down
14 changes: 14 additions & 0 deletions src/app/paywall/article/[id]/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ARTICLES } from '../../../api/articles';
import { USE_CASES } from '../../../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../../../client/components/common/seo';
import { Article } from '../Article';

export async function generateStaticParams() {
return ARTICLES.map((article) => ({ id: article.id }));
}

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function ArticlePage({ params }: { params: { id: string } }) {
return <Article articleId={params.id} embed={true} />;
}
14 changes: 14 additions & 0 deletions src/app/paywall/article/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { ARTICLES } from '../../api/articles';
import { USE_CASES } from '../../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../../client/components/common/seo';
import { Article } from './Article';

export async function generateStaticParams() {
return ARTICLES.map((article) => ({ id: article.id }));
}

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function ArticlePage({ params }: { params: { id: string } }) {
return <Article articleId={params.id} embed={false} />;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import classNames from 'classnames';
import { useRouter } from 'next/router';
import { useRouter } from 'next/navigation';
import { FunctionComponent } from 'react';
import { ArticleData } from '../../../server/paywall/articles';
import { TEST_IDS } from '../../testIDs';
import { TEST_IDS } from '../../../client/testIDs';
import Image from 'next/image';
import styles from './articleGrid.module.scss';
import BylineDot from './dot.svg';
import { ArticleData } from '../api/articles';

function calculateReadingTime(text: string[], wordsPerMinute = 200) {
const words = text
Expand Down Expand Up @@ -36,7 +36,7 @@ export const Byline = ({ article, includeReadingTime }: { article: ArticleData;
*/
type ArticleCardProps = {
article: ArticleData;
embed?: boolean;
embed: boolean;
isHeroArticle?: boolean;
};

Expand Down Expand Up @@ -66,7 +66,7 @@ export const ArticleCard: FunctionComponent<ArticleCardProps> = ({ article, embe
);
};

export const ArticleGrid: FunctionComponent<{ articles: ArticleData[]; embed?: boolean }> = ({ articles, embed }) => {
export const ArticleGrid: FunctionComponent<{ articles: ArticleData[]; embed: boolean }> = ({ articles, embed }) => {
return (
<div className={styles.articles}>
{articles.map((article) => (
Expand Down
File renamed without changes
9 changes: 9 additions & 0 deletions src/app/paywall/embed/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { USE_CASES } from '../../../client/components/common/content';
import { generateUseCaseMetadata } from '../../../client/components/common/seo';
import Paywall from '../Paywall';

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function PaywallPage() {
return <Paywall embed={true} />;
}
9 changes: 9 additions & 0 deletions src/app/paywall/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { USE_CASES } from '../../client/components/common/content';
import { generateUseCaseMetadata } from '../../client/components/common/seo';
import Paywall from './Paywall';

export const metadata = generateUseCaseMetadata(USE_CASES.paywall);

export default function PaywallPage() {
return <Paywall embed={false} />;
}
File renamed without changes.
20 changes: 0 additions & 20 deletions src/client/api/loan-risk/use-request-loan.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/pages/api/admin/reset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import {
UserSearchHistoryDbModel,
} from '../../../server/personalization/database';
import { LoanRequestDbModel } from '../../../app/loan-risk/api/request-loan/database';
import { ArticleViewDbModel } from '../../../server/paywall/database';
import { CouponClaimDbModel } from '../../../server/coupon-fraud/database';
import { Severity, getAndValidateFingerprintResult } from '../../../server/checks';
import { NextApiRequest, NextApiResponse } from 'next';
import { deleteBlockedIp } from '../../../server/botd-firewall/blockedIpsDatabase';
import { syncFirewallRuleset } from '../../../server/botd-firewall/cloudflareApiHelper';
import { SmsVerificationDatabaseModel } from '../../../server/sms-pumping/database';
import { LoginAttemptDbModel } from '../../../server/credentialStuffing/database';
import { ArticleViewDbModel } from '../../../app/paywall/api/database';

export type ResetResponse = {
message: string;
Expand Down
Loading

0 comments on commit 9ca5f87

Please sign in to comment.