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'] ? '
' : '' ) . '
';
}
/* ---------------- 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' ) );
}
}