staff) and product/store enquiries (visitors <-> vendor/staff). * Same table: type = ticket | enquiry. */ class Tickets { public function boot() { add_filter( 'frontglut_menu_items', array( $this, 'menu' ) ); add_filter( 'frontglut_email_templates', array( $this, 'emails' ) ); Ajax::register( 'ticket_create', array( $this, 'ajax_create' ) ); Ajax::register( 'ticket_reply', array( $this, 'ajax_reply' ) ); Ajax::register( 'ticket_update', array( $this, 'ajax_update' ) ); Ajax::register( 'enquiry_submit', array( $this, 'ajax_enquiry' ), array( 'public' => true ) ); add_action( 'woocommerce_single_product_summary', array( $this, 'enquiry_box' ), 35 ); add_shortcode( 'frontglut_enquiry', array( $this, 'sc_enquiry' ) ); } public function emails( $t ) { $t['ticket_new'] = array( 'label' => __( 'Staff: new ticket / enquiry', 'frontglut' ), 'subject' => 'New {kind}: {subject}', 'body' => "From: {name} <{email}>\n\n{message}\n\nOpen: {url}", 'vars' => 'kind, subject, name, email, message, url' ); $t['ticket_reply'] = array( 'label' => __( 'Customer: reply to ticket / enquiry', 'frontglut' ), 'subject' => 'Re: {subject}', 'body' => "{message}\n\n— {from}", 'vars' => 'subject, message, from' ); return $t; } public function menu( $i ) { $i['support'] = array( 'label' => __( 'Support', 'frontglut' ), 'icon' => 'sos', 'group' => 'comms', 'order' => 62, 'render' => function () { return $this->screen( 'ticket', 'support' ); } ); $i['enquiries'] = array( 'label' => __( 'Enquiries', 'frontglut' ), 'icon' => 'email-alt', 'group' => 'comms', 'order' => 63, 'cap' => 'frontglut_manage_tickets', 'render' => function () { return $this->screen( 'enquiry', 'enquiries' ); }, 'badge' => array( $this, 'open_count' ) ); return $i; } public function open_count() { global $wpdb; $uid = get_current_user_id(); if ( \FrontGlut\Core\Caps::can( 'frontglut_manage_settings' ) ) { return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}frontglut_tickets WHERE status = 'open'" ); // phpcs:ignore WordPress.DB.PreparedSQL } return (int) $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->prefix}frontglut_tickets WHERE status = 'open' AND assignee = %d", $uid ) ); // phpcs:ignore WordPress.DB.PreparedSQL } /* ---------------- model ---------------- */ public static function create( $type, $user_id, $name, $email, $assignee, $product_id, $subject, $body ) { global $wpdb; $now = current_time( 'mysql', true ); $wpdb->insert( $wpdb->prefix . 'frontglut_tickets', array( 'type' => 'enquiry' === $type ? 'enquiry' : 'ticket', 'user_id' => (int) $user_id, 'name' => sanitize_text_field( $name ), 'email' => sanitize_email( $email ), 'assignee' => (int) $assignee, 'product_id' => (int) $product_id, 'subject' => substr( sanitize_text_field( $subject ), 0, 200 ), 'status' => 'open', 'priority' => 'normal', 'created' => $now, 'updated' => $now ) ); $id = (int) $wpdb->insert_id; if ( $id ) { $wpdb->insert( $wpdb->prefix . 'frontglut_ticket_replies', array( 'ticket_id' => $id, 'user_id' => (int) $user_id, 'body' => sanitize_textarea_field( $body ), 'created' => $now ) ); $url = Dashboard::url( 'enquiry' === $type ? 'enquiries' : 'support', array( 'a' => 'view', 'id' => $id ) ); $vars = array( 'kind' => 'enquiry' === $type ? 'enquiry' : 'ticket', 'subject' => $subject, 'name' => $name, 'email' => $email, 'message' => $body, 'url' => $url ); if ( $assignee ) { $u = get_userdata( $assignee ); if ( $u ) { Mailer::send_template( 'ticket_new', $u->user_email, $vars ); } /* translators: 1: value, 2: value. */ Notify::send( $assignee, sprintf( __( 'New %1$s: %2$s', 'frontglut' ), $vars['kind'], $subject ), wp_trim_words( $body, 18 ), $url ); } else { Mailer::send_template( 'ticket_new', get_option( 'admin_email' ), $vars ); /* translators: 1: value, 2: value. */ Notify::send_to_cap( 'frontglut_manage_tickets', sprintf( __( 'New %1$s: %2$s', 'frontglut' ), $vars['kind'], $subject ), wp_trim_words( $body, 18 ), $url, (int) $user_id ); } } return $id; } private static function get( $id ) { global $wpdb; return $wpdb->get_row( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}frontglut_tickets WHERE id = %d", $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL } private static function is_staff( $uid = 0 ) { return \FrontGlut\Core\Caps::can( 'frontglut_manage_settings', $uid ) || \FrontGlut\Core\Caps::can( 'manage_woocommerce', $uid ); } private static function can_view( array $t, $uid = 0 ) { $uid = $uid ? $uid : get_current_user_id(); return $uid && ( (int) $t['user_id'] === $uid || (int) $t['assignee'] === $uid || ( ! $t['assignee'] && \FrontGlut\Core\Caps::can( 'frontglut_manage_tickets', $uid ) ) || self::is_staff( $uid ) ); } /* ---------------- screens ---------------- */ private function screen( $type, $section ) { $a = Request::route( 'a', 'key', 'list' ); $id = Request::route( 'id', 'int', 0 ); if ( 'view' === $a && $id ) { return $this->view( $id, $section ); } global $wpdb; $uid = get_current_user_id(); $st = Request::filter( 'status', 'key', '' ); $t = $wpdb->prefix . 'frontglut_tickets'; $where = $wpdb->prepare( 'WHERE type = %s', $type ); if ( 'ticket' === $type ) { if ( ! self::is_staff() && ! \FrontGlut\Core\Caps::can( 'frontglut_manage_tickets' ) ) { $where .= $wpdb->prepare( ' AND user_id = %d', $uid ); } } elseif ( ! self::is_staff() ) { $where .= $wpdb->prepare( ' AND assignee = %d', $uid ); } if ( $st ) { $where .= $wpdb->prepare( ' AND status = %s', $st ); } $rs = $wpdb->get_results( "SELECT * FROM $t $where ORDER BY updated DESC LIMIT 100", ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL $rows = array(); foreach ( $rs as $r ) { $rows[] = array( 'subject' => '#' . (int) $r['id'] . ' ' . esc_html( $r['subject'] ) . '' . ( $r['product_id'] ? '
' . esc_html( get_the_title( $r['product_id'] ) ) . '' : '' ), 'from' => esc_html( $r['name'] ?: ( get_userdata( $r['user_id'] ) ? get_userdata( $r['user_id'] )->display_name : '' ) ), 'priority' => esc_html( ucfirst( $r['priority'] ) ), 'status' => Ui::status_badge( $r['status'] ), 'updated' => Ui::date( $r['updated'] ) ); } $pills = '
'; foreach ( array( '' => 'All', 'open' => 'Open', 'closed' => 'Closed' ) as $k => $l ) { $pills .= '' . esc_html( $l ) . ''; } $pills .= '
'; $new = ''; if ( 'ticket' === $type ) { $new = '

' . esc_html__( 'Open a ticket', 'frontglut' ) . '

' . Ui::form_open( 'ticket_create', array( 'after' => 'reload' ) ) . '
' . Ui::form_close( __( 'Submit ticket', 'frontglut' ) ) . '
'; } return Ui::header( 'ticket' === $type ? __( 'Support tickets', 'frontglut' ) : __( 'Enquiries', 'frontglut' ) ) . $new . $pills . Ui::table( array( 'subject' => __( 'Subject', 'frontglut' ), 'from' => __( 'From', 'frontglut' ), 'priority' => __( 'Priority', 'frontglut' ), 'status' => __( 'Status', 'frontglut' ), 'updated' => __( 'Updated', 'frontglut' ) ), $rows, array( 'empty' => __( 'Nothing here yet.', 'frontglut' ) ) ); } private function view( $id, $section ) { global $wpdb; $t = self::get( $id ); if ( ! $t || ! self::can_view( $t ) ) { return Ui::notice( esc_html__( 'Not found.', 'frontglut' ), 'warn' ); } $replies = $wpdb->get_results( $wpdb->prepare( "SELECT * FROM {$wpdb->prefix}frontglut_ticket_replies WHERE ticket_id = %d ORDER BY id ASC", $id ), ARRAY_A ); // phpcs:ignore WordPress.DB.PreparedSQL $th = '
'; foreach ( $replies as $r ) { $u = $r['user_id'] ? get_userdata( $r['user_id'] ) : null; $mine = (int) $r['user_id'] === get_current_user_id() && $r['user_id']; $th .= '
' . esc_html( ( $u ? $u->display_name : $t['name'] ) . ' · ' . get_date_from_gmt( $r['created'], 'Y-m-d H:i' ) ) . '' . nl2br( esc_html( $r['body'] ) ) . '
'; } $th .= '
'; $ctrl = ''; if ( self::is_staff() || (int) $t['assignee'] === get_current_user_id() ) { $ctrl = '

' . esc_html__( 'Manage', 'frontglut' ) . '

' . Ui::form_open( 'ticket_update', array( 'after' => 'reload' ) ) . Ui::hidden( 'id', $id ) . '
'; if ( self::is_staff() ) { $staff = get_users( array( 'capability' => 'frontglut_manage_tickets', 'fields' => array( 'ID', 'display_name' ), 'number' => 100 ) ); $ctrl .= '
'; } $ctrl .= Ui::form_close( __( 'Update', 'frontglut' ) ) . '
'; } $reply = '

' . esc_html__( 'Reply', 'frontglut' ) . '

' . Ui::form_open( 'ticket_reply', array( 'after' => 'reload' ) ) . Ui::hidden( 'id', $id ) . '
' . Ui::form_close( __( 'Send reply', 'frontglut' ) ) . '
'; return Ui::header( '#' . $id . ' ' . $t['subject'], Ui::btn( __( '← Back', 'frontglut' ), Dashboard::url( $section ) ), $t['name'] . ( $t['email'] ? ' <' . $t['email'] . '>' : '' ) . ' · ' . ucfirst( $t['status'] ) ) . '
' . $th . '
' . $reply . '
' . $ctrl . ( $t['product_id'] ? '

' . esc_html__( 'Product', 'frontglut' ) . '

' . esc_html( get_the_title( $t['product_id'] ) ) . '
' : '' ) . '
'; } /* ---------------- ajax ---------------- */ public function ajax_create( $req ) { $uid = get_current_user_id(); $subj = sanitize_text_field( $req['subject'] ?? '' ); $msg = sanitize_textarea_field( $req['message'] ?? '' ); if ( '' === $subj || '' === $msg ) { Ajax::fail( __( 'Subject and message are required.', 'frontglut' ) ); } if ( ! RateLimiter::hit( 'ticket', 10, HOUR_IN_SECONDS, 'u' . $uid ) ) { Ajax::fail( __( 'Too many tickets. Try later.', 'frontglut' ), 429 ); } $u = wp_get_current_user(); $id = self::create( 'ticket', $uid, $u->display_name, $u->user_email, 0, 0, $subj, $msg ); if ( in_array( $req['priority'] ?? '', array( 'low', 'high' ), true ) ) { global $wpdb; $wpdb->update( $wpdb->prefix . 'frontglut_tickets', array( 'priority' => $req['priority'] ), array( 'id' => $id ) ); } return array( 'message' => __( 'Ticket opened.', 'frontglut' ), 'redirect' => Dashboard::url( 'support', array( 'a' => 'view', 'id' => $id ) ) ); } public function ajax_reply( $req ) { global $wpdb; $t = self::get( (int) ( $req['id'] ?? 0 ) ); if ( ! $t || ! self::can_view( $t ) ) { Ajax::fail( __( 'Not found.', 'frontglut' ), 404 ); } $msg = sanitize_textarea_field( $req['message'] ?? '' ); if ( '' === $msg ) { Ajax::fail( __( 'Write a reply first.', 'frontglut' ) ); } $uid = get_current_user_id(); $me = wp_get_current_user(); $wpdb->insert( $wpdb->prefix . 'frontglut_ticket_replies', array( 'ticket_id' => $t['id'], 'user_id' => $uid, 'body' => $msg, 'created' => current_time( 'mysql', true ) ) ); $wpdb->update( $wpdb->prefix . 'frontglut_tickets', array( 'updated' => current_time( 'mysql', true ), 'status' => 'open' ), array( 'id' => $t['id'] ) ); $is_owner = (int) $t['user_id'] === $uid; if ( ! $is_owner ) { // staff/vendor replying: email + notify the requester. $to = $t['email'] ? $t['email'] : ( get_userdata( $t['user_id'] ) ? get_userdata( $t['user_id'] )->user_email : '' ); if ( $to ) { Mailer::send_template( 'ticket_reply', $to, array( 'subject' => $t['subject'], 'message' => $msg, 'from' => $me->display_name ) ); } if ( $t['user_id'] ) { /* translators: %s: value. */ Notify::send( $t['user_id'], sprintf( __( 'Reply on: %s', 'frontglut' ), $t['subject'] ), wp_trim_words( $msg, 15 ), Dashboard::url( 'support', array( 'a' => 'view', 'id' => $t['id'] ) ) ); } } else { $dest = $t['assignee'] ? array( (int) $t['assignee'] ) : get_users( array( 'capability' => 'frontglut_manage_tickets', 'fields' => 'ID', 'number' => 50 ) ); foreach ( $dest as $d ) { /* translators: %s: value. */ Notify::send( $d, sprintf( __( 'Customer replied: %s', 'frontglut' ), $t['subject'] ), wp_trim_words( $msg, 15 ), Dashboard::url( 'ticket' === $t['type'] ? 'support' : 'enquiries', array( 'a' => 'view', 'id' => $t['id'] ) ) ); } } return array( 'message' => __( 'Reply sent.', 'frontglut' ) ); } public function ajax_update( $req ) { global $wpdb; $t = self::get( (int) ( $req['id'] ?? 0 ) ); if ( ! $t || ! ( self::is_staff() || (int) $t['assignee'] === get_current_user_id() ) ) { Ajax::fail( __( 'Not allowed.', 'frontglut' ), 403 ); } $d = array( 'updated' => current_time( 'mysql', true ) ); if ( in_array( $req['status'] ?? '', array( 'open', 'closed' ), true ) ) { $d['status'] = $req['status']; } if ( in_array( $req['priority'] ?? '', array( 'low', 'normal', 'high' ), true ) ) { $d['priority'] = $req['priority']; } if ( self::is_staff() && isset( $req['assignee'] ) ) { $d['assignee'] = (int) $req['assignee']; } $wpdb->update( $wpdb->prefix . 'frontglut_tickets', $d, array( 'id' => $t['id'] ) ); return array( 'message' => __( 'Updated.', 'frontglut' ) ); } /* ---------------- product enquiry ---------------- */ public function enquiry_box() { global $product; if ( $product ) { echo wp_kses( $this->enquiry_html( $product->get_id() ), Ui::allowed_html() ); } } public function sc_enquiry( $atts ) { $a = shortcode_atts( array( 'product' => 0 ), $atts ); return $this->enquiry_html( (int) ( $a['product'] ? $a['product'] : get_the_ID() ) ); } private function enquiry_html( $pid ) { if ( ! $pid || 'product' !== get_post_type( $pid ) ) { return ''; } Assets::need(); $u = wp_get_current_user(); $h = '
' . esc_html__( 'Ask a question about this product', 'frontglut' ) . '' . Ui::form_open( 'enquiry_submit', array( 'after' => 'reset' ) ) . Ui::hidden( 'product', $pid ) . \FrontGlut\Modules\Forms\Forms::honeypot(); $h .= \FrontGlut\Modules\Forms\Forms::render_fields( array( \FrontGlut\Modules\Products\Products::fd( 'name', 'text', __( 'Your name', 'frontglut' ), array( 'required' => 1, 'width' => '50' ) ), \FrontGlut\Modules\Products\Products::fd( 'email', 'email', __( 'Your email', 'frontglut' ), array( 'required' => 1, 'width' => '50' ) ), \FrontGlut\Modules\Products\Products::fd( 'message', 'textarea', __( 'Your question', 'frontglut' ), array( 'required' => 1 ) ), ), $u->ID ? array( 'name' => $u->display_name, 'email' => $u->user_email ) : array(), 'frontglut_f' ); if ( Captcha::active_for( 'enquiry' ) ) { $h .= Captcha::render(); } return $h . Ui::form_close( __( 'Send', 'frontglut' ) ) . '
'; } public function ajax_enquiry( $req ) { $pid = (int) ( $req['product'] ?? 0 ); if ( 'product' !== get_post_type( $pid ) || 'publish' !== get_post_status( $pid ) ) { Ajax::fail( __( 'Product not found.', 'frontglut' ), 404 ); } if ( ! \FrontGlut\Modules\Forms\Forms::check_honeypot( $req ) ) { Ajax::fail( __( 'Spam detected.', 'frontglut' ) ); } if ( Captcha::active_for( 'enquiry' ) ) { $c = Captcha::verify( $req ); if ( is_wp_error( $c ) ) { Ajax::fail( $c->get_error_message() ); } } if ( ! RateLimiter::hit( 'enq', 5, HOUR_IN_SECONDS ) ) { Ajax::fail( __( 'Too many questions. Try later.', 'frontglut' ), 429 ); } $f = isset( $req['frontglut_f'] ) && is_array( $req['frontglut_f'] ) ? $req['frontglut_f'] : array(); $name = sanitize_text_field( $f['name'] ?? '' ); $mail = sanitize_email( $f['email'] ?? '' ); $msg = sanitize_textarea_field( $f['message'] ?? '' ); $err = array(); if ( '' === $name ) { $err['name'] = __( 'Required.', 'frontglut' ); } if ( ! is_email( $mail ) ) { $err['email'] = __( 'Enter a valid email.', 'frontglut' ); } if ( '' === $msg ) { $err['message'] = __( 'Required.', 'frontglut' ); } if ( $err ) { Ajax::fail( __( 'Please fix the highlighted fields.', 'frontglut' ), 400, array( 'errors' => $err ) ); } $vendor = class_exists( '\FrontGlut\Modules\Vendors\Vendors' ) ? \FrontGlut\Modules\Vendors\Vendors::product_vendor( $pid ) : 0; /* translators: %s: value. */ self::create( 'enquiry', get_current_user_id(), $name, $mail, $vendor, $pid, sprintf( __( 'Question about %s', 'frontglut' ), get_the_title( $pid ) ), $msg ); return array( 'message' => __( 'Thanks! We will reply by email.', 'frontglut' ) ); } }