CSR for Craft CMS

Usage

craft.csr

{% set articles = craft.csr.articles({ category: 'billing', limit: 10 }).all() %}
{% set results = craft.csr.search(craft.app.request.getParam('q') ?? '') %}

{% for category in craft.csr.categories() %}
    <a href="{{ category.url }}">{{ category.name }}</a>
{% endfor %}
MethodReturns
articles(criteria)An article query already limited to what the current visitor may read.
allArticles(criteria)An article query with no visibility filter. For control panel templates and exports.
article(idOrSlug, siteId)One article, if the visitor may read it.
categories(ticketsOnly)Top-level categories, with children attached.
category(handleOrId)One category.
popular(limit, categoryId), featured(limit)Arrays of articles.
search(query, options)Search results, best first. Logged, so it appears in the search reports.
suggest(text, categoryId)Likely answers for part-typed text. Not logged.
kbUrl(path), supportUrl()The help centre and support form URLs on the current site.
myVote(article)true, false or null: what this visitor already said about an article.
myTickets(criteria)The signed-in customer's own tickets. Matches nothing for a guest.
ticket(reference)A ticket by its reference, which is how a guest reaches their own ticket.
tickets(criteria)A ticket query with no filtering at all. Never use it on a public page.
formFields(category)Your custom questions for a category. Always empty in Lite.
stamp(), honeypotField()What the spam checks expect a form to post.
captcha()The CAPTCHA widget, when a provider is configured on the Spam screen. Nothing otherwise.
priorities(), settings(), isPro()
articleSchema(article), faqSchema(category, articles)Ready-to-print JSON-LD.

Article queries also accept categoryId, featured, visibility, authorId and unhelpful. The category parameter takes handles and includes each category's children.

Each search result has article, score, getSnippet(length) and getHighlightedSnippet(length).

articles() filters for you because the most common way a knowledge base leaks a restricted page is a listing written by someone who didn't know there was anything to filter. allArticles() has a longer name on purpose, so it isn't used by accident.

Taking over the templates

CSR ships a working help centre, support form and ticket page. To change one page, copy it into templates/csr/ and edit your copy. A template in your site always takes precedence over CSR's, and you only need to copy the ones you change.

cp vendor/justinholtweb/craft-csr/src/templates/_kb/article.twig templates/csr/_kb/article.twig
TemplateReceives
_kb/indexcategories, featured, popular
_kb/categorycategory, articles, children
_kb/articlearticle, category, related, existingVote
_kb/searchquery, results, categories
_support/formticket, categories, category, formFields, stamp, honeypot, priorities
_portal/ticketticket, replies, reference, canRate

Every page also receives settings, kbName and baseUri. The shared shell is _kb/_partials/_layout, and every page puts its content in a content block, so your copy can extend your own layout instead.

The Sites screen also has a Template setting for articles. A template named there is rendered by CSR's article controller with the same variables as article.twig, so views are still counted, search clicks are still attributed and restricted articles are still refused. Copying article.twig does the same without a setting.

The support form

The form posts action=csr/support/submit with subject, message, name, email, categoryId, priority, answers[<handle>] and attachments[], plus csrStamp and the honeypot field. The server decides what it accepts. A category that doesn't take tickets is ignored, a priority is only used if visitors may choose one, and answers are only read for questions you have configured.

The suggestions panel is progressive enhancement. The script looks for [data-csr-support-form] with data-csr-suggest-url and data-csr-min-chars, and inside it [data-csr-subject], [data-csr-category], [data-csr-suggestions], [data-csr-suggestion-list] and a [data-csr-deflected] button. If JavaScript is blocked, the form works as an ordinary contact form. Keep those attributes when you rewrite the form.

The Yes, that answers it button is what makes the deflection rate a real measurement. See FAQ.

The customer's ticket page

Every ticket has a 32-character random reference. The link in each email is /support/ticket?csrRef=…, which lets someone without an account read and reply to their own ticket. The parameter is csrRef and not token because Craft reserves token and rejects a request with an unrecognised one before any plugin runs. A signed-in customer can also open their own tickets with ?ticketId=. A reference that doesn't match returns a 404, not a 403, so nobody can tell whether a reference exists.

The page shows ticket.publicReplies. Internal notes are left out by the database query, not by the template, so a rewritten template can't show one by mistake.

Ratings and structured data

"Was this helpful?" posts action=csr/kb/rate with articleId, helpful (1 or 0) and, after a no, comment. The shipped template uses ordinary forms, and the script turns them into background requests.

articleSchema() prints Article and breadcrumb JSON-LD. faqSchema() prints FAQPage only when a category has at least two articles and most of their titles look like questions. Using FAQPage on reference pages is against search engines' structured-data guidelines.

Console commands

php craft csr/sla/check [--limit=500]          # the SLA sweep; run it from cron
php craft csr/sla/preview 240                  # when a 240-minute target set now would be due
php craft csr/kb/gaps [--days=30] [--limit=20] # failed and abandoned searches (Pro)
php craft csr/kb/recount-ratings               # rebuild helpful/unhelpful totals
php craft csr/kb/prune-searches [--days=N]     # delete search rows past the retention period
php craft csr/tickets/close-resolved [--days=N] [--dry-run]
php craft csr/tickets/prune [--days=N] [--dry-run]
php craft csr/tickets/recount                  # rebuild reply counters
php craft resave/csr-articles [--queue]        # re-save articles, like Craft's resave/* commands
php craft resave/csr-tickets [--queue]

close-resolved and prune use the General settings unless you pass --days. prune permanently deletes resolved and closed tickets and asks for confirmation when run interactively. prune-searches uses Keep searches for (capped at 30 days on Lite) unless you pass --days.

Events

use craft\base\Event;
use justinholtweb\csr\events\TicketEvent;
use justinholtweb\csr\services\Replies;
use justinholtweb\csr\services\Tickets;

Event::on(Tickets::class, Tickets::EVENT_AFTER_SUBMIT, function(TicketEvent $e) {
    // $e->ticket has been saved, numbered and notified
});

Event::on(Tickets::class, Tickets::EVENT_AFTER_STATUS_CHANGE, function(TicketEvent $e) {
    // $e->ticket, $e->previousStatus
});

Event::on(Replies::class, Replies::EVENT_AFTER_REPLY, function(TicketEvent $e) {
    // $e->ticket, $e->reply (check $e->reply->isNote and $e->reply->byAgent)
});

Permissions

View knowledge base articles, with Write and edit articles (and Delete articles) and Manage categories beneath it. View tickets, with Answer and manage tickets (and Delete tickets), Export tickets and searches and Manage canned replies beneath it. View reports stands alone. Settings are for admins. Auto-assignment only gives tickets to people with Answer and manage tickets.

Emails

CSR adds eight messages to Settings → Email → System Messages, where you can translate them per site and they use your HTML email template:

csr_ticket_raised, csr_ticket_raised_agent, csr_ticket_reply, csr_ticket_reply_agent, csr_ticket_status, csr_ticket_assigned, csr_sla_alert and csr_satisfaction.

The available variables are ticket, reply, status, number, subject, message, requesterName, requesterEmail, agentName, statusName, category, priority, replyBody, ticketUrl, cpUrl, target and organizationName. Emails to the desk set Reply-To to the customer, so hitting reply answers them.

Pigeon

If Pigeon is installed and Hand the conversation to Pigeon is on, each new ticket opens a Pigeon support thread with the customer's message in it, and the conversation flows both ways: replies and internal notes written in CSR are posted to the thread, and messages written in Pigeon become replies on the ticket — from the desk when the author can answer tickets, from the customer otherwise — so the first-response clock and the ticket's status move either way. Pigeon emails the people on the thread about every message, so on a bridged ticket CSR leaves reply emails to Pigeon rather than sending each answer twice.

The ticket screen links to the thread. CSR imports none of Pigeon's classes. If the bridge fails, a warning is logged and the ticket is still created.