feat(courses): add type, search, and pricing filters to org landing page

- Add Types section to the Filter sidebar with all 4 course types
- Add full-text search input centered in the hero (title + description)
- Add Pricing section (Free / Paid) based on course cost
- Compact hero prop across all 10 themes to halve vertical padding
- Children snippet support in all hero themes for the search input
- All filters are URL-driven (?types=, ?search=, ?tags=, ?pricing=)
- Full-stack: validation schema, DB queries, service, API route, server load, UI
This commit is contained in:
Rotimi Best
2026-07-01 10:36:53 +01:00
parent 8d2f2a6d2d
commit 78a2b85ad3
27 changed files with 464 additions and 97 deletions
+41 -2
View File
@@ -11,7 +11,7 @@ import {
TNewCourseSection,
TProfile
} from '@db/types';
import { and, count, desc, eq, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
import { and, count, desc, eq, gt, ilike, inArray, isNotNull, isNull, or, sql } from 'drizzle-orm';
import { ROLE } from '@cio/utils/constants';
import { db, type DbOrTxClient } from '@db/drizzle';
@@ -59,6 +59,9 @@ export interface TStudentCourse extends TBaseCourse {
export const getPublishedCoursesBySiteName = async (
siteName: string,
courseIds?: string[],
courseTypes?: string[],
search?: string,
pricing?: 'free' | 'paid',
limit?: number,
offset?: number
): Promise<TBaseCourse[]> => {
@@ -77,6 +80,21 @@ export const getPublishedCoursesBySiteName = async (
conditions.push(inArray(schema.course.id, courseIds));
}
if (courseTypes && courseTypes.length > 0) {
conditions.push(inArray(schema.course.type, courseTypes as (typeof schema.courseType.enumValues)[number][]));
}
if (search) {
const pattern = `%${search}%`;
conditions.push(or(ilike(schema.course.title, pattern), ilike(schema.course.description, pattern))!);
}
if (pricing === 'free') {
conditions.push(eq(schema.course.cost, 0));
} else if (pricing === 'paid') {
conditions.push(gt(schema.course.cost, 0));
}
const exerciseCountSql = sql<number>`(
SELECT COUNT(*)::bigint
FROM ${schema.exercise} as ex
@@ -127,7 +145,13 @@ export const getPublishedCoursesBySiteName = async (
}
};
export const countPublishedCoursesBySiteName = async (siteName: string, courseIds?: string[]): Promise<number> => {
export const countPublishedCoursesBySiteName = async (
siteName: string,
courseIds?: string[],
courseTypes?: string[],
search?: string,
pricing?: 'free' | 'paid'
): Promise<number> => {
try {
if (courseIds && courseIds.length === 0) {
return 0;
@@ -143,6 +167,21 @@ export const countPublishedCoursesBySiteName = async (siteName: string, courseId
conditions.push(inArray(schema.course.id, courseIds));
}
if (courseTypes && courseTypes.length > 0) {
conditions.push(inArray(schema.course.type, courseTypes as (typeof schema.courseType.enumValues)[number][]));
}
if (search) {
const pattern = `%${search}%`;
conditions.push(or(ilike(schema.course.title, pattern), ilike(schema.course.description, pattern))!);
}
if (pricing === 'free') {
conditions.push(eq(schema.course.cost, 0));
} else if (pricing === 'paid') {
conditions.push(gt(schema.course.cost, 0));
}
const [result] = await db
.select({
total: count(schema.course.id)