Skip to content

Commit aab2f8c

Browse files
committed
feat(store): add custom cover info to book product page
1 parent a84902c commit aab2f8c

3 files changed

Lines changed: 99 additions & 2 deletions

File tree

client/src/api.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,7 +490,14 @@ class API {
490490
async getBookDetail(bookID: string) {
491491
const res = await axios.get<
492492
{
493-
book: { license?: string; [key: string]: unknown };
493+
book: {
494+
license?: string;
495+
exportInfo?: {
496+
customCoverOrg?: string;
497+
[key: string]: unknown;
498+
};
499+
[key: string]: unknown;
500+
};
494501
} & ConductorBaseResponse
495502
>(`/commons/book/${bookID}`);
496503
return res;

client/src/screens/conductor/store/product.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { buildLibraryPageGoURL } from "../../../utils/projectHelpers";
1616
import { Alert, Button, Divider, Heading, Link, NumberInput, Radio, RadioGroup, Stack, Text } from "@libretexts/davis-react";
1717
import { IconBook2, IconShoppingCartPlus } from "@tabler/icons-react";
1818
import useStoreMaxQuantityPerItem from "../../../hooks/useStoreMaxQuantityPerItem";
19+
import useClientConfig from "../../../hooks/useClientConfig";
20+
import { getBookExportURL } from "../../../utils/bookExports";
1921

2022
const BOOK_PAGE_LIMIT = 799;
2123

@@ -25,6 +27,7 @@ export default function ProductPage() {
2527
const { cart, addToCart, removeFromCart, loading: cartLoading } = useCart();
2628
const params = useParams<{ product_id: string }>();
2729
const maxQuantityPerItem = useStoreMaxQuantityPerItem();
30+
const { isProduction } = useClientConfig();
2831
const { data: product, isFetching } = useQuery<StoreProduct>({
2932
queryKey: ["store-product", params.product_id],
3033
queryFn: async () => {
@@ -118,7 +121,11 @@ export default function ProductPage() {
118121
return num_pages > BOOK_PAGE_LIMIT;
119122
}, [product, isBook]);
120123

121-
const [bookDetail, setBookDetail] = useState<{ license?: string;[key: string]: unknown } | null>(null);
124+
const [bookDetail, setBookDetail] = useState<{
125+
license?: string;
126+
exportInfo?: { customCoverOrg?: string;[key: string]: unknown };
127+
[key: string]: unknown;
128+
} | null>(null);
122129

123130
useEffect(() => {
124131
if (!product || !isBook) return;
@@ -141,6 +148,30 @@ export default function ProductPage() {
141148
return license.includes("cc-by-nc") || license.includes("ccbync");
142149
}, [bookDetail]);
143150

151+
/**
152+
* The organization producing the book's custom cover, when it has one.
153+
* Display-only: the field is informational and never gates pricing or
154+
* fulfillment.
155+
*/
156+
const customCoverOrg = useMemo(() => {
157+
if (!isBook) return null;
158+
const org = bookDetail?.exportInfo?.customCoverOrg;
159+
return typeof org === "string" && org.trim() ? org.trim() : null;
160+
}, [bookDetail, isBook]);
161+
162+
/**
163+
* Cover artwork for the binding the customer currently has selected, so the
164+
* link always points at the PDF that would actually be printed.
165+
*/
166+
const customCoverURL = useMemo(() => {
167+
if (!customCoverOrg) return "";
168+
return getBookExportURL(
169+
product?.metadata["book_id"],
170+
hardcover ? "cover-casewrap" : "cover-perfectbound",
171+
{ isProduction }
172+
);
173+
}, [customCoverOrg, product, hardcover, isProduction]);
174+
144175
function handleAddToCart() {
145176
if (!product) return;
146177
if (!price || !price.unit_amount) {
@@ -308,6 +339,25 @@ export default function ProductPage() {
308339
error={quantity > maxQuantityPerItem}
309340
errorMessage={`Maximum ${maxQuantityPerItem} per item. Please reduce the quantity to continue.`}
310341
/>
342+
{customCoverOrg && (
343+
<Alert
344+
variant="info"
345+
message={`This book will ship with a custom cover by ${customCoverOrg}.`}
346+
{...(customCoverURL
347+
? {
348+
action: {
349+
label: "View custom cover",
350+
onClick: () =>
351+
window.open(
352+
customCoverURL,
353+
"_blank",
354+
"noopener,noreferrer"
355+
),
356+
},
357+
}
358+
: {})}
359+
/>
360+
)}
311361
{hasNCLicense && (
312362
<Alert variant="warning" message={"This book has a NonCommercial clause in its license and cannot be resold for profit."} />
313363
)}

client/src/utils/bookExports.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@ export interface BookExportDisplay {
1111
key: BookExportKey;
1212
label: string;
1313
group: BookExportGroup;
14+
/**
15+
* Segment appended to the book's downloads base URL. `null` for exports the
16+
* downloads service does not produce yet. Mirrors `pathSegment` in
17+
* `server/api/services/book-export-service.ts` and must stay in step with it.
18+
*/
19+
pathSegment: string | null;
1420
/**
1521
* Whether the browser can render this export inline.
1622
*
@@ -42,6 +48,7 @@ export const BOOK_EXPORT_GROUP_LABELS: Record<BookExportGroup, string> = {
4248
export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
4349
{
4450
key: "full-pdf",
51+
pathSegment: "pdf",
4552
label: "Full PDF",
4653
group: "full-book",
4754
previewable: true,
@@ -52,6 +59,7 @@ export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
5259
},
5360
{
5461
key: "content-pdf",
62+
pathSegment: "content",
5563
label: "Content PDF",
5664
group: "publication",
5765
previewable: true,
@@ -61,6 +69,7 @@ export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
6169
},
6270
{
6371
key: "cover-perfectbound",
72+
pathSegment: "cover-perfectbound",
6473
label: "Paperback Cover PDF",
6574
group: "publication",
6675
previewable: true,
@@ -70,6 +79,7 @@ export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
7079
},
7180
{
7281
key: "cover-casewrap",
82+
pathSegment: "cover-casewrap",
7383
label: "Hardcover Cover PDF",
7484
group: "publication",
7585
previewable: true,
@@ -79,6 +89,7 @@ export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
7989
},
8090
{
8191
key: "thincc",
92+
pathSegment: "thincc",
8293
label: "ThinCC",
8394
group: "full-book",
8495
previewable: false,
@@ -89,6 +100,7 @@ export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
89100
},
90101
{
91102
key: "page-pdfs",
103+
pathSegment: "pages",
92104
label: "Page PDFs",
93105
group: "other",
94106
previewable: false,
@@ -98,6 +110,7 @@ export const BOOK_EXPORT_DISPLAY: BookExportDisplay[] = [
98110
},
99111
{
100112
key: "epub",
113+
pathSegment: null,
101114
label: "EPUB",
102115
group: "other",
103116
previewable: false,
@@ -141,3 +154,30 @@ export const getDefaultExportKey = (
141154
);
142155
return firstAvailable?.key ?? BOOK_EXPORT_DISPLAY[0].key;
143156
};
157+
158+
const DOWNLOADS_HOST_PRODUCTION = "https://downloads.libretexts.org";
159+
const DOWNLOADS_HOST_STAGING = "https://staging.downloads.libretexts.org";
160+
161+
/**
162+
* Download URL for one of a Book's exports.
163+
*
164+
* The server half of the registry owns the authoritative path shape
165+
* (`getExportURL` in `server/api/services/book-export-service.ts`); this mirrors
166+
* it for links the client renders on its own. Returns an empty string when the
167+
* bookID is missing or the export has no artifact yet, so callers can gate on a
168+
* falsy URL rather than rendering a dead link.
169+
*/
170+
export const getBookExportURL = (
171+
bookID: string | undefined,
172+
key: BookExportKey,
173+
opts?: { isProduction?: boolean },
174+
): string => {
175+
if (!bookID) return "";
176+
const display = getExportDisplay(key);
177+
if (!display?.enabled || !display.pathSegment) return "";
178+
const host =
179+
opts?.isProduction === false
180+
? DOWNLOADS_HOST_STAGING
181+
: DOWNLOADS_HOST_PRODUCTION;
182+
return `${host}/api/v1/download/${encodeURIComponent(bookID)}/${display.pathSegment}`;
183+
};

0 commit comments

Comments
 (0)