true ] );
wp_set_script_translations( self::HANDLE, 'advently', ADVENTLY_DIR . 'languages' );
add_shortcode( 'advently', [ self::class, 'shortcode' ] );
register_block_type(
ADVENTLY_DIR . 'blocks/calendar',
[
'render_callback' => [ self::class, 'block' ],
]
);
}
/**
* Plugin version plus the file's modification time, so browsers and CDNs
* fetch a changed file instead of serving the cached one.
*/
private static function assetVersion( string $file ): string {
$mtime = filemtime( ADVENTLY_DIR . 'assets/src/frontend/' . $file );
return ADVENTLY_VERSION . ( $mtime ? '.' . $mtime : '' );
}
/**
* @param array|string $atts Shortcode attributes.
*/
public static function shortcode( $atts ): string {
$atts = shortcode_atts(
[ 'title' => __( 'Advent calendar', 'advently' ) ],
is_array( $atts ) ? $atts : [],
'advently'
);
return self::render( (string) $atts['title'] );
}
public static function block( array $attributes ): string {
$title = isset( $attributes['title'] ) ? (string) $attributes['title'] : __( 'Advent calendar', 'advently' );
return self::render( $title, get_block_wrapper_attributes() );
}
public static function render( string $title, string $wrapperAttributes = '' ): string {
$config = Option::get();
$schedule = Option::schedule( $config );
$now = Option::now();
$statuses = $schedule->statuses( $now );
$id = 'advently-' . ( ++self::$instances );
self::enqueue( $config, $schedule );
$classes = [
'advently',
'advently--' . $config['theme'],
'advently--' . $config['shape'],
'advently--font-' . $config['font'],
];
if ( $config['snow'] ) {
$classes[] = 'advently--snow';
}
$cols = self::columns( $config['doors'], [ 6, 7, 5, 8, 4 ] );
$colsPhone = self::columns( $config['doors'], [ 4, 3 ] );
$style = sprintf(
'--adv-accent:%1$s;--adv-accent-ink:%2$s;--adv-cols:%3$d;--adv-cols-phone:%4$d;',
$config['accent'],
self::inkFor( $config['accent'] ),
$cols,
$colsPhone
);
$background = $config['background_id'] ? wp_get_attachment_image_url( $config['background_id'], 'full' ) : false;
if ( $background ) {
$classes[] = 'advently--image';
$style .= '--adv-image:url("' . esc_url_raw( $background ) . '");';
}
$doors = '';
// Colours follow the grid position, so a shuffled calendar keeps its checkerboard.
foreach ( Layout::order( $config['doors'], $config['start'], $config['shuffle'] ) as $i => $n ) {
$doors .= self::door( $n, $statuses[ $n ], $schedule, self::tone( $i + 1, $cols ), self::tone( $i + 1, $colsPhone ) );
}
$head = '' !== trim( $title )
? '
' . esc_html( $title ) . '
'
: '';
$section = sprintf(
'' .
'' .
'
' .
'
' .
'
%5$s
' .
'' .
'
' .
'
' .
'' .
'',
esc_attr( implode( ' ', $classes ) ),
esc_attr( $style ),
$head ? 'aria-labelledby="' . esc_attr( $id ) . '-title"' : 'aria-label="' . esc_attr__( 'Advent calendar', 'advently' ) . '"',
$head,
$doors,
esc_html__( 'Please turn on JavaScript to open the doors.', 'advently' )
);
if ( '' === $wrapperAttributes ) {
return $section;
}
// Block wrapper attributes are already escaped by core.
return '' . $section . '
';
}
private static function door( int $n, string $status, Schedule $schedule, int $tone, int $tonePhone ): string {
$opens = $schedule->dateOf( $n );
/* translators: date format for a door's opening day, see https://www.php.net/manual/datetime.format.php */
$day = wp_date( _x( 'F j', 'door opening day', 'advently' ), $opens->getTimestamp() );
return sprintf(
'' .
'
' .
'' .
'',
esc_attr( $status ),
$n,
esc_attr( $opens->format( DATE_ATOM ) ),
esc_attr( $day ),
esc_attr( self::doorLabel( $n, $status, $day ) ),
'locked' === $status || 'ended' === $status ? ' aria-disabled="true"' : '',
$tone,
$tonePhone
);
}
/**
* Leaf colour 1–4 for door $n in a grid of $cols columns: a checkerboard of
* the two hues (1/3 and 2/4), with the shade switching every row. Worked out
* per grid because a plain nth-child cycle turns into stripes whenever the
* column count is a multiple of the cycle.
*/
public static function tone( int $n, int $cols ): int {
$cols = max( 1, $cols );
$row = intdiv( $n - 1, $cols );
$col = ( $n - 1 ) % $cols;
$hue = ( $row + $col ) % 2;
return 1 + $hue + 2 * ( $row % 2 );
}
/**
* The accessible name of a door. calendar.js keeps it in sync with the live status.
*/
public static function doorLabel( int $n, string $status, string $day ): string {
switch ( $status ) {
case 'today':
/* translators: %d: door number. */
return sprintf( __( 'Door %d, open today', 'advently' ), $n );
case 'past':
/* translators: %d: door number. */
return sprintf( __( 'Door %d', 'advently' ), $n );
case 'ended':
/* translators: %d: door number. */
return sprintf( __( 'Door %d, the calendar has ended', 'advently' ), $n );
default:
/* translators: 1: door number, 2: day it opens, e.g. December 7. */
return sprintf( __( 'Door %1$d, opens on %2$s', 'advently' ), $n, $day );
}
}
private static function enqueue( array $config, Schedule $schedule ): void {
wp_enqueue_style( self::HANDLE );
if ( wp_script_is( self::HANDLE, 'enqueued' ) ) {
return;
}
wp_enqueue_script( self::HANDLE );
$data = [
'rest' => esc_url_raw( rest_url( DoorsController::NS . '/' ) ),
'nonce' => wp_create_nonce( 'wp_rest' ),
'mode' => $config['mode'],
'missed' => $config['missed'],
'doors' => $schedule->doors(),
'store' => 'advently:' . $config['start'],
'snow' => (bool) $config['snow'],
'consent' => self::consentHtml( $config['consent_text'] ),
'note' => self::emailNoteHtml(),
'loginUrl' => esc_url_raw( wp_login_url( self::currentUrl() ) ),
'loggedIn' => is_user_logged_in(),
'ajaxUrl' => esc_url_raw( admin_url( 'admin-ajax.php' ) ),
'shop' => get_bloginfo( 'name' ),
'preview' => self::previewData( $schedule ),
];
wp_add_inline_script( self::HANDLE, 'window.adventlyData = ' . wp_json_encode( $data ) . ';', 'before' );
}
/**
* Days the owner can preview (store managers only; false for everyone else).
* The server checks the capability again on every request.
*
* @return array|false
*/
private static function previewData( Schedule $schedule ) {
if ( ! current_user_can( 'manage_woocommerce' ) ) {
return false;
}
$days = [];
$doors = $schedule->doors();
for ( $n = 1; $n <= $doors; $n++ ) {
$date = $schedule->dateOf( $n );
/* translators: date format for a door's opening day, see https://www.php.net/manual/datetime.format.php */
$day = wp_date( _x( 'F j', 'door opening day', 'advently' ), $date->getTimestamp() );
$days[] = [
'value' => $date->format( 'Y-m-d' ),
/* translators: 1: door number, 2: day, e.g. December 7. */
'label' => sprintf( __( 'Door %1$d, %2$s', 'advently' ), $n, $day ),
];
}
$before = $schedule->dateOf( 1 )->modify( '-1 day' );
$after = $schedule->end();
$initial = isset( $_GET['advently_preview'] ) ? sanitize_text_field( wp_unslash( $_GET['advently_preview'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only preselects a day in the owner's own preview.
return [
'initial' => preg_match( '/^\d{4}-\d{2}-\d{2}$/', $initial ) ? $initial : '',
'before' => $before->format( 'Y-m-d' ),
'after' => $after->format( 'Y-m-d' ),
'days' => $days,
];
}
/**
* The optional "send me offers" sentence next to the email box: the
* owner's text, or a translated default that names the shop.
*/
private static function consentHtml( string $text ): string {
if ( '' === trim( $text ) ) {
/* translators: %s: shop name. */
$text = esc_html( sprintf( __( 'Also send me offers and news from %s.', 'advently' ), get_bloginfo( 'name' ) ) );
}
return wp_kses(
$text,
[
'a' => [
'href' => true,
'target' => true,
'rel' => true,
],
'strong' => [],
'em' => [],
]
);
}
/**
* The line under the email box: what the address is used for, and the privacy policy.
*/
private static function emailNoteHtml(): string {
$note = esc_html__( 'We email each coupon to this address. Offers only if you tick the box below.', 'advently' );
$privacy = get_privacy_policy_url();
if ( $privacy ) {
$note .= ' ' . esc_html__( 'Privacy policy', 'advently' ) . '';
}
return $note;
}
private static function currentUrl(): string {
$id = get_queried_object_id();
return $id ? (string) get_permalink( $id ) : home_url( '/' );
}
/**
* The first column count that fills every row, else the one that leaves the fullest last row.
*
* @param int[] $options Preferred column counts, best first.
*/
public static function columns( int $doors, array $options ): int {
$best = $options[0];
$bestScore = -1.0;
foreach ( $options as $cols ) {
$rest = $doors % $cols;
if ( 0 === $rest ) {
return min( $cols, max( 1, $doors ) );
}
$score = $rest / $cols;
if ( $score > $bestScore ) {
$best = $cols;
$bestScore = $score;
}
}
return min( $best, max( 1, $doors ) );
}
/**
* Black or white, whichever reads better on the accent colour (WCAG relative luminance).
*/
public static function inkFor( string $hex ): string {
$hex = ltrim( $hex, '#' );
if ( 3 === strlen( $hex ) ) {
$hex = $hex[0] . $hex[0] . $hex[1] . $hex[1] . $hex[2] . $hex[2];
}
$lum = 0.0;
foreach ( [ 0.2126, 0.7152, 0.0722 ] as $i => $weight ) {
$c = hexdec( substr( $hex, $i * 2, 2 ) ) / 255;
$c = $c <= 0.03928 ? $c / 12.92 : ( ( $c + 0.055 ) / 1.055 ) ** 2.4;
$lum += $weight * $c;
}
// Contrast against white vs against black; pick the larger.
return ( 1.05 / ( $lum + 0.05 ) ) >= ( ( $lum + 0.05 ) / 0.05 ) ? '#ffffff' : '#1a1a1a';
}
}