Plugin Directory

Changeset 3465730


Ignore:
Timestamp:
02/20/2026 11:13:52 AM (5 weeks ago)
Author:
donncha
Message:

Tagged 0.3.0

Location:
photo-competition-manager/tags/0.3.0
Files:
1 added
17 edited
13 copied

Legend:

Unmodified
Added
Removed
  • photo-competition-manager/tags/0.3.0/admin/class-competitions-controller.php

    r3415757 r3465730  
    120120            document.addEventListener(\'click\', function(e) {
    121121                if (e.target.classList.contains(\'photo-comp-delete\') ||
    122                         e.target.classList.contains(\'photo-comp-reset-votes\')) {
     122                        e.target.classList.contains(\'photo-comp-reset-votes\') ||
     123                        e.target.classList.contains(\'photo-comp-regenerate-hash\')) {
    123124                            var confirmMessage = e.target.getAttribute(\'data-confirm\');
    124125                            if (confirmMessage && !confirm(confirmMessage)) {
     
    128129                        }
    129130                });
     131
     132            // Progress meter preview animations
     133            document.querySelectorAll(".progress-meter-card").forEach(function(card) {
     134                card.addEventListener("click", function() {
     135                    document.querySelectorAll(".progress-meter-card").forEach(function(c) {
     136                        c.classList.remove("active");
     137                        c.style.borderColor = "#ddd";
     138                    });
     139                    card.classList.add("active");
     140                    card.style.borderColor = "#0073aa";
     141                });
     142            });
     143
     144            function animatePreviews() {
     145                var duration = 3000;
     146                var startTime = Date.now();
     147
     148                function tick() {
     149                    var elapsed = Date.now() - startTime;
     150                    var progress = (elapsed % duration) / duration;
     151
     152                    document.querySelectorAll(".meter-preview").forEach(function(preview) {
     153                        var type = preview.dataset.meterType;
     154                        renderMeterPreview(preview, type, progress);
     155                    });
     156
     157                    requestAnimationFrame(tick);
     158                }
     159
     160                tick();
     161            }
     162
     163            function renderMeterPreview(container, type, progress) {
     164                if (!container._initialized) {
     165                    container._initialized = true;
     166                    container.innerHTML = "";
     167
     168                    if (type === "bar") {
     169                        container.style.display = "flex";
     170                        container.style.alignItems = "flex-end";
     171                        var track = document.createElement("div");
     172                        track.style.cssText = "width:100%;height:8px;background:rgba(255,255,255,0.2);border-radius:0;";
     173                        var fill = document.createElement("div");
     174                        fill.style.cssText = "height:100%;background:#0073aa;transition:width 100ms linear;border-radius:0;";
     175                        fill.className = "meter-fill";
     176                        track.appendChild(fill);
     177                        container.appendChild(track);
     178                    } else if (type === "line") {
     179                        container.style.display = "flex";
     180                        container.style.alignItems = "flex-end";
     181                        var track = document.createElement("div");
     182                        track.style.cssText = "width:100%;height:3px;background:rgba(255,255,255,0.1);";
     183                        var fill = document.createElement("div");
     184                        fill.style.cssText = "height:100%;background:#fff;box-shadow:0 0 8px rgba(255,255,255,0.6);transition:width 100ms linear;";
     185                        fill.className = "meter-fill";
     186                        track.appendChild(fill);
     187                        container.appendChild(track);
     188                    } else if (type === "dots") {
     189                        container.style.display = "flex";
     190                        container.style.alignItems = "flex-end";
     191                        container.style.justifyContent = "center";
     192                        container.style.gap = "4px";
     193                        container.style.paddingBottom = "4px";
     194                        for (var i = 0; i < 15; i++) {
     195                            var dot = document.createElement("div");
     196                            dot.style.cssText = "width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.2);transition:background 0.2s,transform 0.2s;";
     197                            dot.className = "meter-dot";
     198                            container.appendChild(dot);
     199                        }
     200                    } else if (type === "radial") {
     201                        container.style.display = "flex";
     202                        container.style.alignItems = "center";
     203                        container.style.justifyContent = "center";
     204                        var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
     205                        svg.setAttribute("width", "40");
     206                        svg.setAttribute("height", "40");
     207                        svg.setAttribute("viewBox", "0 0 40 40");
     208                        var bgCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
     209                        bgCircle.setAttribute("cx", "20");
     210                        bgCircle.setAttribute("cy", "20");
     211                        bgCircle.setAttribute("r", "16");
     212                        bgCircle.setAttribute("fill", "none");
     213                        bgCircle.setAttribute("stroke", "rgba(255,255,255,0.2)");
     214                        bgCircle.setAttribute("stroke-width", "3");
     215                        svg.appendChild(bgCircle);
     216                        var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
     217                        circle.setAttribute("cx", "20");
     218                        circle.setAttribute("cy", "20");
     219                        circle.setAttribute("r", "16");
     220                        circle.setAttribute("fill", "none");
     221                        circle.setAttribute("stroke", "#0073aa");
     222                        circle.setAttribute("stroke-width", "3");
     223                        circle.setAttribute("stroke-linecap", "round");
     224                        circle.setAttribute("transform", "rotate(-90 20 20)");
     225                        var circumference = 2 * Math.PI * 16;
     226                        circle.setAttribute("stroke-dasharray", circumference);
     227                        circle.setAttribute("stroke-dashoffset", circumference);
     228                        circle.className.baseVal = "meter-ring";
     229                        svg.appendChild(circle);
     230                        container.appendChild(svg);
     231                    }
     232                }
     233
     234                if (type === "bar" || type === "line") {
     235                    var fill = container.querySelector(".meter-fill");
     236                    if (fill) fill.style.width = (progress * 100) + "%";
     237                } else if (type === "dots") {
     238                    var dots = container.querySelectorAll(".meter-dot");
     239                    var filledCount = Math.floor(progress * dots.length);
     240                    dots.forEach(function(dot, i) {
     241                        if (i < filledCount) {
     242                            dot.style.background = "#0073aa";
     243                            dot.style.transform = "scale(1.3)";
     244                        } else if (i === filledCount) {
     245                            dot.style.background = "rgba(0,115,170,0.5)";
     246                            dot.style.transform = "scale(1.1)";
     247                        } else {
     248                            dot.style.background = "rgba(255,255,255,0.2)";
     249                            dot.style.transform = "scale(1)";
     250                        }
     251                    });
     252                } else if (type === "radial") {
     253                    var ring = container.querySelector(".meter-ring");
     254                    if (ring) {
     255                        var circumference = 2 * Math.PI * 16;
     256                        ring.setAttribute("stroke-dashoffset", circumference * (1 - progress));
     257                    }
     258                }
     259            }
     260
     261            animatePreviews();
    130262            })();
    131263        });
     
    178310                'close_date' => $this->parse_date_input( $close_date_raw ),
    179311                'settings'   => $this->get_global_settings(),
     312                'share_hash' => Competition_Settings::generate_share_hash(),
    180313            );
    181314
     
    251384
    252385            $this->redirect_with_settings_errors( $this->dashboard_url() );
     386        }
     387
     388        if ( 'generate_results_link' === $action && isset( $_GET['competition'] ) ) {
     389            $competition_id = absint( wp_unslash( $_GET['competition'] ) );
     390
     391            check_admin_referer( 'photo_competition_generate_results_link_' . $competition_id );
     392
     393            $competition = $this->competitions->find( $competition_id );
     394            if ( ! $competition ) {
     395                add_settings_error(
     396                    'photo_competition_manager',
     397                    'competition_not_found',
     398                    __( 'Competition not found.', 'photo-competition-manager' ),
     399                    'error'
     400                );
     401                $this->redirect_with_settings_errors( $this->dashboard_url() );
     402                return;
     403            }
     404
     405            $new_hash = Competition_Settings::generate_share_hash();
     406            $result   = $this->competitions->update_share_hash( $competition_id, $new_hash );
     407
     408            if ( is_wp_error( $result ) ) {
     409                add_settings_error(
     410                    'photo_competition_manager',
     411                    $result->get_error_code(),
     412                    $result->get_error_message(),
     413                    'error'
     414                );
     415            } else {
     416                $settings         = Competition_Settings::parse( $competition->settings );
     417                $results_page_url = $settings['urls']['results_page'] ?? '';
     418                if ( ! empty( $results_page_url ) ) {
     419                    $share_url = add_query_arg( 'share', $new_hash, $results_page_url );
     420                    add_settings_error(
     421                        'photo_competition_manager',
     422                        'results_link_generated',
     423                        sprintf(
     424                            /* translators: %s: share URL */
     425                            __( 'Results share link generated: <a href="%s" target="_blank">%s</a>', 'photo-competition-manager' ),
     426                            esc_url( $share_url ),
     427                            esc_html( $share_url )
     428                        ),
     429                        'updated'
     430                    );
     431                } else {
     432                    add_settings_error(
     433                        'photo_competition_manager',
     434                        'results_link_generated',
     435                        __( 'Results share hash generated. Configure a results page URL to get a shareable link.', 'photo-competition-manager' ),
     436                        'updated'
     437                    );
     438                }
     439            }
     440
     441            $this->redirect_with_settings_errors( $this->dashboard_url() );
     442            return;
    253443        }
    254444
     
    484674            $voting_page_url = sanitize_url( $this->get_post_string( 'voting_page_url', '' ) );
    485675
    486             // Hash the password if provided, clear if checkbox checked, otherwise preserve existing hash.
    487             $hashed_password = '';
     676            $progress_meter_type_input = sanitize_text_field( $this->get_post_string( 'progress_meter_type', 'bar' ) );
     677            if ( ! in_array( $progress_meter_type_input, array( 'bar', 'line', 'dots', 'radial' ), true ) ) {
     678                $progress_meter_type_input = 'bar';
     679            }
     680
     681            // Store the password if provided, clear if empty or checkbox checked.
     682            // For legacy hashed passwords (not shown in the form), preserve when field is blank.
     683            $existing_password    = $existing_settings['voting']['password'] ?? '';
     684            $is_legacy_hash       = '' !== $existing_password && (bool) preg_match( '/^\$P\$|\$wp\$/', $existing_password );
     685            $hashed_password      = '';
    488686            if ( $voting_password_clear ) {
    489                 // Clear the password - leave empty.
     687                // Clear the password via checkbox (legacy hash flow).
    490688                $hashed_password = '';
    491689            } elseif ( ! empty( $voting_password ) ) {
    492                 // New password provided - lowercase and hash it for case-insensitive comparison.
    493                 $hashed_password = wp_hash_password( strtolower( $voting_password ) );
    494             } elseif ( isset( $existing_settings['voting']['password'] ) ) {
    495                 // Preserve existing password hash if no new password provided and not clearing.
    496                 $hashed_password = $existing_settings['voting']['password'];
    497             }
     690                // Password provided - store lowercase for case-insensitive comparison.
     691                $hashed_password = strtolower( $voting_password );
     692            } elseif ( $is_legacy_hash ) {
     693                // Legacy hash: blank field means keep existing password.
     694                $hashed_password = $existing_password;
     695            }
     696
     697            // Preserve existing results settings (results_visible) controlled via Voting Controls page.
     698            $existing_results = $existing_settings['results'] ?? array();
    498699
    499700            $settings = array(
     
    515716                ),
    516717                'slideshow'       => array(
    517                     'duration_seconds' => 10,
     718                    'duration_seconds'    => 10,
     719                    'progress_meter_type' => $progress_meter_type_input,
    518720                ),
    519721                'email_reminders' => array(
     
    527729                    'voting_page' => $voting_page_url,
    528730                ),
     731                'results'         => $existing_results,
    529732            );
    530733
     
    769972     */
    770973    private function render_competition_settings_form( object $competition ): void {
    771         $settings       = Competition_Settings::parse( $competition->settings );
    772         $categories     = Competition_Settings::get_categories( $settings );
    773         $grades         = Competition_Settings::get_grades( $settings );
    774         $upload         = Competition_Settings::get_upload_constraints( $settings );
    775         $voting         = Competition_Settings::get_voting_config( $settings );
    776         $voting_ui_type = $voting['ui_type'] ?? '';
     974        $settings            = Competition_Settings::parse( $competition->settings );
     975        $categories          = Competition_Settings::get_categories( $settings );
     976        $grades              = Competition_Settings::get_grades( $settings );
     977        $upload              = Competition_Settings::get_upload_constraints( $settings );
     978        $voting              = Competition_Settings::get_voting_config( $settings );
     979        $slideshow           = $settings['slideshow'] ?? array();
     980        $progress_meter_type = $slideshow['progress_meter_type'] ?? 'bar';
     981        $voting_ui_type      = $voting['ui_type'] ?? '';
    777982        if ( ! in_array( $voting_ui_type, array( 'buttons', 'dropdown' ), true ) ) {
    778983            $voting_ui_type = Competition_Settings::get_voting_ui_type( $settings );
     
    8431048        echo '<label for="voting_password">' . esc_html__( 'Voting Password (for password mode)', 'photo-competition-manager' ) . '</label><br />';
    8441049
    845         // Show placeholder if password is set, empty if not.
    846         $password_placeholder = ! empty( $voting['password'] ) ? __( 'Password is set', 'photo-competition-manager' ) : '';
    847         echo '<input type="text" id="voting_password" name="voting_password" value="" placeholder="' . esc_attr( $password_placeholder ) . '" class="regular-text" />';
    848 
    849         if ( ! empty( $voting['password'] ) ) {
     1050        $is_plaintext_password = ! empty( $voting['password'] ) && ! preg_match( '/^\$P\$|\$wp\$/', $voting['password'] );
     1051        $is_legacy_hash        = ! empty( $voting['password'] ) && ! $is_plaintext_password;
     1052        $password_value        = $is_plaintext_password ? $voting['password'] : '';
     1053
     1054        echo '<input type="text" id="voting_password" name="voting_password" value="' . esc_attr( $password_value ) . '" class="regular-text" />';
     1055
     1056        if ( $is_legacy_hash ) {
    8501057            echo '<br /><label>';
    8511058            echo '<input type="checkbox" id="voting_password_clear" name="voting_password_clear" value="1" />';
     
    8541061            echo '<br /><span class="description">' . esc_html__( 'A password is currently set. Enter a new password to change it, check the box above to remove password protection, or leave both blank to keep the existing password. Passwords are not case-sensitive.', 'photo-competition-manager' ) . '</span>';
    8551062        } else {
    856             echo '<br /><span class="description">' . esc_html__( 'Voters must enter this password before submitting votes. Leave blank to disable. Only used when auth mode is "Password-based". Passwords are not case-sensitive.', 'photo-competition-manager' ) . '</span>';
     1063            echo '<br /><span class="description">' . esc_html__( 'Leave blank for no password. Passwords are case insensitive.', 'photo-competition-manager' ) . '</span>';
    8571064        }
    8581065        echo '</p>';
     
    8821089        echo '</p>';
    8831090
     1091        echo '<h3>' . esc_html__( 'Slideshow', 'photo-competition-manager' ) . '</h3>';
     1092
     1093        echo '<p>';
     1094        echo '<label>' . esc_html__( 'Progress Meter Style', 'photo-competition-manager' ) . '</label>';
     1095        echo '</p>';
     1096
     1097        $meter_types = array(
     1098            'bar'    => __( 'Bar', 'photo-competition-manager' ),
     1099            'line'   => __( 'Thin Line', 'photo-competition-manager' ),
     1100            'dots'   => __( 'Dots', 'photo-competition-manager' ),
     1101            'radial' => __( 'Radial', 'photo-competition-manager' ),
     1102        );
     1103
     1104        echo '<div class="progress-meter-selector" style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px;">';
     1105
     1106        foreach ( $meter_types as $type => $label ) {
     1107            $is_active = ( $type === $progress_meter_type ) ? ' active' : '';
     1108            echo '<label class="progress-meter-card' . esc_attr( $is_active ) . '" style="cursor: pointer; border: 2px solid ' . ( $is_active ? '#0073aa' : '#ddd' ) . '; border-radius: 8px; padding: 12px; text-align: center; background: #1a1a1a; min-width: 140px; transition: border-color 0.2s;">';
     1109            echo '<input type="radio" name="progress_meter_type" value="' . esc_attr( $type ) . '"' . checked( $progress_meter_type, $type, false ) . ' style="display: none;" />';
     1110            echo '<div class="meter-preview" data-meter-type="' . esc_attr( $type ) . '" style="height: 50px; position: relative; margin-bottom: 8px; overflow: hidden; border-radius: 4px;"></div>';
     1111            echo '<span style="color: #666; font-size: 13px; font-weight: 600;">' . esc_html( $label ) . '</span>';
     1112            echo '</label>';
     1113        }
     1114
     1115        echo '</div>';
     1116        echo '<span class="description">' . esc_html__( 'Choose the progress indicator style shown during the slideshow.', 'photo-competition-manager' ) . '</span>';
     1117
    8841118        echo '<h3>' . esc_html__( 'URLs', 'photo-competition-manager' ) . '</h3>';
    8851119
     
    9001134        echo '<br /><span class="description">' . esc_html__( 'The page where members can vote on images. This URL will be included in voting notification emails.', 'photo-competition-manager' ) . '</span>';
    9011135        echo '</p>';
     1136
     1137        $share_hash = $competition->share_hash ?? '';
     1138        if ( ! empty( $share_hash ) ) {
     1139            echo '<p>';
     1140            echo '<label>' . esc_html__( 'Results Share Hash', 'photo-competition-manager' ) . '</label><br />';
     1141            echo '<code>' . esc_html( $share_hash ) . '</code>';
     1142
     1143            $results_page_url = $urls['results_page'] ?? '';
     1144            if ( ! empty( $results_page_url ) ) {
     1145                $share_url = add_query_arg( 'share', $share_hash, $results_page_url );
     1146                echo '<br /><span class="description">' . esc_html__( 'Share link:', 'photo-competition-manager' ) . ' <a href="' . esc_url( $share_url ) . '" target="_blank">' . esc_html( $share_url ) . '</a></span>';
     1147            }
     1148            echo '</p>';
     1149        }
    9021150
    9031151        submit_button( __( 'Save Settings', 'photo-competition-manager' ) );
     
    10551303                $actions[] = sprintf( '<span title="Send only on open competitions" style="color: #888;">%s</span>', esc_html__( 'Send Upload Emails', 'photo-competition-manager' ) );
    10561304            }
     1305
     1306            // Generate Results Link action.
     1307            $generate_link_url = wp_nonce_url(
     1308                add_query_arg(
     1309                    array(
     1310                        'page'        => 'photo-competition-manager',
     1311                        'action'      => 'generate_results_link',
     1312                        'competition' => (int) $competition->id,
     1313                    ),
     1314                    admin_url( 'admin.php' )
     1315                ),
     1316                'photo_competition_generate_results_link_' . (int) $competition->id
     1317            );
     1318
     1319            $actions[] = sprintf(
     1320                '<a href="%s" class="photo-comp-regenerate-hash" data-confirm="%s">%s</a>',
     1321                esc_url( $generate_link_url ),
     1322                esc_attr( __( 'This will generate a new results link and invalidate any previously shared link. Continue?', 'photo-competition-manager' ) ),
     1323                esc_html__( 'Generate Results Link', 'photo-competition-manager' )
     1324            );
    10571325
    10581326            if ( $is_archived ) {
  • photo-competition-manager/tags/0.3.0/admin/class-email-templates-controller.php

    r3415757 r3465730  
    247247                'enabled'     => false,
    248248                'subject'     => __( 'Image uploaded successfully for {competition_title}', 'photo-competition-manager' ),
    249                 'body'        => __( "<p>Hi {member_name},</p>\n\n<p>Your image has been successfully uploaded for {competition_title} in the {category_name} category.</p>\n\n<p>You have uploaded {current_count} of {quota} images for this category.</p>\n\n<p>Thank you for your submission!</p>", 'photo-competition-manager' ),
    250                 'merge_tags'  => array( '{member_name}', '{competition_title}', '{category_name}', '{current_count}', '{quota}', '{site_name}' ),
     249                'body'        => __( "<p>Hi {member_name},</p>\n\n<p>Your image has been successfully uploaded for {competition_title} in the {category_name} category.</p>\n\n<p>You are entering in the {member_grade} grade.</p>\n\n<p>You have uploaded {current_count} of {quota} images for this category.</p>\n\n<p>Thank you for your submission!</p>", 'photo-competition-manager' ),
     250                'merge_tags'  => array( '{member_name}', '{member_grade}', '{competition_title}', '{category_name}', '{current_count}', '{quota}', '{site_name}' ),
    251251            ),
    252252        );
  • photo-competition-manager/tags/0.3.0/admin/class-members-controller.php

    r3446169 r3465730  
    278278            $email_raw = $this->get_post_string( 'member_email' );
    279279            $grade_raw = $this->get_post_string( 'member_grade' );
    280             $is_active = isset( $_POST['member_active'] );
    281             $name      = sanitize_text_field( $name_raw );
    282             $email     = sanitize_email( $email_raw );
    283             $grade     = sanitize_text_field( $grade_raw );
     280            $is_active    = isset( $_POST['member_active'] );
     281            $is_committee = isset( $_POST['member_committee'] );
     282            $name         = sanitize_text_field( $name_raw );
     283            $email        = sanitize_email( $email_raw );
     284            $grade        = sanitize_text_field( $grade_raw );
    284285
    285286            $data = array(
    286                 'name'   => $name,
    287                 'email'  => $email,
    288                 'grade'  => $grade,
    289                 'active' => $is_active ? 1 : 0,
     287                'name'      => $name,
     288                'email'     => $email,
     289                'grade'     => $grade,
     290                'active'    => $is_active ? 1 : 0,
     291                'committee' => $is_committee ? 1 : 0,
    290292            );
    291293
     
    316318            check_admin_referer( 'photo_competition_member_update_' . $member_id, 'photo_competition_member_nonce' );
    317319
    318             $name_raw  = $this->get_post_string( 'member_name' );
    319             $email_raw = $this->get_post_string( 'member_email' );
    320             $grade_raw = $this->get_post_string( 'member_grade' );
    321             $is_active = isset( $_POST['member_active'] );
    322             $name      = sanitize_text_field( $name_raw );
    323             $email     = sanitize_email( $email_raw );
    324             $grade     = sanitize_text_field( $grade_raw );
     320            $name_raw     = $this->get_post_string( 'member_name' );
     321            $email_raw    = $this->get_post_string( 'member_email' );
     322            $grade_raw    = $this->get_post_string( 'member_grade' );
     323            $is_active    = isset( $_POST['member_active'] );
     324            $is_committee = isset( $_POST['member_committee'] );
     325            $name         = sanitize_text_field( $name_raw );
     326            $email        = sanitize_email( $email_raw );
     327            $grade        = sanitize_text_field( $grade_raw );
    325328
    326329            $data = array(
    327                 'name'   => $name,
    328                 'email'  => $email,
    329                 'grade'  => $grade,
    330                 'active' => $is_active ? 1 : 0,
     330                'name'      => $name,
     331                'email'     => $email,
     332                'grade'     => $grade,
     333                'active'    => $is_active ? 1 : 0,
     334                'committee' => $is_committee ? 1 : 0,
    331335            );
    332336
     
    956960        echo '</p>';
    957961
     962        echo '<p>';
     963        echo '<label>';
     964        echo '<input type="checkbox" name="member_committee" value="1" /> ';
     965        echo esc_html__( 'Committee Member', 'photo-competition-manager' );
     966        echo '</label>';
     967        echo '</p>';
     968
    958969        submit_button( __( 'Add Member', 'photo-competition-manager' ) );
    959970
     
    10091020        echo '<input type="checkbox" name="member_active" value="1"' . checked( (bool) $member->active, true, false ) . ' /> ';
    10101021        echo esc_html__( 'Active', 'photo-competition-manager' );
     1022        echo '</label>';
     1023        echo '</p>';
     1024
     1025        echo '<p>';
     1026        echo '<label>';
     1027        echo '<input type="checkbox" name="member_committee" value="1"' . checked( (bool) ( $member->committee ?? false ), true, false ) . ' /> ';
     1028        echo esc_html__( 'Committee Member', 'photo-competition-manager' );
    10111029        echo '</label>';
    10121030        echo '</p>';
  • photo-competition-manager/tags/0.3.0/admin/class-results-controller.php

    r3415757 r3465730  
    260260        }
    261261
     262        if ( 'send_results_committee' === $action || 'send_results_all' === $action ) {
     263            $competition_id = isset( $_GET['competition'] ) ? absint( wp_unslash( $_GET['competition'] ) ) : 0;
     264
     265            check_admin_referer( 'photo_competition_' . $action . '_' . $competition_id );
     266
     267            $redirect_url = add_query_arg(
     268                array(
     269                    'page'        => 'photo-competition-manager-results',
     270                    'competition' => $competition_id,
     271                ),
     272                admin_url( 'admin.php' )
     273            );
     274
     275            $competition = $this->competitions->find( $competition_id );
     276            if ( ! $competition ) {
     277                add_settings_error(
     278                    'photo_competition_results',
     279                    'competition_not_found',
     280                    __( 'Competition not found.', 'photo-competition-manager' ),
     281                    'error'
     282                );
     283                $this->redirect_with_settings_errors( $redirect_url );
     284                return;
     285            }
     286
     287            $share_hash = $competition->share_hash ?? '';
     288
     289            if ( empty( $share_hash ) ) {
     290                add_settings_error(
     291                    'photo_competition_results',
     292                    'no_share_hash',
     293                    __( 'No share hash exists. Generate a results link first from the Competitions page.', 'photo-competition-manager' ),
     294                    'error'
     295                );
     296                $this->redirect_with_settings_errors( $redirect_url );
     297                return;
     298            }
     299
     300            $settings         = Competition_Settings::parse( $competition->settings );
     301            $results_page_url = $settings['urls']['results_page'] ?? '';
     302            if ( empty( $results_page_url ) ) {
     303                add_settings_error(
     304                    'photo_competition_results',
     305                    'no_results_page',
     306                    __( 'No results page URL configured. Set one in competition settings.', 'photo-competition-manager' ),
     307                    'error'
     308                );
     309                $this->redirect_with_settings_errors( $redirect_url );
     310                return;
     311            }
     312
     313            $share_url = add_query_arg( 'share', $share_hash, $results_page_url );
     314
     315            if ( 'send_results_committee' === $action ) {
     316                $recipients = $this->members->find_committee_members();
     317            } else {
     318                $recipients = $this->members->find_active_members();
     319            }
     320
     321            $sent_count = 0;
     322
     323            foreach ( $recipients as $member ) {
     324                if ( ! empty( $member->email ) ) {
     325                    $sent = $this->email_service->send_results_share_link(
     326                        $member->email,
     327                        $member->name,
     328                        $competition->title,
     329                        $share_url,
     330                        $competition_id
     331                    );
     332                    if ( $sent ) {
     333                        ++$sent_count;
     334                    }
     335                }
     336            }
     337
     338            $audience_label = 'send_results_committee' === $action
     339                ? __( 'committee members', 'photo-competition-manager' )
     340                : __( 'active members', 'photo-competition-manager' );
     341
     342            add_settings_error(
     343                'photo_competition_results',
     344                'results_link_sent',
     345                sprintf(
     346                    /* translators: 1: number of emails sent, 2: audience label */
     347                    __( 'Results link sent to %1$d %2$s.', 'photo-competition-manager' ),
     348                    $sent_count,
     349                    $audience_label
     350                ),
     351                'updated'
     352            );
     353
     354            $this->redirect_with_settings_errors( $redirect_url );
     355            return;
     356        }
     357
    262358        if ( 'export_results_csv' === $action ) {
    263359            $competition_id = isset( $_GET['competition'] ) ? absint( wp_unslash( $_GET['competition'] ) ) : 0;
     
    477573        echo esc_html__( 'Email Results', 'photo-competition-manager' );
    478574        echo '</a>';
     575
     576        echo '</div>';
     577
     578        // Share results section.
     579        $share_hash   = $competition->share_hash ?? '';
     580        $results_page = $settings['urls']['results_page'] ?? '';
     581
     582        echo '<div class="photo-comp-share-results" style="margin: 20px 0; padding: 15px; background: #f9f9f9; border-left: 4px solid #2271b1;">';
     583        echo '<h3 style="margin-top: 0;">' . esc_html__( 'Share Results', 'photo-competition-manager' ) . '</h3>';
     584
     585        if ( ! empty( $share_hash ) && ! empty( $results_page ) ) {
     586            $share_url = add_query_arg( 'share', $share_hash, $results_page );
     587
     588            $send_committee_url = wp_nonce_url(
     589                add_query_arg(
     590                    array(
     591                        'page'        => 'photo-competition-manager-results',
     592                        'action'      => 'send_results_committee',
     593                        'competition' => (int) $competition->id,
     594                    ),
     595                    admin_url( 'admin.php' )
     596                ),
     597                'photo_competition_send_results_committee_' . (int) $competition->id
     598            );
     599
     600            $send_all_url = wp_nonce_url(
     601                add_query_arg(
     602                    array(
     603                        'page'        => 'photo-competition-manager-results',
     604                        'action'      => 'send_results_all',
     605                        'competition' => (int) $competition->id,
     606                    ),
     607                    admin_url( 'admin.php' )
     608                ),
     609                'photo_competition_send_results_all_' . (int) $competition->id
     610            );
     611
     612            echo '<p class="description">';
     613            echo esc_html__( 'Share link (bypasses visibility setting):', 'photo-competition-manager' );
     614            echo '<br><code>' . esc_html( $share_url ) . '</code>';
     615            echo '</p>';
     616            echo '<p>';
     617            echo '<a href="' . esc_url( $send_committee_url ) . '" class="button" onclick="return confirm(\'' . esc_js( __( 'This will send the results link to all committee members. Continue?', 'photo-competition-manager' ) ) . '\');">';
     618            echo '<span class="dashicons dashicons-groups" style="margin-top: 4px;"></span> ';
     619            echo esc_html__( 'Send to Committee', 'photo-competition-manager' );
     620            echo '</a> ';
     621            echo '<a href="' . esc_url( $send_all_url ) . '" class="button" onclick="return confirm(\'' . esc_js( __( 'This will send the results link to ALL active members. Continue?', 'photo-competition-manager' ) ) . '\');">';
     622            echo '<span class="dashicons dashicons-email" style="margin-top: 4px;"></span> ';
     623            echo esc_html__( 'Send to All Members', 'photo-competition-manager' );
     624            echo '</a>';
     625            echo '</p>';
     626        } elseif ( empty( $share_hash ) ) {
     627            echo '<p class="description">';
     628            printf(
     629                /* translators: %s: link to competitions page */
     630                esc_html__( 'No share hash exists. %s on the Competitions page first.', 'photo-competition-manager' ),
     631                '<a href="' . esc_url( admin_url( 'admin.php?page=photo-competition-manager' ) ) . '">' . esc_html__( 'Generate a results link', 'photo-competition-manager' ) . '</a>'
     632            );
     633            echo '</p>';
     634        } else {
     635            echo '<p class="description">';
     636            echo esc_html__( 'No results page URL configured. Set one in competition settings.', 'photo-competition-manager' );
     637            echo '</p>';
     638        }
    479639
    480640        echo '</div>';
  • photo-competition-manager/tags/0.3.0/admin/class-settings-controller.php

    r3415757 r3465730  
    125125                }
    126126            });
     127
     128            // Progress meter preview animations
     129            document.querySelectorAll(".progress-meter-card").forEach(function(card) {
     130                card.addEventListener("click", function() {
     131                    document.querySelectorAll(".progress-meter-card").forEach(function(c) {
     132                        c.classList.remove("active");
     133                        c.style.borderColor = "#ddd";
     134                    });
     135                    card.classList.add("active");
     136                    card.style.borderColor = "#0073aa";
     137                });
     138            });
     139
     140            function animatePreviews() {
     141                var duration = 3000;
     142                var startTime = Date.now();
     143
     144                function tick() {
     145                    var elapsed = Date.now() - startTime;
     146                    var progress = (elapsed % duration) / duration;
     147
     148                    document.querySelectorAll(".meter-preview").forEach(function(preview) {
     149                        var type = preview.dataset.meterType;
     150                        renderMeterPreview(preview, type, progress);
     151                    });
     152
     153                    requestAnimationFrame(tick);
     154                }
     155
     156                tick();
     157            }
     158
     159            function renderMeterPreview(container, type, progress) {
     160                if (!container._initialized) {
     161                    container._initialized = true;
     162                    container.innerHTML = "";
     163
     164                    if (type === "bar") {
     165                        container.style.display = "flex";
     166                        container.style.alignItems = "flex-end";
     167                        var track = document.createElement("div");
     168                        track.style.cssText = "width:100%;height:8px;background:rgba(255,255,255,0.2);border-radius:0;";
     169                        var fill = document.createElement("div");
     170                        fill.style.cssText = "height:100%;background:#0073aa;transition:width 100ms linear;border-radius:0;";
     171                        fill.className = "meter-fill";
     172                        track.appendChild(fill);
     173                        container.appendChild(track);
     174                    } else if (type === "line") {
     175                        container.style.display = "flex";
     176                        container.style.alignItems = "flex-end";
     177                        var track = document.createElement("div");
     178                        track.style.cssText = "width:100%;height:3px;background:rgba(255,255,255,0.1);";
     179                        var fill = document.createElement("div");
     180                        fill.style.cssText = "height:100%;background:#fff;box-shadow:0 0 8px rgba(255,255,255,0.6);transition:width 100ms linear;";
     181                        fill.className = "meter-fill";
     182                        track.appendChild(fill);
     183                        container.appendChild(track);
     184                    } else if (type === "dots") {
     185                        container.style.display = "flex";
     186                        container.style.alignItems = "flex-end";
     187                        container.style.justifyContent = "center";
     188                        container.style.gap = "4px";
     189                        container.style.paddingBottom = "4px";
     190                        for (var i = 0; i < 15; i++) {
     191                            var dot = document.createElement("div");
     192                            dot.style.cssText = "width:6px;height:6px;border-radius:50%;background:rgba(255,255,255,0.2);transition:background 0.2s,transform 0.2s;";
     193                            dot.className = "meter-dot";
     194                            container.appendChild(dot);
     195                        }
     196                    } else if (type === "radial") {
     197                        container.style.display = "flex";
     198                        container.style.alignItems = "center";
     199                        container.style.justifyContent = "center";
     200                        var svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
     201                        svg.setAttribute("width", "40");
     202                        svg.setAttribute("height", "40");
     203                        svg.setAttribute("viewBox", "0 0 40 40");
     204                        var bgCircle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
     205                        bgCircle.setAttribute("cx", "20");
     206                        bgCircle.setAttribute("cy", "20");
     207                        bgCircle.setAttribute("r", "16");
     208                        bgCircle.setAttribute("fill", "none");
     209                        bgCircle.setAttribute("stroke", "rgba(255,255,255,0.2)");
     210                        bgCircle.setAttribute("stroke-width", "3");
     211                        svg.appendChild(bgCircle);
     212                        var circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
     213                        circle.setAttribute("cx", "20");
     214                        circle.setAttribute("cy", "20");
     215                        circle.setAttribute("r", "16");
     216                        circle.setAttribute("fill", "none");
     217                        circle.setAttribute("stroke", "#0073aa");
     218                        circle.setAttribute("stroke-width", "3");
     219                        circle.setAttribute("stroke-linecap", "round");
     220                        circle.setAttribute("transform", "rotate(-90 20 20)");
     221                        var circumference = 2 * Math.PI * 16;
     222                        circle.setAttribute("stroke-dasharray", circumference);
     223                        circle.setAttribute("stroke-dashoffset", circumference);
     224                        circle.className.baseVal = "meter-ring";
     225                        svg.appendChild(circle);
     226                        container.appendChild(svg);
     227                    }
     228                }
     229
     230                if (type === "bar" || type === "line") {
     231                    var fill = container.querySelector(".meter-fill");
     232                    if (fill) fill.style.width = (progress * 100) + "%";
     233                } else if (type === "dots") {
     234                    var dots = container.querySelectorAll(".meter-dot");
     235                    var filledCount = Math.floor(progress * dots.length);
     236                    dots.forEach(function(dot, i) {
     237                        if (i < filledCount) {
     238                            dot.style.background = "#0073aa";
     239                            dot.style.transform = "scale(1.3)";
     240                        } else if (i === filledCount) {
     241                            dot.style.background = "rgba(0,115,170,0.5)";
     242                            dot.style.transform = "scale(1.1)";
     243                        } else {
     244                            dot.style.background = "rgba(255,255,255,0.2)";
     245                            dot.style.transform = "scale(1)";
     246                        }
     247                    });
     248                } else if (type === "radial") {
     249                    var ring = container.querySelector(".meter-ring");
     250                    if (ring) {
     251                        var circumference = 2 * Math.PI * 16;
     252                        ring.setAttribute("stroke-dashoffset", circumference * (1 - progress));
     253                    }
     254                }
     255            }
     256
     257            animatePreviews();
    127258        })();
    128259        });
     
    209340        $voting_password     = sanitize_text_field( $this->get_post_string( 'voting_password' ) );
    210341        $click_image_to_zoom = isset( $_POST['click_image_to_zoom'] ) && '1' === $_POST['click_image_to_zoom'];
     342
     343        $progress_meter_type_input = sanitize_text_field( $this->get_post_string( 'progress_meter_type', 'bar' ) );
     344        if ( ! in_array( $progress_meter_type_input, array( 'bar', 'line', 'dots', 'radial' ), true ) ) {
     345            $progress_meter_type_input = 'bar';
     346        }
    211347
    212348        $upload_page_url  = sanitize_url( $this->get_post_string( 'upload_page_url', '' ) );
     
    236372            ),
    237373            'slideshow'       => array(
    238                 'duration_seconds' => 10,
     374                'duration_seconds'    => 10,
     375                'progress_meter_type' => $progress_meter_type_input,
    239376            ),
    240377            'email_reminders' => array(
     
    307444        settings_errors( 'photo_competition_settings' );
    308445
    309         $settings       = $this->get_global_settings();
    310         $categories     = Competition_Settings::get_categories( $settings );
    311         $grades         = Competition_Settings::get_grades( $settings );
    312         $upload         = Competition_Settings::get_upload_constraints( $settings );
    313         $voting         = Competition_Settings::get_voting_config( $settings );
    314         $voting_ui_type = get_option( 'photo_comp_voting_ui_type', 'buttons' );
    315         $urls           = $settings['urls'] ?? array(
     446        $settings            = $this->get_global_settings();
     447        $categories          = Competition_Settings::get_categories( $settings );
     448        $grades              = Competition_Settings::get_grades( $settings );
     449        $upload              = Competition_Settings::get_upload_constraints( $settings );
     450        $voting              = Competition_Settings::get_voting_config( $settings );
     451        $slideshow           = $settings['slideshow'] ?? array();
     452        $progress_meter_type = $slideshow['progress_meter_type'] ?? 'bar';
     453        $voting_ui_type      = get_option( 'photo_comp_voting_ui_type', 'buttons' );
     454        $urls                = $settings['urls'] ?? array(
    316455            'upload_page' => '',
    317456            'voting_page' => '',
     
    412551        echo '<span class="description">' . esc_html__( 'E.g., 9, 8, 7, 6, 5', 'photo-competition-manager' ) . '</span>';
    413552        echo '</p>';
     553
     554        echo '<h2>' . esc_html__( 'Slideshow', 'photo-competition-manager' ) . '</h2>';
     555
     556        echo '<p>';
     557        echo '<label>' . esc_html__( 'Progress Meter Style', 'photo-competition-manager' ) . '</label>';
     558        echo '</p>';
     559
     560        $meter_types = array(
     561            'bar'    => __( 'Bar', 'photo-competition-manager' ),
     562            'line'   => __( 'Thin Line', 'photo-competition-manager' ),
     563            'dots'   => __( 'Dots', 'photo-competition-manager' ),
     564            'radial' => __( 'Radial', 'photo-competition-manager' ),
     565        );
     566
     567        echo '<div class="progress-meter-selector" style="display: flex; gap: 16px; flex-wrap: wrap; margin-bottom: 20px;">';
     568
     569        foreach ( $meter_types as $type => $label ) {
     570            $is_active = ( $type === $progress_meter_type ) ? ' active' : '';
     571            echo '<label class="progress-meter-card' . esc_attr( $is_active ) . '" style="cursor: pointer; border: 2px solid ' . ( $is_active ? '#0073aa' : '#ddd' ) . '; border-radius: 8px; padding: 12px; text-align: center; background: #1a1a1a; min-width: 140px; transition: border-color 0.2s;">';
     572            echo '<input type="radio" name="progress_meter_type" value="' . esc_attr( $type ) . '"' . checked( $progress_meter_type, $type, false ) . ' style="display: none;" />';
     573            echo '<div class="meter-preview" data-meter-type="' . esc_attr( $type ) . '" style="height: 50px; position: relative; margin-bottom: 8px; overflow: hidden; border-radius: 4px;"></div>';
     574            echo '<span style="color: #666; font-size: 13px; font-weight: 600;">' . esc_html( $label ) . '</span>';
     575            echo '</label>';
     576        }
     577
     578        echo '</div>';
     579        echo '<span class="description">' . esc_html__( 'Choose the progress indicator style shown during the slideshow.', 'photo-competition-manager' ) . '</span>';
    414580
    415581        echo '<h2>' . esc_html__( 'Email Configuration', 'photo-competition-manager' ) . '</h2>';
  • photo-competition-manager/tags/0.3.0/admin/class-voting-controller.php

    r3446169 r3465730  
    655655
    656656        // Render Quick Actions.
    657         $this->render_quick_actions( $voting_page_url, $global_settings );
     657        $this->render_quick_actions( $voting_page_url, $global_settings, $active_settings );
    658658
    659659        // Hidden duration setting for slideshow.
    660660        echo '<input type="hidden" id="slideshow-duration-setting" value="20" />';
     661
     662        // Hidden meter type setting for slideshow.
     663        $meter_type = $active_settings['slideshow']['progress_meter_type'] ?? 'bar';
     664        echo '<input type="hidden" id="slideshow-meter-type" value="' . esc_attr( $meter_type ) . '" />';
    661665
    662666        // Slideshow container (hidden by default).
     
    11631167     * Render collapsible quick actions bar.
    11641168     *
    1165      * @param string $voting_page_url The voting page URL for QR code.
    1166      * @param array  $settings        Global settings.
     1169     * @param string $voting_page_url    The voting page URL for QR code.
     1170     * @param array  $settings           Global settings.
     1171     * @param array  $competition_settings Active competition settings.
    11671172     * @return void
    11681173     */
    1169     private function render_quick_actions( string $voting_page_url, array $settings ): void {
     1174    private function render_quick_actions( string $voting_page_url, array $settings, array $competition_settings = array() ): void {
    11701175        $results_url = $settings['urls']['results_page'] ?? '';
    11711176        $top3_url    = $settings['urls']['top3_page'] ?? '';
     1177
     1178        // Get voting password if it's stored as plaintext (not a legacy hash).
     1179        $voting_password = '';
     1180        $raw_password    = $competition_settings['voting']['password'] ?? '';
     1181        if ( '' !== $raw_password && ! preg_match( '/^\$P\$|\$wp\$/', $raw_password ) ) {
     1182            $voting_password = $raw_password;
     1183        }
    11721184        ?>
    11731185        <div class="quick-actions-bar" id="quick-actions">
     
    12001212                <?php if ( ! empty( $voting_page_url ) ) : ?>
    12011213                <div class="qr-code-panel" id="qr-code-panel" style="display: none;">
     1214                    <?php if ( '' !== $voting_password ) : ?>
     1215                    <div class="qr-code-password">
     1216                        <span class="qr-code-password-label"><?php esc_html_e( 'Voting Password:', 'photo-competition-manager' ); ?></span>
     1217                        <span class="qr-code-password-value"><?php echo esc_html( $voting_password ); ?></span>
     1218                    </div>
     1219                    <?php endif; ?>
    12021220                    <div class="qr-code-container" data-voting-url="<?php echo esc_attr( $voting_page_url ); ?>">
    12031221                        <div class="qr-code-canvas"></div>
     
    12521270            <div class="complete-body">
    12531271                <p class="complete-competition"><?php echo esc_html( $competition->title ); ?></p>
     1272                <div class="complete-slideshow-section">
     1273                    <div class="duration-presets">
     1274                        <button type="button" class="button duration-preset" data-duration="5"><?php esc_html_e( '5s', 'photo-competition-manager' ); ?></button>
     1275                        <button type="button" class="button duration-preset" data-duration="10"><?php esc_html_e( '10s', 'photo-competition-manager' ); ?></button>
     1276                        <button type="button" class="button duration-preset" data-duration="15"><?php esc_html_e( '15s', 'photo-competition-manager' ); ?></button>
     1277                        <button type="button" class="button duration-preset active" data-duration="20"><?php esc_html_e( '20s', 'photo-competition-manager' ); ?></button>
     1278                        <button type="button" class="button duration-preset" data-duration="25"><?php esc_html_e( '25s', 'photo-competition-manager' ); ?></button>
     1279                        <button type="button" class="button duration-preset" data-duration="30"><?php esc_html_e( '30s', 'photo-competition-manager' ); ?></button>
     1280                        <button type="button" class="button duration-preset" data-duration="0" title="<?php esc_attr_e( 'Manual: advance with Space or arrow keys', 'photo-competition-manager' ); ?>"><?php esc_html_e( 'Manual', 'photo-competition-manager' ); ?></button>
     1281                    </div>
     1282                </div>
     1283
    12541284                <ul class="complete-categories">
    12551285                    <?php foreach ( $all_categories as $cat_data ) : ?>
    12561286                        <li class="complete-category-item">
    12571287                            <span class="dashicons dashicons-yes"></span>
    1258                             <?php echo esc_html( $cat_data['category']['label'] ?? '' ); ?>
     1288                            <span class="category-name"><?php echo esc_html( $cat_data['category']['label'] ?? '' ); ?></span>
    12591289                            <span class="category-count">(<?php echo (int) $cat_data['image_count']; ?> <?php esc_html_e( 'images', 'photo-competition-manager' ); ?>)</span>
     1290                            <span class="category-slideshow-actions">
     1291                                <button type="button" class="button button-small photo-competition-manager-start-slideshow"
     1292                                    data-competition-id="<?php echo esc_attr( $competition->id ); ?>"
     1293                                    data-competition-slug="<?php echo esc_attr( $competition->slug ); ?>"
     1294                                    data-category="<?php echo esc_attr( $cat_data['category']['slug'] ?? '' ); ?>"
     1295                                    data-category-label="<?php echo esc_attr( $cat_data['category']['label'] ?? '' ); ?>">
     1296                                    <span class="dashicons dashicons-slides"></span> <?php esc_html_e( 'Slideshow', 'photo-competition-manager' ); ?>
     1297                                </button>
     1298                                <button type="button" class="button button-small photo-competition-manager-start-critique"
     1299                                    data-competition-id="<?php echo esc_attr( $competition->id ); ?>"
     1300                                    data-competition-slug="<?php echo esc_attr( $competition->slug ); ?>"
     1301                                    data-category="<?php echo esc_attr( $cat_data['category']['slug'] ?? '' ); ?>"
     1302                                    data-category-label="<?php echo esc_attr( $cat_data['category']['label'] ?? '' ); ?>"
     1303                                    title="<?php esc_attr_e( 'Manual slideshow for discussion', 'photo-competition-manager' ); ?>">
     1304                                    <span class="dashicons dashicons-format-chat"></span> <?php esc_html_e( 'Critique', 'photo-competition-manager' ); ?>
     1305                                </button>
     1306                            </span>
    12601307                        </li>
    12611308                    <?php endforeach; ?>
  • photo-competition-manager/tags/0.3.0/assets/css/admin-slideshow.css

    r3415757 r3465730  
    5151.slideshow-image-info {
    5252    position: absolute;
    53     bottom: 60px;
    54     left: 50%;
    55     transform: translateX(-50%);
     53    bottom: 20px;
     54    left: 20px;
    5655    background: rgba(0, 0, 0, 0.8);
    5756    color: #fff;
    58     padding: 1rem 2rem;
     57    padding: 0.75rem 1.5rem;
    5958    border-radius: 8px;
    60     font-size: 2rem;
     59    font-size: 1.5rem;
    6160    font-weight: 700;
    6261    text-align: center;
     
    6463}
    6564
     65/* Progress Meter - shared container */
    6666.slideshow-progress {
    6767    position: absolute;
     
    7777    background: #0073aa;
    7878    transition: width 100ms linear;
     79}
     80
     81/* Progress Meter - Thin Line */
     82.slideshow-progress.meter-line {
     83    height: 3px;
     84    background: rgba(255, 255, 255, 0.1);
     85}
     86
     87.slideshow-progress.meter-line .progress-bar {
     88    background: #fff;
     89    box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
     90}
     91
     92/* Progress Meter - Dots */
     93.slideshow-progress.meter-dots {
     94    height: 20px;
     95    background: transparent;
     96    display: flex;
     97    align-items: center;
     98    justify-content: center;
     99    gap: 6px;
     100}
     101
     102.slideshow-progress.meter-dots .progress-bar {
     103    display: none;
     104}
     105
     106.slideshow-progress.meter-dots .meter-dot {
     107    width: 8px;
     108    height: 8px;
     109    border-radius: 50%;
     110    background: rgba(255, 255, 255, 0.2);
     111    transition: background 0.2s ease, transform 0.2s ease;
     112    flex-shrink: 0;
     113}
     114
     115.slideshow-progress.meter-dots .meter-dot.filled {
     116    background: #0073aa;
     117    transform: scale(1.4);
     118}
     119
     120.slideshow-progress.meter-dots .meter-dot.filling {
     121    background: rgba(0, 115, 170, 0.5);
     122    transform: scale(1.2);
     123}
     124
     125/* Progress Meter - Radial */
     126.slideshow-progress.meter-radial {
     127    width: 100%;
     128    height: 44px;
     129    background: transparent;
     130    display: flex;
     131    align-items: center;
     132    justify-content: center;
     133}
     134
     135.slideshow-progress.meter-radial .progress-bar {
     136    display: none;
     137}
     138
     139.slideshow-progress.meter-radial svg {
     140    width: 44px;
     141    height: 44px;
     142    filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
     143}
     144
     145.slideshow-progress.meter-radial .meter-ring {
     146    transition: stroke-dashoffset 100ms linear;
    79147}
    80148
     
    690758}
    691759
     760.qr-code-password {
     761    text-align: center;
     762    padding: 16px 20px;
     763    margin-bottom: 16px;
     764    background: #f0f6fc;
     765    border: 1px solid #c3c4c7;
     766    border-radius: 8px;
     767}
     768
     769.qr-code-password-label {
     770    display: block;
     771    font-size: 14px;
     772    font-weight: 600;
     773    color: #646970;
     774    text-transform: uppercase;
     775    letter-spacing: 0.5px;
     776    margin-bottom: 8px;
     777}
     778
     779.qr-code-password-value {
     780    display: block;
     781    font-size: 48px;
     782    font-weight: 700;
     783    color: #1d2327;
     784    letter-spacing: 2px;
     785}
     786
    692787.qr-code-details h4 {
    693788    margin: 0 0 8px 0;
     
    747842
    748843.complete-body {
    749     max-width: 500px;
     844    max-width: 680px;
    750845    margin: 0 auto;
    751846}
     
    782877    color: #646970;
    783878    font-size: 12px;
     879}
     880
     881.complete-category-item .category-slideshow-actions {
    784882    margin-left: auto;
     883    display: flex;
     884    gap: 6px;
     885    flex-shrink: 0;
     886}
     887
     888.complete-category-item .category-slideshow-actions .button .dashicons {
     889    font-size: 14px;
     890    width: 14px;
     891    height: 14px;
     892    line-height: 14px;
     893    position: relative;
     894    top: 2px;
     895    margin-right: 2px;
     896}
     897
     898.complete-slideshow-section {
     899    margin-bottom: 16px;
     900    display: flex;
     901    justify-content: center;
    785902}
    786903
     
    839956@media screen and (max-width: 768px) {
    840957    .slideshow-image-info {
    841         font-size: 1.5rem;
    842         padding: 0.75rem 1.5rem;
    843         bottom: 40px;
     958        font-size: 1.2rem;
     959        padding: 0.5rem 1rem;
     960        bottom: 10px;
     961        left: 10px;
    844962    }
    845963
  • photo-competition-manager/tags/0.3.0/assets/css/slideshow.css

    r3415757 r3465730  
    131131.slideshow-image-info {
    132132    position: absolute;
    133     bottom: 60px;
    134     left: 50%;
    135     transform: translateX(-50%);
     133    bottom: 20px;
     134    left: 20px;
    136135    background: rgba(0, 0, 0, 0.8);
    137136    color: #fff;
    138     padding: 1rem 2rem;
     137    padding: 0.75rem 1.5rem;
    139138    border-radius: 8px;
    140     font-size: 2rem;
     139    font-size: 1.5rem;
    141140    font-weight: 700;
    142141    text-align: center;
     
    148147}
    149148
     149/* Progress Meter - shared container */
    150150.slideshow-progress {
    151151    position: absolute;
     
    161161    background: #0073aa;
    162162    transition: width 100ms linear;
     163}
     164
     165/* Progress Meter - Thin Line */
     166.slideshow-progress.meter-line {
     167    height: 3px;
     168    background: rgba(255, 255, 255, 0.1);
     169}
     170
     171.slideshow-progress.meter-line .progress-bar {
     172    background: #fff;
     173    box-shadow: 0 0 8px rgba(255, 255, 255, 0.6);
     174}
     175
     176/* Progress Meter - Dots */
     177.slideshow-progress.meter-dots {
     178    height: 20px;
     179    background: transparent;
     180    display: flex;
     181    align-items: center;
     182    justify-content: center;
     183    gap: 6px;
     184}
     185
     186.slideshow-progress.meter-dots .progress-bar {
     187    display: none;
     188}
     189
     190.slideshow-progress.meter-dots .meter-dot {
     191    width: 8px;
     192    height: 8px;
     193    border-radius: 50%;
     194    background: rgba(255, 255, 255, 0.2);
     195    transition: background 0.2s ease, transform 0.2s ease;
     196    flex-shrink: 0;
     197}
     198
     199.slideshow-progress.meter-dots .meter-dot.filled {
     200    background: #0073aa;
     201    transform: scale(1.4);
     202}
     203
     204.slideshow-progress.meter-dots .meter-dot.filling {
     205    background: rgba(0, 115, 170, 0.5);
     206    transform: scale(1.2);
     207}
     208
     209/* Progress Meter - Radial */
     210.slideshow-progress.meter-radial {
     211    width: 100%;
     212    height: 44px;
     213    background: transparent;
     214    display: flex;
     215    align-items: center;
     216    justify-content: center;
     217}
     218
     219.slideshow-progress.meter-radial .progress-bar {
     220    display: none;
     221}
     222
     223.slideshow-progress.meter-radial svg {
     224    width: 44px;
     225    height: 44px;
     226    filter: drop-shadow(0 0 4px rgba(0, 0, 0, 0.5));
     227}
     228
     229.slideshow-progress.meter-radial .meter-ring {
     230    transition: stroke-dashoffset 100ms linear;
    163231}
    164232
     
    213281
    214282    .slideshow-image-info {
    215         font-size: 1.5rem;
    216         padding: 0.75rem 1.5rem;
    217         bottom: 40px;
     283        font-size: 1.2rem;
     284        padding: 0.5rem 1rem;
     285        bottom: 10px;
     286        left: 10px;
    218287    }
    219288
  • photo-competition-manager/tags/0.3.0/assets/js/admin-slideshow.js

    r3415757 r3465730  
    3434            // Image pre-caching
    3535            this.imageCache = new Map();
     36            this.meterType = $('#slideshow-meter-type').val() || 'bar';
    3637
    3738            this.bindEvents();
     
    4748            }
    4849            return seconds * 1000;
     50        }
     51
     52        createMeterRenderer(type) {
     53            const $progress = this.$display.find('.slideshow-progress');
     54            const $progressBar = this.$progressBar;
     55
     56            if (type === 'line') {
     57                $progress.addClass('meter-line');
     58                return {
     59                    update(progress) { $progressBar.css('width', progress + '%'); },
     60                    reset() { $progressBar.css('width', '0%'); },
     61                    destroy() { $progress.removeClass('meter-line'); }
     62                };
     63            }
     64
     65            if (type === 'dots') {
     66                $progress.addClass('meter-dots');
     67                for (let i = 0; i < 20; i++) {
     68                    $progress.append('<div class="meter-dot"></div>');
     69                }
     70                const $dots = $progress.find('.meter-dot');
     71                return {
     72                    update(progress) {
     73                        const filledCount = Math.floor(progress / 100 * $dots.length);
     74                        $dots.each(function(i) {
     75                            const $dot = $(this);
     76                            $dot.removeClass('filled filling');
     77                            if (i < filledCount) {
     78                                $dot.addClass('filled');
     79                            } else if (i === filledCount) {
     80                                $dot.addClass('filling');
     81                            }
     82                        });
     83                    },
     84                    reset() { $dots.removeClass('filled filling'); },
     85                    destroy() { $dots.remove(); $progress.removeClass('meter-dots'); }
     86                };
     87            }
     88
     89            if (type === 'radial') {
     90                $progress.addClass('meter-radial');
     91                const circumference = 2 * Math.PI * 16;
     92                const svg = '<svg width="44" height="44" viewBox="0 0 44 44">' +
     93                    '<circle cx="22" cy="22" r="16" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
     94                    '<circle class="meter-ring" cx="22" cy="22" r="16" fill="none" stroke="#0073aa" stroke-width="3" stroke-linecap="round" transform="rotate(-90 22 22)" stroke-dasharray="' + circumference + '" stroke-dashoffset="' + circumference + '"/>' +
     95                    '</svg>';
     96                $progress.append(svg);
     97                const $ring = $progress.find('.meter-ring');
     98                return {
     99                    update(progress) { $ring.attr('stroke-dashoffset', circumference * (1 - progress / 100)); },
     100                    reset() { $ring.attr('stroke-dashoffset', circumference); },
     101                    destroy() { $progress.find('svg').remove(); $progress.removeClass('meter-radial'); }
     102                };
     103            }
     104
     105            // Default: bar
     106            return {
     107                update(progress) { $progressBar.css('width', progress + '%'); },
     108                reset() { $progressBar.css('width', '0%'); },
     109                destroy() {}
     110            };
    49111        }
    50112
     
    272334
    273335                // Show first image (pass true to start timer)
     336                this.meterRenderer = this.createMeterRenderer(this.meterType);
    274337                this.showImage(0, true);
    275338
     
    285348                this.currentIndex = 0;
    286349                this.$display.css('display', 'flex');
     350                this.meterRenderer = this.createMeterRenderer(this.meterType);
    287351                this.showImage(0, true);
    288352                setTimeout(function() {
     
    333397            // Hide display
    334398            this.$display.fadeOut(300);
    335             this.$progressBar.css('width', '0%');
     399            if (this.meterRenderer) {
     400                this.meterRenderer.reset();
     401                this.meterRenderer.destroy();
     402            }
    336403            this.$pauseBtn.show();
    337404            this.$resumeBtn.hide();
     
    359426
    360427                // Reset progress bar
    361                 this.$progressBar.css('width', '0%');
     428                if (this.meterRenderer) this.meterRenderer.reset();
    362429                this.startTime = Date.now();
    363430
     
    376443                this.$image.attr('alt', 'Image #' + image.random_number);
    377444                this.$imageInfo.find('.image-number').text('#' + image.random_number);
    378                 this.$progressBar.css('width', '0%');
     445                if (this.meterRenderer) this.meterRenderer.reset();
    379446                this.startTime = Date.now();
    380447
     
    419486            if (duration === 0) {
    420487                // Hide progress bar in manual mode
    421                 this.$progressBar.css('width', '0%');
     488                if (this.meterRenderer) this.meterRenderer.reset();
    422489                return;
    423490            }
     
    433500                const elapsed = Date.now() - self.startTime;
    434501                const progress = Math.min((elapsed / duration) * 100, 100);
    435                 self.$progressBar.css('width', progress + '%');
     502                if (self.meterRenderer) self.meterRenderer.update(progress);
    436503            }, 100);
    437504        }
  • photo-competition-manager/tags/0.3.0/assets/js/slideshow.js

    r3415757 r3465730  
    2424            this.nonce = this.$container.data('nonce');
    2525            this.ajaxUrl = this.$container.data('ajax-url');
     26            this.meterType = this.$container.data('meter-type') || 'bar';
    2627            this.images = this.$container.data('images');
    2728
     
    8586        }
    8687
     88        createMeterRenderer(type) {
     89            const $progress = this.$container.find('.slideshow-progress');
     90            const $progressBar = this.$progressBar;
     91
     92            if (type === 'line') {
     93                $progress.addClass('meter-line');
     94                return {
     95                    update(progress) { $progressBar.css('width', progress + '%'); },
     96                    reset() { $progressBar.css('width', '0%'); },
     97                    destroy() { $progress.removeClass('meter-line'); }
     98                };
     99            }
     100
     101            if (type === 'dots') {
     102                $progress.addClass('meter-dots');
     103                for (let i = 0; i < 20; i++) {
     104                    $progress.append('<div class="meter-dot"></div>');
     105                }
     106                const $dots = $progress.find('.meter-dot');
     107                return {
     108                    update(progress) {
     109                        const filledCount = Math.floor(progress / 100 * $dots.length);
     110                        $dots.each(function(i) {
     111                            const $dot = $(this);
     112                            $dot.removeClass('filled filling');
     113                            if (i < filledCount) {
     114                                $dot.addClass('filled');
     115                            } else if (i === filledCount) {
     116                                $dot.addClass('filling');
     117                            }
     118                        });
     119                    },
     120                    reset() {
     121                        $dots.removeClass('filled filling');
     122                    },
     123                    destroy() {
     124                        $dots.remove();
     125                        $progress.removeClass('meter-dots');
     126                    }
     127                };
     128            }
     129
     130            if (type === 'radial') {
     131                $progress.addClass('meter-radial');
     132                const circumference = 2 * Math.PI * 16;
     133                const svg = '<svg width="44" height="44" viewBox="0 0 44 44">' +
     134                    '<circle cx="22" cy="22" r="16" fill="none" stroke="rgba(255,255,255,0.2)" stroke-width="3"/>' +
     135                    '<circle class="meter-ring" cx="22" cy="22" r="16" fill="none" stroke="#0073aa" stroke-width="3" stroke-linecap="round" transform="rotate(-90 22 22)" stroke-dasharray="' + circumference + '" stroke-dashoffset="' + circumference + '"/>' +
     136                    '</svg>';
     137                $progress.append(svg);
     138                const $ring = $progress.find('.meter-ring');
     139                return {
     140                    update(progress) {
     141                        $ring.attr('stroke-dashoffset', circumference * (1 - progress / 100));
     142                    },
     143                    reset() {
     144                        $ring.attr('stroke-dashoffset', circumference);
     145                    },
     146                    destroy() {
     147                        $progress.find('svg').remove();
     148                        $progress.removeClass('meter-radial');
     149                    }
     150                };
     151            }
     152
     153            // Default: 'bar'
     154            return {
     155                update(progress) { $progressBar.css('width', progress + '%'); },
     156                reset() { $progressBar.css('width', '0%'); },
     157                destroy() {}
     158            };
     159        }
     160
    87161        start() {
    88162            if (this.images.length === 0) {
     
    105179
    106180                // Show first image
     181                this.meterRenderer = this.createMeterRenderer(this.meterType);
    107182                this.showImage(0);
    108183                this.startAutoAdvance();
     
    120195                this.$display.fadeIn(300);
    121196                this.$statusMessage.text('Slideshow running...');
     197                this.meterRenderer = this.createMeterRenderer(this.meterType);
    122198                this.showImage(0);
    123199                this.startAutoAdvance();
     
    165241                this.$display.fadeOut(300);
    166242                this.$statusMessage.text(response.message || 'Slideshow stopped');
    167                 this.$progressBar.css('width', '0%');
     243                if (this.meterRenderer) {
     244                    this.meterRenderer.reset();
     245                    this.meterRenderer.destroy();
     246                }
    168247
    169248                // Exit fullscreen
     
    223302
    224303            // Reset progress bar
    225             this.$progressBar.css('width', '0%');
     304            if (this.meterRenderer) this.meterRenderer.reset();
    226305            this.startTime = Date.now();
    227306        }
     
    258337                this.$display.fadeOut(300);
    259338                this.$statusMessage.text(response.message || 'Slideshow ended');
    260                 this.$progressBar.css('width', '0%');
     339                if (this.meterRenderer) {
     340                    this.meterRenderer.reset();
     341                    this.meterRenderer.destroy();
     342                }
    261343
    262344                // Exit fullscreen
     
    298380            if (intervalMs === 0) {
    299381                // Hide progress bar in manual mode
    300                 this.$progressBar.css('width', '0%');
     382                if (this.meterRenderer) this.meterRenderer.reset();
    301383                return;
    302384            }
     
    312394                const elapsed = Date.now() - this.startTime;
    313395                const progress = Math.min((elapsed / intervalMs) * 100, 100);
    314                 this.$progressBar.css('width', progress + '%');
     396                if (this.meterRenderer) this.meterRenderer.update(progress);
    315397            }, 100);
    316398        }
  • photo-competition-manager/tags/0.3.0/includes/Install/class-activator.php

    r3415757 r3465730  
    9393            grade VARCHAR(100) NOT NULL,
    9494            active TINYINT(1) NOT NULL DEFAULT 1,
     95            committee TINYINT(1) NOT NULL DEFAULT 0,
    9596            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    9697            updated_at DATETIME NULL,
     
    106107            close_date DATETIME NULL,
    107108            settings LONGTEXT NULL,
     109            share_hash VARCHAR(64) NOT NULL DEFAULT '',
    108110            created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    109111            updated_at DATETIME NULL,
    110112            deleted_at DATETIME NULL,
    111113            PRIMARY KEY  (id),
    112             UNIQUE KEY slug (slug)
     114            UNIQUE KEY slug (slug),
     115            KEY share_hash (share_hash)
    113116        ) {$charset_collate};";
    114117
  • photo-competition-manager/tags/0.3.0/includes/Repository/class-competitions-repository.php

    r3446169 r3465730  
    185185            'close_date' => $close_date,
    186186            'settings'   => isset( $data['settings'] ) ? wp_json_encode( $data['settings'] ) : null,
     187            'share_hash' => isset( $data['share_hash'] ) ? sanitize_text_field( (string) $data['share_hash'] ) : '',
    187188            'created_at' => $now,
    188189            'updated_at' => $now,
     
    190191
    191192        $format = array(
     193            '%s',
    192194            '%s',
    193195            '%s',
     
    399401        if ( false === $deleted ) {
    400402            return new WP_Error( 'db_delete_failed', __( 'Could not delete competition.', 'photo-competition-manager' ), $wpdb->last_error );
     403        }
     404
     405        return true;
     406    }
     407
     408    /**
     409     * Find a competition by its share hash.
     410     *
     411     * @param string $share_hash Share hash to look up.
     412     * @return object|null
     413     */
     414    public function find_by_share_hash( string $share_hash ) {
     415        global $wpdb;
     416
     417        if ( empty( $share_hash ) ) {
     418            return null;
     419        }
     420
     421        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     422        return $wpdb->get_row(
     423            $wpdb->prepare(
     424                'SELECT * FROM %i WHERE share_hash = %s AND deleted_at IS NULL LIMIT 1',
     425                $this->table(),
     426                $share_hash
     427            )
     428        );
     429    }
     430
     431    /**
     432     * Update the share hash for a competition.
     433     *
     434     * @param int    $id         Competition ID.
     435     * @param string $share_hash New share hash.
     436     * @return bool|WP_Error
     437     */
     438    public function update_share_hash( int $id, string $share_hash ) {
     439        global $wpdb;
     440
     441        if ( $id <= 0 ) {
     442            return new WP_Error( 'invalid_competition', __( 'Competition not found.', 'photo-competition-manager' ) );
     443        }
     444
     445        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     446        $updated = $wpdb->update(
     447            $this->table(),
     448            array(
     449                'share_hash' => $share_hash,
     450                'updated_at' => utc_time(),
     451            ),
     452            array( 'id' => $id ),
     453            array( '%s', '%s' ),
     454            array( '%d' )
     455        );
     456
     457        if ( false === $updated ) {
     458            return new WP_Error( 'db_update_failed', __( 'Could not update share hash.', 'photo-competition-manager' ), $wpdb->last_error );
    401459        }
    402460
  • photo-competition-manager/tags/0.3.0/includes/Repository/class-members-repository.php

    r3446169 r3465730  
    148148        }
    149149
    150         $grade  = isset( $data['grade'] ) ? sanitize_text_field( (string) $data['grade'] ) : '';
    151         $active = isset( $data['active'] ) ? (int) (bool) $data['active'] : 1;
    152         $now    = utc_time();
     150        $grade     = isset( $data['grade'] ) ? sanitize_text_field( (string) $data['grade'] ) : '';
     151        $active    = isset( $data['active'] ) ? (int) (bool) $data['active'] : 1;
     152        $committee = isset( $data['committee'] ) ? (int) (bool) $data['committee'] : 0;
     153        $now       = utc_time();
    153154
    154155        $payload = array(
     
    157158            'grade'      => $grade,
    158159            'active'     => $active,
     160            'committee'  => $committee,
    159161            'created_at' => $now,
    160162            'updated_at' => $now,
    161163        );
    162164
    163         $format = array( '%s', '%s', '%s', '%d', '%s', '%s' );
     165        $format = array( '%s', '%s', '%s', '%d', '%d', '%s', '%s' );
    164166
    165167        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     
    209211        }
    210212
    211         $grade  = array_key_exists( 'grade', $data ) ? sanitize_text_field( (string) $data['grade'] ) : $current->grade;
    212         $active = array_key_exists( 'active', $data ) ? (int) (bool) $data['active'] : (int) $current->active;
     213        $grade     = array_key_exists( 'grade', $data ) ? sanitize_text_field( (string) $data['grade'] ) : $current->grade;
     214        $active    = array_key_exists( 'active', $data ) ? (int) (bool) $data['active'] : (int) $current->active;
     215        $committee = array_key_exists( 'committee', $data ) ? (int) (bool) $data['committee'] : (int) ( $current->committee ?? 0 );
    213216
    214217        $payload = array(
     
    217220            'grade'      => $grade,
    218221            'active'     => $active,
     222            'committee'  => $committee,
    219223            'updated_at' => utc_time(),
    220224        );
    221225
    222         $format = array( '%s', '%s', '%s', '%d', '%s' );
     226        $format = array( '%s', '%s', '%s', '%d', '%d', '%s' );
    223227
    224228        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     
    239243
    240244    /**
     245     * Fetch active committee members.
     246     *
     247     * @return array<int, object>
     248     */
     249    public function find_committee_members(): array {
     250        global $wpdb;
     251
     252        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     253        return $wpdb->get_results(
     254            $wpdb->prepare(
     255                'SELECT * FROM %i WHERE committee = 1 AND active = 1 ORDER BY name ASC',
     256                $this->table()
     257            )
     258        );
     259    }
     260
     261    /**
     262     * Fetch all active members (unbounded).
     263     *
     264     * @return array<int, object>
     265     */
     266    public function find_active_members(): array {
     267        global $wpdb;
     268
     269        // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching
     270        return $wpdb->get_results(
     271            $wpdb->prepare(
     272                'SELECT * FROM %i WHERE active = 1 ORDER BY name ASC',
     273                $this->table()
     274            )
     275        );
     276    }
     277
     278    /**
    241279     * Toggle active flag.
    242280     *
  • photo-competition-manager/tags/0.3.0/includes/Service/class-email-service.php

    r3446169 r3465730  
    323323     * @param int      $quota             Maximum allowed submissions.
    324324     * @param int|null $competition_id    Optional competition ID for logging.
     325     * @param string   $member_grade      Member grade.
    325326     * @return bool Whether email was sent successfully.
    326327     */
     
    332333        int $current_count,
    333334        int $quota,
    334         ?int $competition_id = null
     335        ?int $competition_id = null,
     336        string $member_grade = ''
    335337    ): bool {
    336338        $template = $this->get_template( 'submission_confirmed' );
     
    342344        $merge_data = array(
    343345            '{member_name}'       => $member_name,
     346            '{member_grade}'      => $member_grade,
    344347            '{competition_title}' => $competition_title,
    345348            '{category_name}'     => $category_name,
     
    524527
    525528        return $result;
     529    }
     530
     531    /**
     532     * Send results share link email.
     533     *
     534     * @param string   $to_email          Recipient email address.
     535     * @param string   $member_name       Member name.
     536     * @param string   $competition_title Competition title.
     537     * @param string   $share_url         Results page URL with share hash.
     538     * @param int|null $competition_id    Optional competition ID for logging.
     539     * @return bool Whether the email was sent successfully.
     540     */
     541    public function send_results_share_link(
     542        string $to_email,
     543        string $member_name,
     544        string $competition_title,
     545        string $share_url,
     546        ?int $competition_id = null
     547    ): bool {
     548        $template = $this->get_template( 'results_published' );
     549
     550        if ( $template && $template['enabled'] ) {
     551            $merge_data = array(
     552                '{member_name}'        => $member_name,
     553                '{competition_title}'  => $competition_title,
     554                '{results_share_link}' => $share_url,
     555                '{results_page}'       => $share_url,
     556                '{site_name}'          => get_bloginfo( 'name' ),
     557            );
     558
     559            $subject = $this->replace_merge_tags( $template['subject'], $merge_data );
     560            $message = $this->replace_merge_tags( $template['body'], $merge_data );
     561            $message = $this->wrap_html_email( $message );
     562        } else {
     563            $subject = sprintf(
     564                /* translators: %s: Competition title */
     565                __( 'Results for %s', 'photo-competition-manager' ),
     566                $competition_title
     567            );
     568            $message = $this->get_results_share_link_email_body( $member_name, $competition_title, $share_url );
     569        }
     570
     571        $headers = array( 'Content-Type: text/html; charset=UTF-8' );
     572
     573        $result = $this->send_mail( $to_email, $this->prefix_subject( $subject ), $message, $headers );
     574
     575        if ( $result && $this->event_logger ) {
     576            $this->event_logger->log_email_sent(
     577                $competition_id,
     578                'results_share_link',
     579                $member_name,
     580                array( 'email' => $to_email )
     581            );
     582        }
     583
     584        return $result;
     585    }
     586
     587    /**
     588     * Get results share link email body.
     589     *
     590     * @param string $member_name       Member name.
     591     * @param string $competition_title Competition title.
     592     * @param string $share_url         Share URL.
     593     * @return string
     594     */
     595    private function get_results_share_link_email_body( string $member_name, string $competition_title, string $share_url ): string {
     596        ob_start();
     597        ?>
     598        <!DOCTYPE html>
     599        <html>
     600        <head>
     601            <meta charset="UTF-8">
     602        </head>
     603        <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
     604            <div style="max-width: 600px; margin: 0 auto; padding: 20px;">
     605                <h2 style="color: #0073aa;"><?php echo esc_html( $competition_title ); ?> - <?php esc_html_e( 'Results', 'photo-competition-manager' ); ?></h2>
     606
     607                <p>
     608                <?php
     609                    printf(
     610                        /* translators: %s: Member name */
     611                        esc_html__( 'Hi %s,', 'photo-competition-manager' ),
     612                        esc_html( $member_name )
     613                    );
     614                ?>
     615                </p>
     616
     617                <p><?php esc_html_e( 'The results for this competition are now available. Click the button below to view them:', 'photo-competition-manager' ); ?></p>
     618
     619                <p style="margin: 30px 0;">
     620                    <a href="<?php echo esc_url( $share_url ); ?>"
     621                        style="background-color: #0073aa; color: #ffffff; padding: 12px 24px; text-decoration: none; border-radius: 4px; display: inline-block;">
     622                        <?php esc_html_e( 'View Results', 'photo-competition-manager' ); ?>
     623                    </a>
     624                </p>
     625
     626                <p style="color: #666; font-size: 14px;">
     627                    <?php esc_html_e( 'This is a private link. Please do not share it publicly.', 'photo-competition-manager' ); ?>
     628                </p>
     629
     630                <hr style="border: none; border-top: 1px solid #ddd; margin: 30px 0;">
     631
     632                <p style="color: #999; font-size: 12px;">
     633                    <?php
     634                    printf(
     635                        /* translators: %s: Site name */
     636                        esc_html__( 'This email was sent by %s', 'photo-competition-manager' ),
     637                        esc_html( get_bloginfo( 'name' ) )
     638                    );
     639                    ?>
     640                </p>
     641            </div>
     642        </body>
     643        </html>
     644        <?php
     645        return ob_get_clean();
    526646    }
    527647
  • photo-competition-manager/tags/0.3.0/includes/Service/class-member-csv-importer.php

    r3415757 r3465730  
    102102
    103103            // Get column indexes.
    104             $col_name   = array_search( 'name', $header, true );
    105             $col_email  = array_search( 'email', $header, true );
    106             $col_grade  = array_search( 'grade', $header, true );
    107             $col_active = array_search( 'active', $header, true );
     104            $col_name      = array_search( 'name', $header, true );
     105            $col_email     = array_search( 'email', $header, true );
     106            $col_grade     = array_search( 'grade', $header, true );
     107            $col_active    = array_search( 'active', $header, true );
     108            $col_committee = array_search( 'committee', $header, true );
    108109
    109110            $row_number = 1; // Start at 1 (header is row 0).
    110111        } else {
    111             // First row is data - assume format: name,email or name,email,grade,active.
     112            // First row is data - assume format: name,email or name,email,grade,active,committee.
    112113            // Rewind to process first row as data.
    113114            // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rewind
    114115            rewind( $handle );
    115116
    116             // Assume columns: name, email, grade (optional), active (optional).
    117             $col_name   = 0;
    118             $col_email  = 1;
    119             $col_grade  = 2;
    120             $col_active = 3;
     117            // Assume columns: name, email, grade (optional), active (optional), committee (optional).
     118            $col_name      = 0;
     119            $col_email     = 1;
     120            $col_grade     = 2;
     121            $col_active    = 3;
     122            $col_committee = 4;
    121123
    122124            $row_number = 0; // Start at 0 since there's no header.
     
    140142            $email  = isset( $row[ $col_email ] ) ? sanitize_email( trim( $row[ $col_email ] ) ) : '';
    141143            $grade  = false !== $col_grade && isset( $row[ $col_grade ] ) ? sanitize_text_field( trim( $row[ $col_grade ] ) ) : '';
    142             $active = false !== $col_active && isset( $row[ $col_active ] ) ? trim( $row[ $col_active ] ) : '1';
     144            $active    = false !== $col_active && isset( $row[ $col_active ] ) ? trim( $row[ $col_active ] ) : '1';
     145            $committee = false !== $col_committee && isset( $row[ $col_committee ] ) ? trim( $row[ $col_committee ] ) : '0';
    143146
    144147            // Validate required fields.
     
    168171
    169172            // Normalize active field (1, yes, true, active = true; others = false).
    170             $active_normalized = in_array( strtolower( $active ), array( '1', 'yes', 'true', 'active' ), true ) ? 1 : 0;
     173            $active_normalized    = in_array( strtolower( $active ), array( '1', 'yes', 'true', 'active' ), true ) ? 1 : 0;
     174            $committee_normalized = in_array( strtolower( $committee ), array( '1', 'yes', 'true' ), true ) ? 1 : 0;
    171175
    172176            // Check if member already exists by email.
     
    174178
    175179            $data = array(
    176                 'name'   => $name,
    177                 'email'  => $email,
    178                 'grade'  => $grade,
    179                 'active' => $active_normalized,
     180                'name'      => $name,
     181                'email'     => $email,
     182                'grade'     => $grade,
     183                'active'    => $active_normalized,
     184                'committee' => $committee_normalized,
    180185            );
    181186
     
    226231     */
    227232    public function generate_sample_csv(): string {
    228         $csv  = "name,email,grade,active\n";
    229         $csv .= '"John Doe",john.doe@example.com,Beginner,1' . "\n";
    230         $csv .= '"Jane Smith",jane.smith@example.com,Advanced,1' . "\n";
    231         $csv .= '"Bob Johnson",bob.johnson@example.com,Intermediate,0' . "\n";
     233        $csv  = "name,email,grade,active,committee\n";
     234        $csv .= '"John Doe",john.doe@example.com,Beginner,1,0' . "\n";
     235        $csv .= '"Jane Smith",jane.smith@example.com,Advanced,1,1' . "\n";
     236        $csv .= '"Bob Johnson",bob.johnson@example.com,Intermediate,0,0' . "\n";
    232237
    233238        return $csv;
  • photo-competition-manager/tags/0.3.0/includes/Service/class-upload-handler.php

    r3415757 r3465730  
    191191            $category_config['label'],
    192192            $counter,
    193             $quota
     193            $quota,
     194            null,
     195            ! empty( $member->grade ) ? $member->grade : ''
    194196        );
    195197
  • photo-competition-manager/tags/0.3.0/includes/Support/class-competition-settings.php

    r3415757 r3465730  
    9292            ),
    9393            'slideshow'       => array(
    94                 'duration_seconds' => 10,
     94                'duration_seconds'    => 10,
     95                'progress_meter_type' => 'bar',
    9596            ),
    9697            'email_reminders' => array(
     
    108109            ),
    109110        );
     111    }
     112
     113    /**
     114     * Generate a 32-character hex share hash.
     115     *
     116     * @return string
     117     */
     118    public static function generate_share_hash(): string {
     119        return bin2hex( random_bytes( 16 ) );
    110120    }
    111121
     
    269279        }
    270280
     281        if ( isset( $settings['slideshow']['progress_meter_type'] ) ) {
     282            $valid_meter_types = array( 'bar', 'line', 'dots', 'radial' );
     283            if ( ! in_array( $settings['slideshow']['progress_meter_type'], $valid_meter_types, true ) ) {
     284                return new WP_Error( 'invalid_meter_type', __( 'Progress meter type must be "bar", "line", "dots", or "radial".', 'photo-competition-manager' ) );
     285            }
     286        }
     287
    271288        return true;
    272289    }
  • photo-competition-manager/tags/0.3.0/includes/bootstrap.php

    r3415757 r3465730  
    1212// Define plugin version.
    1313if ( ! defined( 'PHOTO_COMPETITION_MANAGER_VERSION' ) ) {
    14     define( 'PHOTO_COMPETITION_MANAGER_VERSION', '0.1.0' );
     14    define( 'PHOTO_COMPETITION_MANAGER_VERSION', '0.3.0' );
    1515}
    1616
  • photo-competition-manager/tags/0.3.0/photo-competition-manager.php

    r3446169 r3465730  
    33 * Plugin Name: Photo Competition Manager
    44 * Description: Manage photography competitions, submissions, and voting.
    5  * Version: 0.2.0
     5 * Version: 0.3.0
    66 * Author: Donncha O Caoimh
    77 * License: GPL2
  • photo-competition-manager/tags/0.3.0/public/class-results-shortcode.php

    r3415757 r3465730  
    103103        );
    104104
     105        $share_hash  = isset( $_GET['share'] ) ? sanitize_text_field( wp_unslash( $_GET['share'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only parameter for share link access.
    105106        $competition = null;
    106 
    107         // If no competition specified, get the most recent one.
     107        $valid_share = false;
     108
    108109        if ( empty( $atts['competition'] ) ) {
    109             $competitions = $this->competitions_repo->all( 1, false, false );
    110             if ( empty( $competitions ) ) {
    111                 return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>';
    112             }
    113             $competition = $competitions[0];
     110            // When a share hash is provided, resolve the competition it belongs to.
     111            if ( ! empty( $share_hash ) ) {
     112                $competition = $this->competitions_repo->find_by_share_hash( $share_hash );
     113                if ( $competition ) {
     114                    $valid_share = true;
     115                }
     116            }
     117
     118            // Fall back to the most recent competition.
     119            if ( ! $competition ) {
     120                $competitions = $this->competitions_repo->all( 1, false, false );
     121                if ( empty( $competitions ) ) {
     122                    return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>';
     123                }
     124                $competition = $competitions[0];
     125            }
    114126        } else {
    115127            $competition = $this->competitions_repo->find_by_slug( $atts['competition'] );
     
    117129                return '<p class="error">' . esc_html__( 'Competition not found.', 'photo-competition-manager' ) . '</p>';
    118130            }
     131
     132            // Validate share hash against the explicit competition.
     133            if ( ! empty( $share_hash ) ) {
     134                $stored_hash = $competition->share_hash ?? '';
     135                $valid_share = ! empty( $stored_hash ) && hash_equals( $stored_hash, $share_hash );
     136            }
    119137        }
    120138
     
    122140
    123141        ob_start();
    124         $this->render_results( $competition, $hide_names );
     142        $this->render_results( $competition, $hide_names, $valid_share );
    125143        $output = ob_get_clean();
    126144        return false !== $output ? $output : '';
     
    130148     * Render results display.
    131149     *
    132      * @param object $competition Competition object.
    133      * @param bool   $hide_names  Whether to hide member names.
     150     * @param object $competition  Competition object.
     151     * @param bool   $hide_names   Whether to hide member names.
     152     * @param bool   $valid_share  Whether a valid share hash was provided.
    134153     * @return void
    135154     */
    136     private function render_results( object $competition, bool $hide_names = false ): void {
     155    private function render_results( object $competition, bool $hide_names = false, bool $valid_share = false ): void {
    137156        $settings   = Competition_Settings::parse( $competition->settings );
    138157        $grades     = Competition_Settings::get_grades( $settings );
    139158        $categories = Competition_Settings::get_categories( $settings );
    140159
    141         // Check if results are visible.
    142160        $results_visible = $settings['results']['results_visible'] ?? false;
    143161
    144         if ( ! $results_visible ) {
     162        if ( ! $results_visible && ! $valid_share ) {
    145163            echo '<div class="photo-comp-results">';
    146164            echo '<h2>' . esc_html( $competition->title ) . ' - ' . esc_html__( 'Results', 'photo-competition-manager' ) . '</h2>';
  • photo-competition-manager/tags/0.3.0/public/class-slideshow-shortcode.php

    r3415757 r3465730  
    149149        // Output slideshow interface.
    150150        ob_start();
    151         $this->render_slideshow_interface( $competition, $category, $category_label, $image_data );
     151        $this->render_slideshow_interface( $competition, $category, $category_label, $image_data, $settings );
    152152        $output = ob_get_clean();
    153153
     
    161161     * @param string            $category       Category slug.
    162162     * @param string            $category_label Category label.
    163      * @param array<int, array> $image_data     Image data for JavaScript.
    164      * @return void
    165      */
    166     private function render_slideshow_interface( object $competition, string $category, string $category_label, array $image_data ): void {
     163     * @param array<int, array>   $image_data     Image data for JavaScript.
     164     * @param array<string, mixed> $settings       Competition settings.
     165     * @return void
     166     */
     167    private function render_slideshow_interface( object $competition, string $category, string $category_label, array $image_data, array $settings ): void {
    167168        $nonce = wp_create_nonce( 'photo_comp_slideshow' );
    168169        ?>
     
    172173            data-nonce="<?php echo esc_attr( $nonce ); ?>"
    173174            data-ajax-url="<?php echo esc_url( admin_url( 'admin-ajax.php' ) ); ?>"
    174             data-images="<?php echo esc_attr( wp_json_encode( $image_data ) ); ?>">
     175            data-images="<?php echo esc_attr( wp_json_encode( $image_data ) ); ?>"
     176            data-meter-type="<?php echo esc_attr( $settings['slideshow']['progress_meter_type'] ?? 'bar' ); ?>">
    175177
    176178            <!-- Control Panel -->
  • photo-competition-manager/tags/0.3.0/public/class-top3-shortcode.php

    r3415757 r3465730  
    102102        );
    103103
     104        $share_hash  = isset( $_GET['share'] ) ? sanitize_text_field( wp_unslash( $_GET['share'] ) ) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Public read-only parameter for share link access.
    104105        $competition = null;
    105 
    106         // If no competition specified, get the most recent one.
     106        $valid_share = false;
     107
    107108        if ( empty( $atts['competition'] ) ) {
    108             $competitions = $this->competitions_repo->all( 1, false, false );
    109             if ( empty( $competitions ) ) {
    110                 return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>';
    111             }
    112             $competition = $competitions[0];
     109            // When a share hash is provided, resolve the competition it belongs to.
     110            if ( ! empty( $share_hash ) ) {
     111                $competition = $this->competitions_repo->find_by_share_hash( $share_hash );
     112                if ( $competition ) {
     113                    $valid_share = true;
     114                }
     115            }
     116
     117            // Fall back to the most recent competition.
     118            if ( ! $competition ) {
     119                $competitions = $this->competitions_repo->all( 1, false, false );
     120                if ( empty( $competitions ) ) {
     121                    return '<p class="error">' . esc_html__( 'No competitions found.', 'photo-competition-manager' ) . '</p>';
     122                }
     123                $competition = $competitions[0];
     124            }
    113125        } else {
    114126            $competition = $this->competitions_repo->find_by_slug( $atts['competition'] );
     
    116128                return '<p class="error">' . esc_html__( 'Competition not found.', 'photo-competition-manager' ) . '</p>';
    117129            }
     130
     131            // Validate share hash against the explicit competition.
     132            if ( ! empty( $share_hash ) ) {
     133                $stored_hash = $competition->share_hash ?? '';
     134                $valid_share = ! empty( $stored_hash ) && hash_equals( $stored_hash, $share_hash );
     135            }
    118136        }
    119137
    120138        ob_start();
    121         $this->render_top3_results( $competition );
     139        $this->render_top3_results( $competition, $valid_share );
    122140        $output = ob_get_clean();
    123141        return $output ? $output : '';
     
    127145     * Render top 3 results display.
    128146     *
    129      * @param object $competition Competition object.
     147     * @param object $competition  Competition object.
     148     * @param bool   $valid_share  Whether a valid share hash was provided.
    130149     * @return void
    131150     */
    132     private function render_top3_results( object $competition ): void {
     151    private function render_top3_results( object $competition, bool $valid_share = false ): void {
    133152        $settings   = Competition_Settings::parse( $competition->settings );
    134153        $grades     = Competition_Settings::get_grades( $settings );
    135154        $categories = Competition_Settings::get_categories( $settings );
    136155
    137         // Check if results are visible.
    138156        $results_visible = $settings['results']['results_visible'] ?? false;
    139157
    140         if ( ! $results_visible ) {
     158        if ( ! $results_visible && ! $valid_share ) {
    141159            echo '<div class="photo-comp-top3">';
    142160            echo '<h2>' . esc_html( $competition->title ) . ' - ' . esc_html__( 'Top 3 Winners', 'photo-competition-manager' ) . '</h2>';
  • photo-competition-manager/tags/0.3.0/public/class-voting-shortcode.php

    r3415757 r3465730  
    498498            }
    499499
    500             // Lowercase the provided password for case-insensitive comparison.
    501             if ( ! wp_check_password( strtolower( $provided_pass ), $expected_password ) ) {
     500            // Try direct case-insensitive comparison first (plaintext), fall back to wp_check_password for legacy hashes.
     501            $password_matches = strtolower( $provided_pass ) === strtolower( $expected_password )
     502                || wp_check_password( strtolower( $provided_pass ), $expected_password );
     503            if ( ! $password_matches ) {
    502504                return array(
    503505                    'status'   => 'error',
  • photo-competition-manager/tags/0.3.0/readme.txt

    r3446169 r3465730  
    55Tested up to: 6.9
    66Requires PHP: 7.4
    7 Stable tag: 0.2.0
     7Stable tag: 0.3.0
    88License: GPLv2 or later
    99License URI: https://www.gnu.org/licenses/gpl-2.0.html
     
    155155== Changelog ==
    156156
     157= 0.3.0 =
     158* Fix fatal error on activation due to missing Admin_Dependencies class in release package
     159* **Results Sharing** — Share competition results via a secret link before making them public
     160  * New "Generate Results Link" action on the Competitions page
     161  * "Send to Committee" and "Send to All Members" buttons on the Results Dashboard
     162  * Share link bypasses results visibility and resolves to the correct competition
     163* **Committee Members** — Mark members as committee via admin or CSV import
     164* Confirmation dialogs on hash regeneration and email sending to prevent accidental actions
     165
    157166= 0.2.0 =
    158167* **Voting Controls Redesign**
     
    223232== Upgrade Notice ==
    224233
     234= 0.3.0 =
     235Fixes a fatal error on plugin activation. Share competition results with committee members or all members via a secret link before making results public.
     236
    225237= 0.2.0 =
    226238Redesigned voting controls, improved tie handling in scoring, better export formatting, and various bug fixes. All times are now normalized to UTC.
Note: See TracChangeset for help on using the changeset viewer.