add_action( 'init', $this, 'register_shortcode' );
$loader->add_action( 'init', $this, 'register_block' );
}
public function register_shortcode(): void {
add_shortcode( 'acc_recent_courses', [ $this, 'render' ] );
}
public function register_block(): void {
if ( ! function_exists( 'register_block_type' ) ) {
return;
}
wp_register_script(
'acc-block-recent-courses',
false,
[ 'wp-blocks', 'wp-element', 'wp-server-side-render', 'wp-block-editor', 'wp-components', 'wp-i18n' ],
ACADEMICCREW_VERSION,
true
);
wp_add_inline_script( 'acc-block-recent-courses', $this->editor_script() );
register_block_type( 'academiccrew/recent-courses', [
'api_version' => 2,
'title' => __( 'AcademicCrew: Recent Courses', 'academiccrew' ),
'description' => __( 'Grid of the courses currently being taught (current academic year and teaching period), ordered by level.', 'academiccrew' ),
'category' => 'academiccrew',
'icon' => 'clock',
'supports' => [ 'html' => false, 'align' => [ 'wide', 'full' ] ],
'attributes' => [
'count' => [ 'type' => 'integer', 'default' => 3 ],
'gap' => [ 'type' => 'integer', 'default' => 12 ],
'card_color' => [ 'type' => 'string', 'default' => '#f8f9fb' ],
'max_card_width' => [ 'type' => 'integer', 'default' => 0 ],
],
'render_callback' => [ $this, 'render' ],
'editor_script' => 'acc-block-recent-courses',
'editor_style_handles' => [ 'acc-system', 'acc-featured-courses' ],
] );
}
// -------------------------------------------------------------------------
// Render (block callback + shortcode handler)
// -------------------------------------------------------------------------
public function render( array $atts ): string {
$atts = shortcode_atts( [
'count' => 3,
'gap' => 12,
'card_color' => '#f8f9fb',
'max_card_width' => 0,
], $atts, 'acc_recent_courses' );
$count = max( 1, min( 12, (int) $atts['count'] ) );
$gap = max( 0, min( 80, (int) $atts['gap'] ) );
$card_color = $this->sanitize_color( (string) $atts['card_color'] ) ?: '#f8f9fb';
$max_card_width = max( 0, (int) $atts['max_card_width'] );
$courses = $this->get_recent_courses( $count );
if ( ! $courses ) {
return sprintf(
'
',
esc_html__( 'No courses are currently being taught this academic year and period.', 'academiccrew' )
);
}
$cols = count( $courses );
$css_vars = sprintf(
'--acc-fc-cols:%d;--acc-fc-gap:%dpx;--acc-fc-card-bg:%s',
$cols,
$gap,
esc_attr( $card_color )
);
if ( $max_card_width > 0 ) {
$css_vars .= sprintf( ';--acc-fc-card-max-w:%dpx', $max_card_width );
}
$cards = implode( '', array_map( [ $this, 'render_card' ], $courses ) );
return sprintf(
'%s
',
esc_attr( $css_vars ),
$cards
);
}
// -------------------------------------------------------------------------
// Card HTML — identical to Featured Courses.
// -------------------------------------------------------------------------
private function render_card( array $course ): string {
$level_labels = [
'bsc' => 'BSc',
'msc' => 'MSc',
'phd' => 'PhD',
'professional' => __( 'Executive', 'academiccrew' ),
'other' => __( 'Other', 'academiccrew' ),
];
$code = $course['code'] ? sprintf(
'%s',
esc_html( $course['code'] )
) : '';
$level = $course['level'] ? sprintf(
'%s',
esc_attr( $course['level'] ),
esc_html( $level_labels[ $course['level'] ] ?? strtoupper( $course['level'] ) )
) : '';
$title_inner = $course['permalink']
? sprintf( '%s', esc_url( $course['permalink'] ), esc_html( $course['title'] ) )
: esc_html( $course['title'] );
$degree = $course['degree_program'] ? sprintf(
'%s
',
esc_html( $course['degree_program'] )
) : '';
$meta_items = [];
if ( $course['credits'] ) {
$meta_items[] = $this->meta_item( __( 'CFU', 'academiccrew' ), (string) $course['credits'] );
}
if ( $course['period'] ) {
$meta_items[] = $this->meta_item( __( 'Period', 'academiccrew' ), 'P' . $course['period'] );
}
if ( $course['language'] ) {
$meta_items[] = $this->meta_item( __( 'Lang', 'academiccrew' ), $course['language'] );
}
if ( $course['lecture_hours'] ) {
$meta_items[] = $this->meta_item( __( 'Hours', 'academiccrew' ), $course['lecture_hours'] . 'h' );
}
$meta = $meta_items ? sprintf(
'%s
',
implode( '', $meta_items )
) : '';
$cta = $course['permalink'] ? sprintf(
'%s →',
esc_url( $course['permalink'] ),
esc_html__( 'Course details', 'academiccrew' )
) : '';
return sprintf(
'%s%s
%s
%s%s%s',
$code,
$level,
$title_inner,
$degree,
$meta,
$cta
);
}
private function meta_item( string $label, string $value ): string {
return sprintf(
'%s%s',
esc_html( $label ),
esc_html( $value )
);
}
// -------------------------------------------------------------------------
// Data
// -------------------------------------------------------------------------
/** Courses at the current academic year (or later) AND the currently-running teaching period. */
private function get_recent_courses( int $limit ): array {
$calc = new AcademicYearCalculator();
$current_ay = $calc->calculate( 1 );
$current_period = $calc->current_period();
$posts = get_posts( [
'post_type' => 'academiccrew_course',
'post_status' => 'publish',
'posts_per_page' => -1,
'no_found_rows' => true,
'meta_query' => [
'relation' => 'AND',
[
'key' => '_acc_academic_year',
'value' => $current_ay,
'compare' => '>=',
'type' => 'NUMERIC',
],
[
'key' => '_acc_period',
'value' => $current_period,
'compare' => '=',
'type' => 'NUMERIC',
],
],
] );
if ( ! $posts ) {
return [];
}
$level_order = [ 'bsc' => 0, 'msc' => 1, 'phd' => 2, 'professional' => 3, 'other' => 4, '' => 5 ];
$courses = array_map( function ( \WP_Post $post ) {
$codes = (array) ( get_post_meta( $post->ID, '_acc_course_codes', true ) ?: [] );
if ( empty( $codes ) ) {
$old = get_post_meta( $post->ID, '_acc_course_code', true );
if ( $old ) {
$codes = [ $old ];
}
}
$page_id = (int) get_post_meta( $post->ID, '_academiccrew_linked_page_id', true );
return [
'id' => $post->ID,
'title' => get_the_title( $post ),
'permalink' => $page_id ? get_permalink( $page_id ) : get_permalink( $post ),
'code' => $codes[0] ?? '',
'level' => (string) ( get_post_meta( $post->ID, '_acc_level', true ) ?: '' ),
'credits' => (int) ( get_post_meta( $post->ID, '_acc_credits', true ) ?: 0 ),
'degree_program' => (string) ( get_post_meta( $post->ID, '_acc_degree_program', true ) ?: '' ),
'language' => (string) ( get_post_meta( $post->ID, '_acc_language', true ) ?: '' ),
'period' => (int) ( get_post_meta( $post->ID, '_acc_period', true ) ?: 0 ),
'lecture_hours' => (int) ( get_post_meta( $post->ID, '_acc_lecture_hours', true ) ?: 0 ),
'academic_year' => (int) ( get_post_meta( $post->ID, '_acc_academic_year', true ) ?: 0 ),
];
}, $posts );
usort( $courses, static function ( array $a, array $b ) use ( $level_order ): int {
if ( $a['academic_year'] !== $b['academic_year'] ) {
return $b['academic_year'] - $a['academic_year'];
}
if ( ( $level_order[ $a['level'] ] ?? 5 ) !== ( $level_order[ $b['level'] ] ?? 5 ) ) {
return ( $level_order[ $a['level'] ] ?? 5 ) - ( $level_order[ $b['level'] ] ?? 5 );
}
return strcasecmp( $a['title'], $b['title'] );
} );
return array_slice( $courses, 0, $limit );
}
private function sanitize_color( string $color ): string {
$color = trim( $color );
if ( $hex = sanitize_hex_color( $color ) ) {
return $hex;
}
if ( preg_match( '/^(?:rgba?|hsla?)\([\d\s%.,]+\)$/', $color ) ) {
return $color;
}
return '';
}
// -------------------------------------------------------------------------
// Gutenberg editor script
// -------------------------------------------------------------------------
private function editor_script(): string {
return <<<'JS'
(function(blocks, el, SSR, blockEditor, components, i18n) {
var __ = i18n.__;
var InspectorControls = blockEditor.InspectorControls;
var useBlockProps = blockEditor.useBlockProps;
var PanelBody = components.PanelBody;
var RangeControl = components.RangeControl;
var BaseControl = components.BaseControl;
blocks.registerBlockType('academiccrew/recent-courses', {
edit: function(props) {
var attrs = props.attributes;
var setA = props.setAttributes;
var blockProps = useBlockProps({ style: { position: 'relative' } });
return el('div', blockProps,
el(InspectorControls, { key: 'controls' },
el(PanelBody, { title: __('Settings', 'academiccrew'), initialOpen: true },
el(RangeControl, {
label: __('Number of courses', 'academiccrew'),
value: attrs.count,
min: 1, max: 12, step: 1,
onChange: function(v) { setA({ count: v }); }
}),
el(RangeControl, {
label: __('Gap between cards (px)', 'academiccrew'),
value: attrs.gap,
min: 0, max: 80, step: 2,
onChange: function(v) { setA({ gap: v }); }
}),
el(RangeControl, {
label: __('Max card width (px, 0 = unlimited)', 'academiccrew'),
value: attrs.max_card_width,
min: 0, max: 600, step: 10,
onChange: function(v) { setA({ max_card_width: v }); }
}),
el(BaseControl, {
label: __('Card background', 'academiccrew'),
id: 'acc-rc-card-color'
},
el('input', {
id: 'acc-rc-card-color',
type: 'color',
value: attrs.card_color,
style: { display: 'block', marginTop: '6px', width: '48px', height: '32px', border: 'none', padding: 0, cursor: 'pointer' },
onChange: function(e) { setA({ card_color: e.target.value }); }
})
)
)
),
el('div', { key: 'preview', style: { pointerEvents: 'none' } },
el(SSR, { block: 'academiccrew/recent-courses', attributes: attrs })
)
);
},
save: function() { return null; }
});
}(wp.blocks, wp.element.createElement, wp.serverSideRender, wp.blockEditor, wp.components, wp.i18n));
JS;
}
}