$atts Block attributes (`loginPageId`) or * shortcode attributes (`login_page_id`). */ public function render( array $atts ): string { if ( ! is_user_logged_in() ) { $loginPageId = Val::int( $atts['loginPageId'] ?? $atts['login_page_id'] ?? 0 ); return sprintf( '

%s %s.

', esc_html__( 'Please', 'unsupervised-schedular' ), esc_url( $this->loginUrl( $loginPageId ) ), esc_html__( 'log in to manage your family', 'unsupervised-schedular' ) ); } wp_enqueue_style( 'us-scheduler' ); $userId = get_current_user_id(); $children = $this->guardians->children( $userId ); $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $error = $this->submitError; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only display flag; the submit that set it was nonce-checked. $result = sanitize_key( Val::string( wp_unslash( $_GET['us_family'] ?? '' ) ) ); $notice = $this->noticeFor( $result ); // Which child the "edit" link opened, if any — the row is swapped for an // editable form rather than every row carrying one. // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only routing; the edit submit is nonce-checked. $editingId = absint( Val::int( $_GET['us_edit_child'] ?? 0 ) ); ob_start(); include USC_PLUGIN_DIR . 'templates/frontend/family-page.php'; return (string) ob_get_clean(); } /** * Process an add/edit/remove submission on `template_redirect`, before any * page output, then post/redirect/get back to the page. An error is stashed * for {@see render()} to show inline with the form. */ public function maybeHandleSubmit(): void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- routing only; the action is nonce-checked immediately below. $action = sanitize_key( Val::string( wp_unslash( $_POST['us_family_action'] ?? '' ) ) ); if ( '' === $action || ! is_user_logged_in() ) { return; } if ( ! check_admin_referer( 'us_family' ) ) { return; } $userId = get_current_user_id(); $result = match ( $action ) { 'add' => $this->handleAdd( $userId ), 'edit' => $this->handleEdit( $userId ), 'remove' => $this->handleRemove( $userId ), default => new \WP_Error( 'unknown_action', __( 'Unrecognised request.', 'unsupervised-schedular' ) ), }; if ( $result instanceof \WP_Error ) { $this->submitError = $result->get_error_message(); return; } $this->redirect( add_query_arg( 'us_family', $result, $this->currentUrl() ) ); } /** * Add a child, then record their answers to the account-signup questions — * asked per child, since they describe the student rather than the account. * * Required answers are validated *before* the child is created, so a missing * one never leaves a nameless half-added child behind. */ private function handleAdd( int $guardianId ): string|\WP_Error { $name = $this->postString( 'child_name' ); $dateOfBirth = $this->postString( 'child_dob' ); $relationship = $this->postString( 'child_relationship' ); $questions = $this->questions->findByScope( Question::SCOPE_ACCOUNT, activeOnly: true ); $answers = $this->submittedAnswers(); $missing = $this->firstMissingAnswer( $questions, $answers ); if ( null !== $missing ) { return $missing; } $childId = $this->guardians->createChild( $guardianId, $name, $dateOfBirth, $relationship ); if ( $childId instanceof \WP_Error ) { return $childId; } $this->recordAnswers( $questions, $answers, $childId ); return self::RESULT_ADDED; } private function handleEdit( int $guardianId ): string|\WP_Error { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); $error = $this->guardians->updateChild( $guardianId, $childId, $this->postString( 'child_name' ), $this->postString( 'child_dob' ) ); return $error ?? self::RESULT_UPDATED; } private function handleRemove( int $guardianId ): string|\WP_Error { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. $childId = absint( Val::int( $_POST['child_id'] ?? 0 ) ); $error = $this->guardians->removeChild( $guardianId, $childId ); return $error ?? self::RESULT_REMOVED; } /** * The first required question left unanswered, as the error to show — or null * when every required question has a value. * * @param list $questions * @param array $answers question_id => submitted value */ private function firstMissingAnswer( array $questions, array $answers ): ?\WP_Error { foreach ( $questions as $question ) { if ( $question->isRequired && '' === trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ) ) { return new \WP_Error( 'missing_answer', __( 'Please answer all required questions for this child.', 'unsupervised-schedular' ) ); } } return null; } /** * Persist a child's answers to the account-signup questions. The answer is * recorded against the child, not the guardian, so a studio admin reading a * child's screen sees the information that describes them. * * @param list $questions * @param array $answers question_id => submitted value */ private function recordAnswers( array $questions, array $answers, int $childId ): void { foreach ( $questions as $question ) { $value = trim( (string) ( $answers[ (int) $question->id ] ?? '' ) ); if ( '' === $value ) { continue; } $this->answers->insert( new Answer( questionId: (int) $question->id, registrationType: Answer::REG_ACCOUNT, registrationId: $childId, studentId: $childId, answerValue: $value, ) ); } } /** * The account-question answers submitted with the form, keyed by question id. * * @return array */ private function submittedAnswers(): array { // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- nonce checked by the caller; each value is unslashed and sanitized in the loop below. $raw = $_POST['us_answers'] ?? []; if ( ! is_array( $raw ) ) { return []; } $out = []; foreach ( $raw as $questionId => $value ) { $out[ absint( Val::int( $questionId ) ) ] = sanitize_textarea_field( Val::string( wp_unslash( $value ) ) ); } return $out; } /** * A sanitized text field from the submission. The caller has already verified * the nonce. */ private function postString( string $key ): string { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- nonce checked by the caller. return sanitize_text_field( Val::string( wp_unslash( $_POST[ $key ] ?? '' ) ) ); } /** * The confirmation to show for a completed action, or an empty string when * the flag is absent or unrecognised. */ private function noticeFor( string $result ): string { return match ( $result ) { self::RESULT_ADDED => __( 'Child added.', 'unsupervised-schedular' ), self::RESULT_UPDATED => __( 'Details updated.', 'unsupervised-schedular' ), self::RESULT_REMOVED => __( 'Child removed.', 'unsupervised-schedular' ), default => '', }; } /** * The current page's clean permalink, used as the post/redirect/get target so * the edit flag and any stale notice are dropped from the URL. */ private function currentUrl(): string { $url = get_permalink(); return is_string( $url ) ? $url : home_url( '/' ); } /** * Issues the post-submit redirect and stops the request. Split out so tests * can observe the target without the process exiting. */ protected function redirect( string $url ): void { wp_safe_redirect( $url ); exit; } /** * URL the logged-out prompt sends visitors to: the chosen login page when one * is configured (and still exists), otherwise the WordPress login screen with * a redirect back to the current page. */ public function loginUrl( int $loginPageId ): string { if ( $loginPageId > 0 ) { $url = get_permalink( $loginPageId ); if ( is_string( $url ) ) { return $url; } } $permalink = get_permalink(); return wp_login_url( false === $permalink ? '' : $permalink ); } }