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 AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@
- Command for running script in a workspace: `pnpm --filter <workspace> <command>`.
- Command for running tests: `pnpm test`.
- The project uses shadcn for building UI, so stick to its conventions and design.
- In `apps/web` workspace, create a string first in `apps/web/config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings.
- In `apps/web` workspace, create a string first in `apps/web/ui-config/strings.ts` and then import it in the `.tsx` files, instead of using inline strings.
- `apps/web/config/strings.ts` is for strings used by backend and `apps/web/ui-config/strings.ts` is for strings used by frontend.
- For admin/dashboard empty states in `apps/web`, prefer reusing `apps/web/components/admin/empty-state.tsx` instead of creating one-off placeholder UIs.
- When working with forms, always use refs to keep the current state of the form's data and use it to enable/disable the form submit button.
- Check the name field inside each package's package.json to confirm the right name—skip the top-level one.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { render, waitFor } from "@testing-library/react";
import type { ReactNode } from "react";

const payloads: any[] = [];
const mockExec = jest.fn();

jest.mock("@components/contexts", () => {
const React = require("react");

return {
AddressContext: React.createContext({
backend: "http://localhost:3000",
frontend: "http://localhost:3000",
}),
ThemeContext: React.createContext({
theme: {
id: "test",
name: "Test",
theme: {},
},
setTheme: jest.fn(),
}),
};
});

jest.mock("@components/public/payments/checkout", () => ({
__esModule: true,
default: ({ product }: { product: { name: string } }) => (
<div>{product.name}</div>
),
}));

jest.mock("@courselit/components-library", () => ({
useToast: () => ({
toast: jest.fn(),
}),
}));

jest.mock("@courselit/page-primitives", () => ({
Header1: ({ children }: { children: ReactNode }) => <h1>{children}</h1>,
}));

jest.mock("@courselit/utils", () => ({
FetchBuilder: jest.fn().mockImplementation(() => ({
setUrl: jest.fn().mockReturnThis(),
setPayload: jest.fn(function (payload) {
payloads.push(payload);
return this;
}),
setIsGraphQLEndpoint: jest.fn().mockReturnThis(),
build: jest.fn().mockReturnThis(),
exec: mockExec,
})),
}));

jest.mock("next/navigation", () => ({
notFound: jest.fn(),
useSearchParams: () => new URLSearchParams("type=course&id=course-1"),
}));

import ProductCheckout from "../product";

describe("ProductCheckout", () => {
beforeEach(() => {
payloads.length = 0;
jest.clearAllMocks();
});

it("uses public course visibility and shows 404 when checkout course is unavailable", async () => {
const { notFound } = jest.requireMock("next/navigation");
mockExec.mockResolvedValueOnce({
course: null,
loginProviders: [],
});

render(<ProductCheckout />);

await waitFor(() => {
expect(payloads[0].query).toContain(
"course: getCourse(id: $id, asGuest: true)",
);
});

await waitFor(() => {
expect(notFound).toHaveBeenCalled();
});
});
});
5 changes: 1 addition & 4 deletions apps/web/app/(with-contexts)/(with-layout)/checkout/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client";

import { ThemeContext } from "@components/contexts";
import { Header1, Section } from "@courselit/page-primitives";
import { Section } from "@courselit/page-primitives";
import { useContext } from "react";
import ProductCheckout from "./product";

Expand All @@ -10,9 +10,6 @@ export default function CheckoutPage() {
return (
<Section theme={theme.theme}>
<div className="flex flex-col">
<Header1 theme={theme.theme} className="mb-8">
Checkout
</Header1>
<ProductCheckout />
</div>
</Section>
Expand Down
48 changes: 26 additions & 22 deletions apps/web/app/(with-contexts)/(with-layout)/checkout/product.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,17 @@ import type { MembershipEntityType } from "@courselit/common-models";
import { useToast } from "@courselit/components-library";
import { FetchBuilder } from "@courselit/utils";
import { TOAST_TITLE_ERROR } from "@ui-config/strings";
import { useSearchParams } from "next/navigation";
import { notFound, useSearchParams } from "next/navigation";
import { useCallback, useContext, useEffect, useState } from "react";
import type { RuntimeLoginProvider } from "@/lib/login-providers";
import { Header1 } from "@courselit/page-primitives";
import { ThemeContext } from "@components/contexts";

const { MembershipEntityType } = Constants;

export default function ProductCheckout() {
const address = useContext(AddressContext);
const { theme } = useContext(ThemeContext);
const searchParams = useSearchParams();
const entityId = searchParams?.get("id");
const entityType = searchParams?.get("type");
Expand All @@ -23,6 +26,7 @@ export default function ProductCheckout() {
const [product, setProduct] = useState<Product | null>(null);
const [paymentPlans, setPaymentPlans] = useState<PaymentPlan[]>([]);
const [includedProducts, setIncludedProducts] = useState<Course[]>([]);
const [productNotFound, setProductNotFound] = useState(false);
const [loginProviders, setLoginProviders] = useState<
RuntimeLoginProvider[]
>([]);
Expand Down Expand Up @@ -60,10 +64,7 @@ export default function ProductCheckout() {
if (response.includedProducts) {
setIncludedProducts([...response.includedProducts]);
} else {
toast({
title: TOAST_TITLE_ERROR,
description: "Course not found",
});
setProductNotFound(true);
}
} catch (err: any) {
toast({
Expand All @@ -77,7 +78,7 @@ export default function ProductCheckout() {
const getProduct = useCallback(async () => {
const query = `
query ($id: String!) {
course: getCourse(id: $id) {
course: getCourse(id: $id, asGuest: true) {
courseId
title
slug
Expand Down Expand Up @@ -126,10 +127,7 @@ export default function ProductCheckout() {
});
setPaymentPlans([...response.course.paymentPlans]);
} else {
toast({
title: TOAST_TITLE_ERROR,
description: "Course not found",
});
setProductNotFound(true);
}
setLoginProviders(response.loginProviders || []);
} catch (err: any) {
Expand Down Expand Up @@ -195,10 +193,7 @@ export default function ProductCheckout() {
});
setPaymentPlans([...response.community.paymentPlans]);
} else {
toast({
title: TOAST_TITLE_ERROR,
description: "Community not found",
});
setProductNotFound(true);
}
setLoginProviders(response.loginProviders || []);
} catch (err: any) {
Expand Down Expand Up @@ -226,18 +221,27 @@ export default function ProductCheckout() {
}
}, [paymentPlans, getIncludedProducts]);

if (productNotFound) {
notFound();
Comment on lines +224 to +225

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Return unavailable checkout products before client render

When an unpublished or otherwise unavailable checkout product is detected here, this notFound() runs only after the client-side useEffect fetch completes. A direct request to /checkout?type=course&id=... has already been served as a 200 with the client shell, so crawlers and HTTP clients still see a successful page instead of a real 404/noindex response. If the checkout URL must be hidden for unavailable products, move this lookup/404 decision into the server page using searchParams or another server-side guard.

Useful? React with 👍 / 👎.

}

if (!product) {
return null;
}

return (
<Checkout
product={product}
paymentPlans={paymentPlans}
includedProducts={includedProducts}
loginProviders={loginProviders}
type={entityType as MembershipEntityType | undefined}
id={entityId as string | undefined}
/>
<>
<Header1 theme={theme.theme} className="mb-8">
Checkout
</Header1>
<Checkout
product={product}
paymentPlans={paymentPlans}
includedProducts={includedProducts}
loginProviders={loginProviders}
type={entityType as MembershipEntityType | undefined}
id={entityId as string | undefined}
/>
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,4 +64,8 @@ describe("course helpers formatCourse", () => {
formatted.groups[0].lessons.map((lesson) => lesson.lessonId),
).toEqual(["lesson-3", "lesson-2"]);
});

it("throws item_not_found instead of reading properties from a null course", () => {
expect(() => formatCourse(null as any)).toThrow("Item not found");
});
});
Loading
Loading