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( '
' . '
' . '' . '
%4$s

' . '
    %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'; } }