= {
+ 'MAIL': { min: 13, max: 15 },
+ 'PRIORITY_MAIL': { min: 11, max: 13 },
+ 'GROUND_HD': { min: 12, max: 14 },
+ 'GROUND_BUS': { min: 12, max: 14 },
+ 'GROUND': { min: 12, max: 14 },
+ 'EXPEDITED': { min: 8, max: 9 },
+ 'EXPRESS': { min: 9, max: 10 },
+ };
+
+ // Get today's date (order date)
+ const today = new Date();
+ today.setHours(0, 0, 0, 0);
+
+ // Helper function to add business days (excluding weekends)
+ const addBusinessDays = (date: Date, days: number): Date => {
+ const result = new Date(date);
+ let added = 0;
+ while (added < days) {
+ result.setDate(result.getDate() + 1);
+ // Skip weekends (Saturday = 6, Sunday = 0)
+ if (result.getDay() !== 0 && result.getDay() !== 6) {
+ added++;
+ }
+ }
+ return result;
+ };
+
+ // Helper function to format date as ISO string
+ const formatDate = (date: Date): string => {
+ return date.toISOString().split('T')[0];
+ };
+
const mapped = filtered_shipping_options.map((opt) => {
// ensure cost_excl_tax is a number and convert it to cents
if (!opt.cost_excl_tax || isNaN(parseFloat(opt.cost_excl_tax))) {
@@ -540,6 +573,17 @@ class StoreService {
}
const costInCents = Math.round(parseFloat(opt.cost_excl_tax) * 100);
+ // Get delivery days for this shipping level (fallback to MAIL if not found)
+ const deliveryDays = shippingLevelDeliveryDays[opt.level] || shippingLevelDeliveryDays['MAIL'];
+
+ // Calculate date estimates
+ const productionStartDate = addBusinessDays(today, 2);
+ const productionEndDate = addBusinessDays(today, 4);
+ const shipDateStart = addBusinessDays(today, 6);
+ const shipDateEnd = addBusinessDays(today, 10);
+ const deliveryDateStart = addBusinessDays(today, deliveryDays.min);
+ const deliveryDateEnd = addBusinessDays(today, deliveryDays.max);
+
return {
id: opt.id,
title: `${opt.level}${opt.carrier_service_name ? ` (${opt.carrier_service_name})` : ''}`,
@@ -547,6 +591,12 @@ class StoreService {
total_days_max: opt.total_days_max,
lulu_shipping_level: opt.level,
cost_excl_tax: costInCents,
+ production_start_date_estimate: formatDate(productionStartDate),
+ production_end_date_estimate: formatDate(productionEndDate),
+ ship_date_start_estimate: formatDate(shipDateStart),
+ ship_date_end_estimate: formatDate(shipDateEnd),
+ delivery_date_start_estimate: formatDate(deliveryDateStart),
+ delivery_date_end_estimate: formatDate(deliveryDateEnd),
}
});
From 3223be5fcc0a183b07c206ea306512a413123abb Mon Sep 17 00:00:00 2001
From: Akhileshwar Shriram <112577383+AkhilTheBoss@users.noreply.github.com>
Date: Sun, 18 Jan 2026 14:21:38 -0800
Subject: [PATCH 19/27] feat(store-checkout): added store shipping timeline
---
.../src/components/store/ShippingTimeline.tsx | 34 +++++++++----
.../src/screens/conductor/store/shipping.tsx | 48 ++++++++++++-------
server/api/services/store-service.ts | 3 +-
server/types/Store.ts | 6 +++
4 files changed, 63 insertions(+), 28 deletions(-)
diff --git a/client/src/components/store/ShippingTimeline.tsx b/client/src/components/store/ShippingTimeline.tsx
index 6629d5154..666610609 100644
--- a/client/src/components/store/ShippingTimeline.tsx
+++ b/client/src/components/store/ShippingTimeline.tsx
@@ -6,6 +6,18 @@ interface ShippingTimelineProps {
}
export default function ShippingTimeline({ shippingOption }: ShippingTimelineProps) {
+ if (
+ !shippingOption.production_start_date_estimate ||
+ !shippingOption.production_end_date_estimate ||
+ !shippingOption.ship_date_start_estimate ||
+ !shippingOption.ship_date_end_estimate ||
+ !shippingOption.delivery_date_start_estimate ||
+ !shippingOption.delivery_date_end_estimate
+ ) {
+ // Don't render if date fields are missing
+ return null;
+ }
+
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return new Intl.DateTimeFormat("en-US", {
@@ -60,11 +72,14 @@ export default function ShippingTimeline({ shippingOption }: ShippingTimelinePro
];
return (
-
+
+ {/* Title outside the box, matching "Order summary" style */}
Estimated Production & Delivery Timeline
-
+
+ {/* Content box with same styling as order summary */}
+
{timelineSteps.map((step, stepIdx) => (
@@ -72,7 +87,7 @@ export default function ShippingTimeline({ shippingOption }: ShippingTimelinePro
@@ -86,7 +101,7 @@ export default function ShippingTimeline({ shippingOption }: ShippingTimelinePro
{stepIdx < timelineSteps.length - 1 && (
{step.name}
@@ -106,11 +121,12 @@ export default function ShippingTimeline({ shippingOption }: ShippingTimelinePro
))}
+
+
+ *Please note: Because your book is printed specially for you upon ordering,
+ production times can vary before shipping begins.
+
-
- *Please note: Because your book is printed specially for you upon ordering,
- production times can vary before shipping begins.
-
);
}
\ No newline at end of file
diff --git a/client/src/screens/conductor/store/shipping.tsx b/client/src/screens/conductor/store/shipping.tsx
index b2704d5e6..c8deeef13 100644
--- a/client/src/screens/conductor/store/shipping.tsx
+++ b/client/src/screens/conductor/store/shipping.tsx
@@ -416,6 +416,20 @@ export default function ShippingPage() {
setShippingOptions(response.data.options);
shippingCalculated.current = true;
+ console.log("=== Shipping Options Response ===");
+ console.log("Full response:", response.data);
+ if (Array.isArray(response.data.options) && response.data.options.length > 0) {
+ console.log("First shipping option:", response.data.options[0]);
+ console.log("Has date fields?", {
+ production_start: !!response.data.options[0].production_start_date_estimate,
+ delivery_end: !!response.data.options[0].delivery_date_end_estimate,
+ });
+ }
+
+ if (Array.isArray(response.data.options) && response.data.options.length > 0) {
+ console.log("Shipping option with dates:", response.data.options[0]);
+ }
+
if (response.data.options === "digital_delivery_only") {
setSelectedShippingOption(null);
} else if (response.data.options.length > 0) {
@@ -899,25 +913,23 @@ export default function ShippingPage() {
)}
- {hasPhysicalProducts && selectedShippingOption && (
-
- )}
-
-
- {loading || shippingLoading ? (
-
- ) : (
-
- )}
- Proceed to Payment
-
-
+ {hasPhysicalProducts && selectedShippingOption && (
+
+ )}
+
+ {loading || shippingLoading ? (
+
+ ) : (
+
+ )}
+ Proceed to Payment
+
If you have any questions or concerns, please contact our{" "}
Date: Sun, 18 Jan 2026 17:00:25 -0800
Subject: [PATCH 20/27] fix: fix checkout page horizontal overflow on mobile
---
client/src/screens/conductor/store/shipping.tsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/screens/conductor/store/shipping.tsx b/client/src/screens/conductor/store/shipping.tsx
index c8deeef13..c4a4373fc 100644
--- a/client/src/screens/conductor/store/shipping.tsx
+++ b/client/src/screens/conductor/store/shipping.tsx
@@ -522,7 +522,7 @@ export default function ShippingPage() {
-
+
);
};
diff --git a/client/src/screens/conductor/Home/index.tsx b/client/src/screens/conductor/Home/index.tsx
index 665cf6b9a..7fd58aec4 100644
--- a/client/src/screens/conductor/Home/index.tsx
+++ b/client/src/screens/conductor/Home/index.tsx
@@ -199,11 +199,11 @@ const Home = () => {
)}
-
+
-
+
setShowCreateProjectModal(true)}
fluid
@@ -213,6 +213,42 @@ const Home = () => {
labelPosition="left"
/>
+
+
+
+
+ Recently Edited Projects
+
+
+
+ To see all of your projects, visit{" "}
+ Projects in the Navbar.
+
+ }
+ trigger={ }
+ position="top center"
+ />
+
+
+
+
+ {recentProjects.length > 0 &&
+ recentProjects.map((item) => (
+ handlePinProject(pid)}
+ />
+ ))}
+ {recentProjects.length === 0 && (
+ You don't have any projects right now.
+ )}
+
+
+
From 96cb7beeceee9effd066a16a3cc27b642fd4e274 Mon Sep 17 00:00:00 2001
From: Jake Turner
Date: Mon, 16 Feb 2026 20:56:43 -0800
Subject: [PATCH 25/27] fix(Support): temp disable Ask Benny
---
client/src/Conductor.jsx | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/client/src/Conductor.jsx b/client/src/Conductor.jsx
index 0db833ed2..a51182ded 100644
--- a/client/src/Conductor.jsx
+++ b/client/src/Conductor.jsx
@@ -194,7 +194,7 @@ const Conductor = () => {
-
+ {/* */}
From ba3ddda33cc09be12a4bfa9dea5aaafaf069be84 Mon Sep 17 00:00:00 2001
From: Jake Turner
Date: Mon, 16 Feb 2026 22:04:23 -0800
Subject: [PATCH 26/27] feat(Search): migrate project search to Meilisearch
---
client/src/api.ts | 11 ++++
.../src/components/commons/CommonsCatalog.tsx | 2 +-
server/api.js | 15 ++++-
server/api/search.ts | 55 +++++++++++++++++++
server/api/services/search-service.ts | 9 +++
server/api/validators/search.ts | 12 ++++
6 files changed, 102 insertions(+), 2 deletions(-)
diff --git a/client/src/api.ts b/client/src/api.ts
index e7da5a534..a6828e9dc 100644
--- a/client/src/api.ts
+++ b/client/src/api.ts
@@ -1296,6 +1296,17 @@ class API {
return res;
}
+ async projectsSearchV2(params: ProjectSearchParams) {
+ const res = await axios.get<
+ ConductorSearchResponse<"projects"> & ConductorBaseResponse
+ >("/search/projects-v2", {
+ params: {
+ ...params,
+ },
+ });
+ return res;
+ }
+
async usersSearch(params: UserSearchParams) {
const res = await axios.get<
ConductorSearchResponse<"users"> & ConductorBaseResponse
diff --git a/client/src/components/commons/CommonsCatalog.tsx b/client/src/components/commons/CommonsCatalog.tsx
index feb088e82..09871f3ea 100644
--- a/client/src/components/commons/CommonsCatalog.tsx
+++ b/client/src/components/commons/CommonsCatalog.tsx
@@ -611,7 +611,7 @@ const CommonsCatalog = () => {
) {
try {
setProjectsLoading(true);
- const res = await api.projectsSearch({
+ const res = await api.projectsSearchV2({
searchQuery: query,
page,
limit: ITEMS_PER_PAGE,
diff --git a/server/api.js b/server/api.js
index fbfbc09b0..1ab5b313a 100644
--- a/server/api.js
+++ b/server/api.js
@@ -1033,7 +1033,13 @@ router
router.route("/commons/syncwithsearch").post(
middleware.checkLibreAPIKey,
booksAPI.syncWithSearchIndex,
-)
+)
+
+router.route("/projects/syncwithsearch").post(
+ middleware.checkLibreAPIKey,
+ projectsAPI.syncWithSearchIndex,
+)
+
/* Commons Books/Catalogs */
router
.route("/commons/catalog")
@@ -1297,6 +1303,13 @@ router
middleware.validateZod(SearchValidators.projectSearchSchema),
searchAPI.projectsSearch
);
+router
+ .route("/search/projects-v2")
+ .get(
+ authAPI.optionalVerifyRequest,
+ middleware.validateZod(SearchValidators.projectSearchV2Schema),
+ searchAPI.projectSearchV2
+ );
router
.route("/search/users")
.get(
diff --git a/server/api/search.ts b/server/api/search.ts
index 1faf31465..f4e22143a 100644
--- a/server/api/search.ts
+++ b/server/api/search.ts
@@ -19,6 +19,7 @@ import {
homeworkSearchSchema,
miniReposSearchSchema,
projectSearchSchema,
+ projectSearchV2Schema,
userSearchSchema,
} from "./validators/search.js";
import ProjectFile from "../models/projectfile.js";
@@ -2446,6 +2447,59 @@ async function bookSearchV2(
}
}
+async function projectSearchV2(
+ req: z.infer,
+ res: Response
+) {
+ try {
+ if (!req.query.searchQuery) {
+ return res.send({
+ err: false,
+ numResults: 0,
+ results: [],
+ });
+ }
+
+ const searchService = await getSearchService();
+ if (!searchService) {
+ return res.status(503).send({
+ err: true,
+ errMsg:
+ "Search service is currently unavailable. Please try again later.",
+ });
+ }
+
+ // if org is libretexts, don't filter by orgID since that includes all public projects across orgs
+ const resolvedOrgID = req.query.orgID || process.env.ORG_ID === 'libretexts' ? undefined : process.env.ORG_ID;
+
+ const filterMap = _getNonNullFieldMap({
+ status: req.query.status,
+ classification: req.query.classification,
+ visibility: "public", // TODO: enable private project search for auth'd users
+ orgID: resolvedOrgID,
+ });
+
+ const filterString = searchService.buildFilterString(filterMap);
+ const results = await searchService.search(
+ "projects",
+ req.query.searchQuery,
+ {
+ limit: req.query.limit || 25,
+ ...(filterString ? { filter: filterString } : {}),
+ }
+ );
+
+ return res.send({
+ err: false,
+ numResults: results.estimatedTotalHits,
+ results: results.hits,
+ });
+ } catch (err) {
+ debugError(err);
+ return conductor500Err(res);
+ }
+}
+
/**
* Takes an arbitrary object and returns only the fields whose values are non-null/undefined.
* Attempts to split comma-separated strings into arrays. Values are always returned as arrays even if single.
@@ -2650,4 +2704,5 @@ export default {
getAuthorFilterOptions,
getProjectFilterOptions,
bookSearchV2,
+ projectSearchV2,
};
diff --git a/server/api/services/search-service.ts b/server/api/services/search-service.ts
index 8bbc47977..018f57ac1 100644
--- a/server/api/services/search-service.ts
+++ b/server/api/services/search-service.ts
@@ -7,6 +7,7 @@ const INDEX_NOT_FOUND_ERROR =
export const INDEX_FILTERABLE_ATTRIBUTES = {
books: ["bookID", "library", "license", "author", "course", "affiliation"],
+ projects: ["status", "classification", "visibility", "orgID"],
};
export default class SearchService {
@@ -43,6 +44,11 @@ export default class SearchService {
const indexes = await this.client.getIndexes();
const foundIndex = indexes.results.find((idx) => idx.uid === indexName);
if (foundIndex) {
+ // Ensure filterable attributes are updated
+ const attrs = INDEX_FILTERABLE_ATTRIBUTES[indexName];
+ if (attrs) {
+ await foundIndex.updateFilterableAttributes(attrs);
+ }
return foundIndex;
}
@@ -57,6 +63,9 @@ export default class SearchService {
if (indexName === "projects") {
await this.client.createIndex(indexName, { primaryKey: "projectID" });
const index = this.client.index(indexName);
+ await index.updateFilterableAttributes(
+ INDEX_FILTERABLE_ATTRIBUTES.projects
+ );
return index;
}
} catch (error: any) {
diff --git a/server/api/validators/search.ts b/server/api/validators/search.ts
index fdd4eecb8..068903c62 100644
--- a/server/api/validators/search.ts
+++ b/server/api/validators/search.ts
@@ -90,6 +90,18 @@ export const projectSearchSchema = z.object({
}),
});
+export const projectSearchV2Schema = z.object({
+ query: z.object({
+ searchQuery: z.string().optional(),
+ status: z.string().optional(),
+ classification: z.string().optional(),
+ visibility: z.string().optional(),
+ orgID: z.string().optional(),
+ limit: z.coerce.number().min(1).default(25),
+ page: z.coerce.number().min(1).default(1),
+ }),
+});
+
export const authorsSearchSchema = z.object({
query: z.object({
primaryInstitution: z.string().optional(),
From a57244d124380e9715f0902f79e6874029062595 Mon Sep 17 00:00:00 2001
From: Jake Turner
Date: Mon, 16 Feb 2026 22:30:21 -0800
Subject: [PATCH 27/27] feat(Store): email notif to bookstore contact on order
fail or rejection
---
server/api/mail.js | 40 ++++++++++++++++++++++++++++
server/api/services/store-service.ts | 14 ++++++++++
2 files changed, 54 insertions(+)
diff --git a/server/api/mail.js b/server/api/mail.js
index 7d5d7015e..4e019e670 100644
--- a/server/api/mail.js
+++ b/server/api/mail.js
@@ -971,6 +971,44 @@ const sendStoreOrderShippedUpdate = (recipientAddress, orderID, trackingURLs) =>
});
}
+const sendStoreOrderRejectedInternalNotification = (recipientAddresses, orderID, reason) => {
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: recipientAddresses,
+ subject: `[Action Required] LibreTexts Store Order Rejected: ${orderID.slice(-6)}`,
+ html: `
+ A store order has been rejected for the following reason: ${reason}.
+
+ Please review it in the Control Panel for more details and resubmission.
+ Sincerely,
+ The LibreTexts team
+
+ Order ID: ${orderID}
+ ${autoGenNoticeHTML}
+ `,
+ });
+}
+
+const sendStoreOrderFailedInternalNotification = (recipientAddresses, orderID, error) => {
+ const stringifiedError = typeof error === 'string' ? error : JSON.stringify(error);
+ return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
+ from: DEFAULT_MAIL_FROM,
+ to: recipientAddresses,
+ subject: `[Action Required] LibreTexts Store Order Failed: ${orderID.slice(-6)}`,
+ html: `
+ A store order has failed with the following error: ${stringifiedError.substring(0, 100)}.
+ An error of this nature typically indicates an issue outside of the book itself and may require assistance from Engineering to resolve.
+
+ Please review it in the Control Panel for more details.
+ Sincerely,
+ The LibreTexts team
+
+ Order ID: ${orderID}
+ ${autoGenNoticeHTML}
+ `,
+ });
+}
+
const sendZIPFileReadyNotification = (url, recipientAddress) => {
return mailgun.messages.create(process.env.MAILGUN_DOMAIN, {
from: 'LibreTexts Support ',
@@ -1120,6 +1158,8 @@ export default {
sendStoreOrderConfirmation,
sendStoreOrderInProductionUpdate,
sendStoreOrderShippedUpdate,
+ sendStoreOrderRejectedInternalNotification,
+ sendStoreOrderFailedInternalNotification,
sendZIPFileReadyNotification,
sendProjectInvitation,
sendSupportTicketCCedNotification
diff --git a/server/api/services/store-service.ts b/server/api/services/store-service.ts
index dd9bc0c78..d7d041cf1 100644
--- a/server/api/services/store-service.ts
+++ b/server/api/services/store-service.ts
@@ -778,6 +778,14 @@ class StoreService {
storeOrder.status = 'completed';
}
+ if (data.status?.name === 'REJECTED') {
+ if (process.env.BOOKSTORE_CONTACT_EMAIL) {
+ await mailAPI.sendStoreOrderRejectedInternalNotification(process.env.BOOKSTORE_CONTACT_EMAIL, storeOrder.id, data.status.message || '').catch((err) => {
+ debug("Failed to send store order rejected internal notification email:", err);
+ });
+ }
+ }
+
storeOrder.luluJobID = data.id.toString(); // Update the Lulu job ID (e.g. on resubmits)
storeOrder.luluJobStatus = data.status?.name || "unknown";
storeOrder.luluJobStatusMessage = data.status?.message || "";
@@ -1482,6 +1490,12 @@ class StoreService {
* @param error - The error message to set on the store order.
*/
private async _failStoreOrder(storeOrder: RawStoreOrder, error: string) {
+ if (process.env.BOOKSTORE_CONTACT_EMAIL) {
+ await mailAPI.sendStoreOrderFailedInternalNotification(process.env.BOOKSTORE_CONTACT_EMAIL, storeOrder.id, error).catch((err) => {
+ debug("Failed to send internal notification email for failed store order:", err);
+ });
+ }
+
return await StoreOrder.updateOne({
id: storeOrder.id,
}, {