DEV Community

Cover image for Prioritize Support Tickets with Text Sentiment (and a Clean UI)
IderaDevTools
IderaDevTools

Posted on • Originally published at blog.filestack.com

Prioritize Support Tickets with Text Sentiment (and a Clean UI)

Goal: Use the Filestack CDN text_sentiment transform to score ticket text and convert those scores into a clear priority (P0–P4). The guide builds the UI block-by-block so each part is understandable and testable. A complete, ready-to-run index.html is provided at the end.

What we’ll build

  • A small client-side app that:

  • Takes ticket text.

  • Calls text_sentiment over the Filestack CDN (URL format shown below).

  • Displays the raw JSON (Positive, Negative, Neutral, Mixed).

  • Maps the scores to a support priority with configurable thresholds.

  • Visualizes the scores in bars for a quick at-a-glance view.

Security note: In production, sign policies server-side and avoid exposing secrets in the browser. This demo keeps everything in one file for clarity and fast iteration.

API URL: exact shape

Keep the structure below — matching the known-good format:

https:<span class="hljs-regexp">//</span>cdn.filestackcontent.com/<APIKEY><span class="hljs-regexp">/security=p:<POLICY>,s:<SIGNATURE>/</span>text_sentiment=text:%22<URL-ENCODED-TEXT>%22

  • Wrap text with %22 (double quotes) and URL-encode the text payload.

  • The policy must permit the needed calls — convert covers most transforms; read is commonly included.

  • The signature must be generated with the same app as the API key.

Priority rules (editable)

Use simple defaults and tweak them in UI inputs:

  • Negative ≥ 0.70P0 (Critical)

  • Negative ≥ 0.40P1 (High)

  • Mixed ≥ 0.50P2 (Medium)

  • Neutral ≥ 0.60P3 (Low)

  • Else → P4 (Very Low)

These thresholds balance straightforward triage with enough nuance to catch frustration (Negative) and uncertainty (Mixed).

1) HTML skeleton (structure only)

Define where credentials, text input, thresholds, and results live — no behavior yet.

<!-- 1_skeleton.html (snippet) -->
<body>
  <div class="app">
    <header class="header">
      <div>
        <div class="title">Text Sentiment → Ticket Priority</div>
        <div class="subtitle">Paste credentials, enter ticket text, get scores + priority.</div>
      </div>
      <button id="analyzeBtn" class="btn">Analyze</button>
    </header>

<section class="panel left">
      <div class="field"><label>API Key</label><input id="apiKey" type="text" placeholder="YOUR_API_KEY" /></div>
      <div class="field"><label>Policy</label><input id="policy" type="text" placeholder="YOUR_BASE64_POLICY" /></div>
      <div class="field"><label>Signature</label><input id="signature" type="password" placeholder="YOUR_SIGNATURE" /></div>
      <div class="field">
        <label>Ticket text</label>
        <textarea id="ticketText" placeholder="Paste the customer/agent message here…"></textarea>
        <div class="chips">
          <span class="chip" data-demo="This product is outstanding. Setup was painless and support was fast.">Demo: very positive</span>
          <span class="chip" data-demo="The upload keeps failing, your docs are wrong, and I'm stuck before a deadline.">Demo: negative</span>
          <span class="chip" data-demo="It works, but the new editor toolbar is confusing and slows my team down.">Demo: mixed</span>
        </div>
      </div>
      <div class="grid2 thresholds">
        <div class="panel compact">
          <div class="field"><label>Negative ≥ X ⇒ P0 (Critical)</label><input id="tNegP0" type="number" step="0.01" min="0" max="1" value="0.70" /></div>
          <div class="field"><label>Negative ≥ X ⇒ P1 (High)</label><input id="tNegP1" type="number" step="0.01" min="0" max="1" value="0.40" /></div>
        </div>
        <div class="panel compact">
          <div class="field"><label>Mixed ≥ X ⇒ P2 (Medium)</label><input id="tMixed" type="number" step="0.01" min="0" max="1" value="0.50" /></div>
          <div class="field"><label>Neutral ≥ X ⇒ P3 (Low)</label><input id="tNeutral" type="number" step="0.01" min="0" max="1" value="0.60" /></div>
        </div>
      </div>
      <div class="row">
        <button class="btn ghost" id="copyUrlBtn">Copy request URL</button>
        <button class="btn ghost" id="clearBtn">Clear</button>
      </div>
    </section>
    <section class="panel right">
      <div class="field">
        <label>Computed priority</label>
        <div class="kpi">
          <div class="pill p4" id="priorityPill">P4 • Very Low</div>
          <div id="dominantLabel" class="small">Dominant: -</div>
        </div>
      </div>
      <div class="bars" id="bars"></div>
      <div class="field">
        <label>Raw JSON</label>
        <pre id="rawJson">{ }</pre>
      </div>
    </section>
    <footer class="footer small">
      Tip: In production, sign on the server and proxy requests-don't leak secrets in the browser.
    </footer>
  </div>
</body>
Enter fullscreen mode Exit fullscreen mode

2) Light-mode styles (accessible, framework-free)

Readable defaults, subtle shadows, and components that don’t fight your content.

<!-- 2_styles.css (snippet in <style>) -->
<style>
  :root{
    --bg:#f7f9fc; --text:#0f172a; --muted:#475569; --border:#e2e8f0; --card:#ffffff;
    --accent:#2563eb;
    --p0:#ef4444; --p1:#f59e0b; --p2:#2563eb; --p3:#22c55e; --p4:#7c3aed;
  }
  *{box-sizing:border-box}
  body{
    margin:0; font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Inter,Arial;
    background: radial-gradient(1200px 600px at 10% -10%, #e8eefc 0%, transparent 60%),
                radial-gradient(1000px 500px at 100% 0%, #eaf2ff 0%, transparent 60%), var(--bg);
    color:var(--text); min-height:100vh; display:flex; align-items:stretch; justify-content:center; padding:32px;
  }
  .app{max-width:1200px; width:100%; display:grid; grid-template-columns:360px 1fr; gap:24px}
  .header{grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; padding:6px 0}
  .title{font-size:20px; font-weight:700}
  .subtitle{color:var(--muted); font-size:13px}
  .panel{background:var(--card); border:1px solid var(--border); border-radius:14px; padding:16px; box-shadow:0 8px 24px rgba(15,23,42,.05)}
  .panel.compact{padding:12px}
  .field{margin:12px 0}
  label{display:block; font-size:12px; color:var(--muted); margin-bottom:6px}
  input[type="text"], input[type="password"], textarea, input[type="number"]{
    width:100%; background:#fff; color:var(--text); border:1px solid var(--border); border-radius:10px; padding:10px 12px; outline:none;
  }
  input:focus, textarea:focus{border-color:#c7d2fe; box-shadow:0 0 0 3px #e0e7ff}
  textarea{min-height:140px; resize:vertical}
  .btn{
    display:inline-flex; align-items:center; gap:8px; border:1px solid #c7d2fe; color:#fff;
    background: linear-gradient(180deg,#60a5fa,#2563eb); padding:10px 14px; border-radius:10px; cursor:pointer; font-weight:600;
    box-shadow:0 6px 18px rgba(37,99,235,.25)
  }
  .btn.ghost{background:#fff; color:var(--muted); border:1px solid var(--border); box-shadow:none}
  .chips{display:flex; gap:8px; flex-wrap:wrap; margin-top:8px}
  .chip{background:#f8fafc; border:1px dashed var(--border); color:var(--muted); padding:6px 10px; border-radius:30px; font-size:12px; cursor:pointer}
  .grid2{display:grid; grid-template-columns:1fr 1fr; gap:12px}
  .row{display:flex; gap:10px; flex-wrap:wrap}
  .kpi{display:flex; align-items:center; gap:10px; background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:10px 12px}
  .pill{padding:4px 10px; border-radius:999px; font-weight:700; font-size:12px; border:1px solid transparent}
  .p0{background:#fee2e2; color:#991b1b; border-color:#fecaca}
  .p1{background:#fef3c7; color:#92400e; border-color:#fde68a}
  .p2{background:#dbeafe; color:#1e3a8a; border-color:#bfdbfe}
  .p3{background:#dcfce7; color:#166534; border-color:#bbf7d0}
  .p4{background:#ede9fe; color:#5b21b6; border-color:#ddd6fe}
  .bars{display:grid; gap:10px; margin-top:8px}
  .bar{display:grid; grid-template-columns: 120px 1fr 60px; align-items:center; gap:10px}
  .track{height:10px; background:#f1f5f9; border:1px solid var(--border); border-radius:999px; overflow:hidden}
  .fill{height:100%; background:linear-gradient(90deg,#60a5fa,#2563eb)}
  pre{white-space:pre-wrap; word-break:break-word; background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:12px; font-size:12px; color:#0f172a}
  .small{font-size:12px; color:var(--muted)}
  .footer{grid-column:1/-1; color:var(--muted); font-size:12px; margin-top:6px}
  @media (max-width:980px){ .app{grid-template-columns:1fr} }
</style>
Enter fullscreen mode Exit fullscreen mode

3) URL builder (one job: construct the request)

This matches the proven, working shape — including %22 around the text.

<script>
  function buildSentimentUrl({ apiKey, policy, signature, text }) {
    if (!apiKey || !policy || !signature) throw new Error('Missing apikey/policy/signature.');
    const enc = encodeURIComponent(text || '');
    // Exact shape you confirmed works:
    return `https://cdn.filestackcontent.com/${apiKey}/security=p:${policy},s:${signature}/text_sentiment=text:%22${enc}%22`;
  }
</script>
Enter fullscreen mode Exit fullscreen mode

4) Priority engine (pure functions)

Decoupled from the DOM so it’s easy to test.

<script>
  function computePriority(emotions, thresholds) {
    const neg = Number(emotions?.Negative || 0);
    const mix = Number(emotions?.Mixed   || 0);
    const neu = Number(emotions?.Neutral || 0);

if (neg >= thresholds.negP0) return ['P0','Critical'];
    if (neg >= thresholds.negP1) return ['P1','High'];
    if (mix >= thresholds.mixed) return ['P2','Medium'];
    if (neu >= thresholds.neutral) return ['P3','Low'];
    return ['P4','Very Low'];
  }
  function dominantEmotion(emotions) {
    let maxK = '-', maxV = 0;
    for (const [k,v] of Object.entries(emotions || {})) {
      const n = Number(v||0);
      if (n > maxV) { maxV = n; maxK = k; }
    }
    return { key:maxK, val:maxV };
  }
</script>
Enter fullscreen mode Exit fullscreen mode

5) UI bindings (wire up inputs, fetch, and render)

Keep DOM work separate from logic for clarity.

<script>
  const els = {
    apiKey: document.getElementById('apiKey'),
    policy: document.getElementById('policy'),
    signature: document.getElementById('signature'),
    ticketText: document.getElementById('ticketText'),
    tNegP0: document.getElementById('tNegP0'),
    tNegP1: document.getElementById('tNegP1'),
    tMixed: document.getElementById('tMixed'),
    tNeutral: document.getElementById('tNeutral'),
    analyzeBtn: document.getElementById('analyzeBtn'),
    copyUrlBtn: document.getElementById('copyUrlBtn'),
    clearBtn: document.getElementById('clearBtn'),
    priorityPill: document.getElementById('priorityPill'),
    dominantLabel: document.getElementById('dominantLabel'),
    bars: document.getElementById('bars'),
    rawJson: document.getElementById('rawJson'),
  };document.querySelectorAll('.chip').forEach(ch => ch.addEventListener('click', () => {
    els.ticketText.value = ch.dataset.demo || '';
  }));
  const pct = n => (Number(n||0)*100).toFixed(2) + '%';
  function setPriorityUI(code,label){
    els.priorityPill.className = `pill ${code.toLowerCase()}`;
    els.priorityPill.textContent = `${code} • ${label}`;
  }
  function renderBars(emotions={}){
    const names = ['Negative','Mixed','Neutral','Positive'];
    els.bars.innerHTML = names.map(name=>{
      const v = Number(emotions[name] || 0);
      return `
        <div class="bar">
          <label>${name}</label>
          <div class="track"><div class="fill" style="width:${Math.max(0,Math.min(100,v*100))}%"></div></div>
          <div class="small">${pct(v)}</div>
        </div>
      `;
    }).join('');
  }
  let lastBuiltUrl = '';
  async function analyze(){
    const text = (els.ticketText.value || '').trim();
    if (!text) { alert('Enter ticket text first.'); return; }
    els.analyzeBtn.disabled = true;
    const original = els.analyzeBtn.textContent;
    els.analyzeBtn.textContent = 'Analyzing…';
    try{
      const url = buildSentimentUrl({
        apiKey: (els.apiKey.value||'').trim(),
        policy: (els.policy.value||'').trim(),
        signature: (els.signature.value||'').trim(),
        text
      });
      lastBuiltUrl = url;
      const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
      if (!res.ok) {
        const errText = await res.text().catch(()=> '');
        throw new Error(`HTTP ${res.status} – ${res.statusText}\n${errText}`);
      }
      const data = await res.json();
      const emotions = data?.emotions || {};
      const [code,label] = computePriority(emotions, {
        negP0: Number(els.tNegP0.value||0.70),
        negP1: Number(els.tNegP1.value||0.40),
        mixed: Number(els.tMixed.value ||0.50),
        neutral:Number(els.tNeutral.value||0.60),
      });
      const dom = dominantEmotion(emotions);
      renderBars(emotions);
      setPriorityUI(code,label);
      els.dominantLabel.textContent = `Dominant: ${dom.key} (${pct(dom.val)})`;
      els.rawJson.textContent = JSON.stringify(data, null, 2);
    } catch (err){
      els.rawJson.textContent = String(err?.message || err);
      renderBars({});
      setPriorityUI('P4','Very Low');
      els.dominantLabel.textContent = 'Dominant: -';
    } finally {
      els.analyzeBtn.disabled = false;
      els.analyzeBtn.textContent = original;
    }
  }
  els.analyzeBtn.addEventListener('click', analyze);
  els.clearBtn.addEventListener('click', ()=>{
    els.ticketText.value = '';
    els.rawJson.textContent = '{ }';
    renderBars({});
    setPriorityUI('P4','Very Low');
    els.dominantLabel.textContent = 'Dominant: -';
  });
  els.copyUrlBtn.addEventListener('click', ()=>{
    if (!lastBuiltUrl) {
      try { lastBuiltUrl = buildSentimentUrl({
        apiKey: (els.apiKey.value||'').trim(),
        policy: (els.policy.value||'').trim(),
        signature: (els.signature.value||'').trim(),
        text: els.ticketText.value || 'Hello world'
      }); } catch { /* ignore */ }
    }
    if (!lastBuiltUrl) return alert('Run once to build the URL, or fill credentials.');
    navigator.clipboard.writeText(lastBuiltUrl).then(()=>{
      els.copyUrlBtn.textContent = 'Copied ✓';
      setTimeout(()=> els.copyUrlBtn.textContent = 'Copy request URL', 900);
    });
  });
</script>
Enter fullscreen mode Exit fullscreen mode

Full demo (single-file index.html)

Paste your API key / policy / signature, enter ticket text, and click Analyze. The UI shows the JSON and the computed priority. You can also copy the exact CDN request URL the page used.

This is identical to the blocks above, consolidated for quick use.

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8" />
  <title>Text Sentiment → Ticket Priority (Light Mode)</title>
  <meta name="viewport" content="width=device-width, initial-scale=1" />
  <style>
    :root{
      --bg:#f7f9fc; --text:#0f172a; --muted:#475569; --border:#e2e8f0; --card:#ffffff;
      --accent:#2563eb;
      --p0:#ef4444; --p1:#f59e0b; --p2:#2563eb; --p3:#22c55e; --p4:#7c3aed;
    }
    *{box-sizing:border-box}
    body{
      margin:0; font-family: ui-sans-serif,system-ui,-apple-system,Segoe UI,Roboto,Inter,Arial;
      background: radial-gradient(1200px 600px at 10% -10%, #e8eefc 0%, transparent 60%),
                  radial-gradient(1000px 500px at 100% 0%, #eaf2ff 0%, transparent 60%), var(--bg);
      color:var(--text); min-height:100vh; display:flex; align-items:stretch; justify-content:center; padding:32px;
    }
    .app{max-width:1200px; width:100%; display:grid; grid-template-columns:360px 1fr; gap:24px}
    .header{grid-column:1/-1; display:flex; align-items:center; justify-content:space-between; padding:6px 0}
    .title{font-size:20px; font-weight:700}
    .subtitle{color:var(--muted); font-size:13px}
    .panel{background:var(--card); border:1px solid var(--border); border-radius:14px; padding:16px; box-shadow:0 8px 24px rgba(15,23,42,.05)}
    .panel.compact{padding:12px}
    .field{margin:12px 0}
    label{display:block; font-size:12px; color:var(--muted); margin-bottom:6px}
    input[type="text"], input[type="password"], textarea, input[type="number"]{
      width:100%; background:#fff; color:var(--text); border:1px solid var(--border); border-radius:10px; padding:10px 12px; outline:none;
    }
    input:focus, textarea:focus{border-color:#c7d2fe; box-shadow:0 0 0 3px #e0e7ff}
    textarea{min-height:140px; resize:vertical}
    .btn{
      display:inline-flex; align-items:center; gap:8px; border:1px solid #c7d2fe; color:#fff;
      background: linear-gradient(180deg,#60a5fa,#2563eb); padding:10px 14px; border-radius:10px; cursor:pointer; font-weight:600;
      box-shadow:0 6px 18px rgba(37,99,235,.25)
    }
    .btn.ghost{background:#fff; color:var(--muted); border:1px solid var(--border); box-shadow:none}
    .chips{display:flex; gap:8px; flex-wrap:wrap; margin-top:8px}
    .chip{background:#f8fafc; border:1px dashed var(--border); color:var(--muted); padding:6px 10px; border-radius:30px; font-size:12px; cursor:pointer}
    .grid2{display:grid; grid-template-columns:1fr 1fr; gap:12px}
    .row{display:flex; gap:10px; flex-wrap:wrap}
    .kpi{display:flex; align-items:center; gap:10px; background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:10px 12px}
    .pill{padding:4px 10px; border-radius:999px; font-weight:700; font-size:12px; border:1px solid transparent}
    .p0{background:#fee2e2; color:#991b1b; border-color:#fecaca}
    .p1{background:#fef3c7; color:#92400e; border-color:#fde68a}
    .p2{background:#dbeafe; color:#1e3a8a; border-color:#bfdbfe}
    .p3{background:#dcfce7; color:#166534; border-color:#bbf7d0}
    .p4{background:#ede9fe; color:#5b21b6; border-color:#ddd6fe}
    .bars{display:grid; gap:10px; margin-top:8px}
    .bar{display:grid; grid-template-columns: 120px 1fr 60px; align-items:center; gap:10px}
    .track{height:10px; background:#f1f5f9; border:1px solid var(--border); border-radius:999px; overflow:hidden}
    .fill{height:100%; background:linear-gradient(90deg,#60a5fa,#2563eb)}
    pre{white-space:pre-wrap; word-break:break-word; background:#f8fafc; border:1px solid var(--border); border-radius:12px; padding:12px; font-size:12px; color:#0f172a}
    .small{font-size:12px; color:var(--muted)}
    .footer{grid-column:1/-1; color:var(--muted); font-size:12px; margin-top:6px}
    @media (max-width:980px){ .app{grid-template-columns:1fr} }
  </style>
</head>
<body>
  <div class="app">
    <header class="header">
      <div>
        <div class="title">Text Sentiment → Ticket Priority</div>
        <div class="subtitle">Paste credentials, enter ticket text, get scores + priority.</div>
      </div>
      <button id="analyzeBtn" class="btn">Analyze</button>
    </header>

<section class="panel left">
      <div class="field"><label>API Key</label><input id="apiKey" type="text" placeholder="YOUR_API_KEY" /></div>
      <div class="field"><label>Policy</label><input id="policy" type="text" placeholder="YOUR_BASE64_POLICY" /></div>
      <div class="field"><label>Signature</label><input id="signature" type="password" placeholder="YOUR_SIGNATURE" /></div>
      <div class="field">
        <label>Ticket text</label>
        <textarea id="ticketText" placeholder="Paste the customer/agent message here…"></textarea>
        <div class="chips">
          <span class="chip" data-demo="This product is outstanding. Setup was painless and support was fast.">Demo: very positive</span>
          <span class="chip" data-demo="The upload keeps failing, your docs are wrong, and I'm stuck before a deadline.">Demo: negative</span>
          <span class="chip" data-demo="It works, but the new editor toolbar is confusing and slows my team down.">Demo: mixed</span>
        </div>
      </div>
      <div class="grid2 thresholds">
        <div class="panel compact">
          <div class="field"><label>Negative ≥ X ⇒ P0 (Critical)</label><input id="tNegP0" type="number" step="0.01" min="0" max="1" value="0.70" /></div>
          <div class="field"><label>Negative ≥ X ⇒ P1 (High)</label><input id="tNegP1" type="number" step="0.01" min="0" max="1" value="0.40" /></div>
        </div>
        <div class="panel compact">
          <div class="field"><label>Mixed ≥ X ⇒ P2 (Medium)</label><input id="tMixed" type="number" step="0.01" min="0" max="1" value="0.50" /></div>
          <div class="field"><label>Neutral ≥ X ⇒ P3 (Low)</label><input id="tNeutral" type="number" step="0.01" min="0" max="1" value="0.60" /></div>
        </div>
      </div>
      <div class="row">
        <button class="btn ghost" id="copyUrlBtn">Copy request URL</button>
        <button class="btn ghost" id="clearBtn">Clear</button>
      </div>
    </section>
    <section class="panel right">
      <div class="field">
        <label>Computed priority</label>
        <div class="kpi">
          <div class="pill p4" id="priorityPill">P4 • Very Low</div>
          <div id="dominantLabel" class="small">Dominant: -</div>
        </div>
      </div>
      <div class="bars" id="bars"></div>
      <div class="field">
        <label>Raw JSON</label>
        <pre id="rawJson">{ }</pre>
      </div>
    </section>
    <footer class="footer small">
      Tip: In production, sign on the server and proxy requests-don't leak secrets in the browser.
    </footer>
  </div>
  <script>
    function buildSentimentUrl({ apiKey, policy, signature, text }) {
      if (!apiKey || !policy || !signature) throw new Error('Missing apikey/policy/signature.');
      const enc = encodeURIComponent(text || '');
      return `https://cdn.filestackcontent.com/${apiKey}/security=p:${policy},s:${signature}/text_sentiment=text:%22${enc}%22`;
    }
    function computePriority(emotions, thresholds) {
      const neg = Number(emotions?.Negative || 0);
      const mix = Number(emotions?.Mixed   || 0);
      const neu = Number(emotions?.Neutral || 0);
      if (neg >= thresholds.negP0) return ['P0','Critical'];
      if (neg >= thresholds.negP1) return ['P1','High'];
      if (mix >= thresholds.mixed) return ['P2','Medium'];
      if (neu >= thresholds.neutral) return ['P3','Low'];
      return ['P4','Very Low'];
    }
    function dominantEmotion(emotions) {
      let maxK = '-', maxV = 0;
      for (const [k,v] of Object.entries(emotions || {})) {
        const n = Number(v||0);
        if (n > maxV) { maxV = n; maxK = k; }
      }
      return { key:maxK, val:maxV };
    }
    const els = {
      apiKey: document.getElementById('apiKey'),
      policy: document.getElementById('policy'),
      signature: document.getElementById('signature'),
      ticketText: document.getElementById('ticketText'),
      tNegP0: document.getElementById('tNegP0'),
      tNegP1: document.getElementById('tNegP1'),
      tMixed: document.getElementById('tMixed'),
      tNeutral: document.getElementById('tNeutral'),
      analyzeBtn: document.getElementById('analyzeBtn'),
      copyUrlBtn: document.getElementById('copyUrlBtn'),
      clearBtn: document.getElementById('clearBtn'),
      priorityPill: document.getElementById('priorityPill'),
      dominantLabel: document.getElementById('dominantLabel'),
      bars: document.getElementById('bars'),
      rawJson: document.getElementById('rawJson'),
    };
    document.querySelectorAll('.chip').forEach(ch => ch.addEventListener('click', () => {
      els.ticketText.value = ch.dataset.demo || '';
    }));
    const pct = n => (Number(n||0)*100).toFixed(2) + '%';
    function setPriorityUI(code,label){
      els.priorityPill.className = `pill ${code.toLowerCase()}`;
      els.priorityPill.textContent = `${code} • ${label}`;
    }
    function renderBars(emotions={}){
      const names = ['Negative','Mixed','Neutral','Positive'];
      els.bars.innerHTML = names.map(name=>{
        const v = Number(emotions[name] || 0);
        return `
          <div class="bar">
            <label>${name}</label>
            <div class="track"><div class="fill" style="width:${Math.max(0,Math.min(100,v*100))}%"></div></div>
            <div class="small">${pct(v)}</div>
          </div>
        `;
      }).join('');
    }
    let lastBuiltUrl = '';
    async function analyze(){
      const text = (els.ticketText.value || '').trim();
      if (!text) { alert('Enter ticket text first.'); return; }
      els.analyzeBtn.disabled = true;
      const original = els.analyzeBtn.textContent;
      els.analyzeBtn.textContent = 'Analyzing…';
      try{
        const url = buildSentimentUrl({
          apiKey: (els.apiKey.value||'').trim(),
          policy: (els.policy.value||'').trim(),
          signature: (els.signature.value||'').trim(),
          text
        });
        lastBuiltUrl = url;
        const res = await fetch(url, { headers: { 'Accept': 'application/json' } });
        if (!res.ok) {
          const errText = await res.text().catch(()=> '');
          throw new Error(`HTTP ${res.status} – ${res.statusText}\n${errText}`);
        }
        const data = await res.json();
        const emotions = data?.emotions || {};
        const [code,label] = computePriority(emotions, {
          negP0: Number(els.tNegP0.value||0.70),
          negP1: Number(els.tNegP1.value||0.40),
          mixed: Number(els.tMixed.value ||0.50),
          neutral:Number(els.tNeutral.value||0.60),
        });
        const dom = dominantEmotion(emotions);
        renderBars(emotions);
        setPriorityUI(code,label);
        els.dominantLabel.textContent = `Dominant: ${dom.key} (${pct(dom.val)})`;
        els.rawJson.textContent = JSON.stringify(data, null, 2);
      } catch (err){
        els.rawJson.textContent = String(err?.message || err);
        renderBars({});
        setPriorityUI('P4','Very Low');
        els.dominantLabel.textContent = 'Dominant: -';
      } finally {
        els.analyzeBtn.disabled = false;
        els.analyzeBtn.textContent = original;
      }
    }
    els.analyzeBtn.addEventListener('click', analyze);
    els.clearBtn.addEventListener('click', ()=>{
      els.ticketText.value = '';
      els.rawJson.textContent = '{ }';
      renderBars({});
      setPriorityUI('P4','Very Low');
      els.dominantLabel.textContent = 'Dominant: -';
    });
    els.copyUrlBtn.addEventListener('click', ()=>{
      if (!lastBuiltUrl) {
        try { lastBuiltUrl = buildSentimentUrl({
          apiKey: (els.apiKey.value||'').trim(),
          policy: (els.policy.value||'').trim(),
          signature: (els.signature.value||'').trim(),
          text: els.ticketText.value || 'Hello world'
        }); } catch { /* ignore */ }
      }
      if (!lastBuiltUrl) return alert('Run once to build the URL, or fill credentials.');
      navigator.clipboard.writeText(lastBuiltUrl).then(()=>{
        els.copyUrlBtn.textContent = 'Copied ✓';
        setTimeout(()=> els.copyUrlBtn.textContent = 'Copy request URL', 900);
      });
    });
  </script>
</body>
</html>
Enter fullscreen mode Exit fullscreen mode

Production notes

  • Sign and proxy: Generate policy and signature on the server (HMAC-SHA256 over the policy JSON using your app secret). Send the client a short-lived token or proxy the request to keep secrets off the page.

  • Policy permissions: Include convert (covers text transforms) and read. Set a tight expiry.

  • Monitoring: Log raw sentiment JSON with ticket IDs for audits and threshold tuning.

  • Routing: Tie P0/P1 to on-call, P2 to senior agents, P3/P4 to standard queue. Adjust thresholds to match your CS ops.

Extensions (for later)

  • CSV batch: Upload a CSV of ticket texts, append sentiment + computed priority, return a CSV.

  • Helpdesk hooks: Call the transform from your ticketing system’s webhook or automation rules.

  • Multi-language: Detect language first; route non-English tickets to the right locale team.

Originally published on the Filestack blog.

Top comments (1)

Collapse
 
hashbyt profile image
Hashbyt

A clean, intuitive UI paired with AI-powered insights can transform how support teams manage their workload and respond to customers. This approach not only reduces friction for support agents but also enhances the overall user journey. Posts like this help push the industry forward by showcasing practical AI applications in SaaS product interfaces.