Diff -- /var/www/vhosts/3dshawn.com/crm.3dshawn.com/tutorials.cgi
Diff

/var/www/vhosts/3dshawn.com/crm.3dshawn.com/tutorials.cgi

added on local at 2026-07-01 15:03:20

Added
+490
lines
Removed
-0
lines
Context
0
unchanged
Blobs
from
to cc7ecff79969
Restore this content →
Unified diff
Naive LCS-based line diff. Additions in emerald, deletions in rose. Both sides decompressed live from BLOB_STORE.
1#!/usr/bin/perl
2#======================================================================
3# ContactForge -- Help Center (user-facing walkthroughs + search).
4#
5# GET /tutorials.cgi -> overview page (sections + steps)
6# GET /tutorials.cgi?slug=X -> render one tutorial body
7# GET /tutorials.cgi?q=text -> search results
8#======================================================================
9use strict;
10use warnings;
11
12use lib '/var/www/vhosts/3dshawn.com/crm.3dshawn.com';
13use CGI;
14use MODS::Template;
15use MODS::DBConnect;
16use MODS::Login;
17use MODS::ContactForge::Config;
18use MODS::ContactForge::Wrapper;
19
20$| = 1;
21
22my $q = CGI->new;
23my $form = $q->Vars;
24my $auth = MODS::Login->new;
25my $tfile = MODS::Template->new;
26my $db = MODS::DBConnect->new;
27my $cfg = MODS::ContactForge::Config->new;
28my $wrap = MODS::ContactForge::Wrapper->new;
29my $DB = $cfg->settings('database_name');
30
31my $userinfo = $auth->login_verify();
32unless ($userinfo) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; }
33my $uid = $userinfo->{user_id}; $uid =~ s/[^0-9]//g;
34
35my $dbh = $db->db_connect();
36
37# Schema probe -- the tutorials table is new.
38my $have_tut = 0;
39{
40 my $r = $db->db_readwrite($dbh, qq~
41 SELECT COUNT(*) AS n FROM information_schema.tables
42 WHERE table_schema='$DB' AND table_name='tutorials'
43 ~, $ENV{SCRIPT_NAME}, __LINE__);
44 $have_tut = 1 if $r && $r->{n};
45}
46unless ($have_tut) {
47 $db->db_disconnect($dbh);
48 print "Content-Type: text/html; charset=utf-8\n\n";
49 $wrap->render({
50 userinfo => $userinfo, page_key => 'tutorials', title => 'Help Center',
51 body => qq~<div class="module"><div class="module-body" style="padding:40px;text-align:center">
52 <h1 style="font-family:var(--font-display);color:#fff">Tutorials not installed yet</h1>
53 <p style="color:var(--col-text-2);margin-top:12px">Run the tutorials migration from <code>installation_instructions.html</code> &sect; 5.15c, then refresh.</p>
54 </div></div>~,
55 });
56 exit;
57}
58
59# Route 1: single-tutorial view (?slug=...).
60my $slug = $form->{slug} || '';
61$slug =~ s/[^a-z0-9\-_]//g;
62
63# Route 2: search (?q=...).
64my $search_q = $form->{q} || '';
65$search_q =~ s/^\s+|\s+$//g;
66$search_q = substr($search_q, 0, 120);
67
68if (length $slug) {
69 my $tut = $db->db_readwrite($dbh, qq~
70 SELECT * FROM `${DB}`.tutorials WHERE slug='$slug' AND is_published=1 LIMIT 1
71 ~, $ENV{SCRIPT_NAME}, __LINE__);
72 unless ($tut && $tut->{id}) {
73 $db->db_disconnect($dbh);
74 print "Status: 302 Found\nLocation: /tutorials.cgi\n\n"; exit;
75 }
76 # Mark seen.
77 $db->db_readwrite($dbh, qq~
78 INSERT INTO `${DB}`.tutorial_views SET
79 user_id='$uid', tutorial_id='$tut->{id}', last_viewed_at=NOW()
80 ON DUPLICATE KEY UPDATE last_viewed_at=NOW()
81 ~, $ENV{SCRIPT_NAME}, __LINE__);
82
83 # Next tutorial in sequence.
84 my $next;
85 if ($tut->{next_slug} && length $tut->{next_slug}) {
86 my $ns = $tut->{next_slug}; $ns =~ s/[^a-z0-9\-_]//g;
87 $next = $db->db_readwrite($dbh, qq~
88 SELECT slug, title FROM `${DB}`.tutorials WHERE slug='$ns' AND is_published=1 LIMIT 1
89 ~, $ENV{SCRIPT_NAME}, __LINE__);
90 }
91
92 $db->db_disconnect($dbh);
93
94 my $tvars = {
95 is_single => 1,
96 title => _h($tut->{title}),
97 summary => _h($tut->{summary} || ''),
98 section => _h($tut->{section}),
99 icon => _h($tut->{icon} || ''),
100 body_html => _render_md($tut->{body} || ''),
101 estimated => $tut->{estimated_min} || 0,
102 has_next => ($next && $next->{slug}) ? 1 : 0,
103 next_slug => $next ? _h($next->{slug}) : '',
104 next_title => $next ? _h($next->{title}) : '',
105 search_q => '',
106 };
107 print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
108 my $body = join('', $tfile->template('contactforge_tutorials.html', $tvars, $userinfo));
109 $wrap->render({
110 userinfo => $userinfo, page_key => 'tutorials', title => $tut->{title} . ' - Help Center', body => $body,
111 });
112 exit;
113}
114
115# Walkthrough overview + optional search.
116my @rows;
117my @completed;
118{
119 # All published tutorials, grouped by section + ordered.
120 @rows = $db->db_readwrite_multiple($dbh, qq~
121 SELECT t.id, t.slug, t.section, t.title, t.summary, t.icon, t.step_order, t.estimated_min,
122 (SELECT 1 FROM `${DB}`.tutorial_views v
123 WHERE v.user_id='$uid' AND v.tutorial_id=t.id LIMIT 1) AS seen
124 FROM `${DB}`.tutorials t
125 WHERE t.is_published=1
126 ORDER BY t.step_order ASC, t.id ASC
127 ~, $ENV{SCRIPT_NAME}, __LINE__);
128}
129
130# Search results (LIKE across title + summary + body + keywords).
131my @hits;
132if (length $search_q) {
133 my $esc = $search_q; $esc =~ s/\\/\\\\/g; $esc =~ s/'/''/g; $esc =~ s/%/\\%/g; $esc =~ s/_/\\_/g;
134 @hits = $db->db_readwrite_multiple($dbh, qq~
135 SELECT slug, title, summary, section, icon
136 FROM `${DB}`.tutorials
137 WHERE is_published=1
138 AND (title LIKE '%$esc%' OR summary LIKE '%$esc%'
139 OR keywords LIKE '%$esc%' OR body LIKE '%$esc%')
140 ORDER BY
141 (CASE
142 WHEN title LIKE '%$esc%' THEN 0
143 WHEN summary LIKE '%$esc%' THEN 1
144 WHEN keywords LIKE '%$esc%' THEN 2
145 ELSE 3
146 END), step_order ASC
147 LIMIT 30
148 ~, $ENV{SCRIPT_NAME}, __LINE__);
149}
150
151$db->db_disconnect($dbh);
152
153# Group rows by section for the overview.
154my @section_order = qw(getting_started organizing monetization reach operations optimization);
155my %section_title = (
156 getting_started => 'Getting started',
157 organizing => 'Organize your catalog',
158 monetization => 'Make money',
159 reach => 'Reach contacts',
160 operations => 'Day-to-day operations',
161 optimization => 'A/B & MVT testing',
162);
163my %by_section;
164foreach my $r (@rows) {
165 push @{ $by_section{$r->{section} || 'getting_started'} }, {
166 slug => _h($r->{slug}),
167 title => _h($r->{title}),
168 summary => _h($r->{summary} || ''),
169 icon => _h($r->{icon} || ''),
170 est => $r->{estimated_min} || 0,
171 is_seen => $r->{seen} ? 1 : 0,
172 };
173}
174my @sections;
175foreach my $s (@section_order) {
176 next unless $by_section{$s};
177 push @sections, {
178 slug => $s,
179 title => $section_title{$s} || ucfirst($s),
180 items => $by_section{$s},
181 count => scalar(@{ $by_section{$s} }),
182 };
183 delete $by_section{$s};
184}
185# Any sections not in the canonical order trail at the end.
186foreach my $s (sort keys %by_section) {
187 push @sections, {
188 slug => $s,
189 title => ucfirst($s),
190 items => $by_section{$s},
191 count => scalar(@{ $by_section{$s} }),
192 };
193}
194
195my @hit_rows;
196foreach my $h (@hits) {
197 push @hit_rows, {
198 slug => _h($h->{slug}),
199 title => _h_highlight($h->{title}, $search_q),
200 summary => _h_highlight($h->{summary} || '', $search_q),
201 section => _h($h->{section}),
202 icon => _h($h->{icon} || ''),
203 };
204}
205
206my $tvars = {
207 is_single => 0,
208 is_search => length $search_q ? 1 : 0,
209 search_q => _h($search_q),
210 sections => \@sections,
211 has_sections=> scalar(@sections) ? 1 : 0,
212 hits => \@hit_rows,
213 has_hits => scalar(@hit_rows) ? 1 : 0,
214 no_hits => (length $search_q && !scalar(@hit_rows)) ? 1 : 0,
215};
216
217print "Content-Type: text/html; charset=utf-8\nCache-Control: no-store, no-cache, must-revalidate, max-age=0\nPragma: no-cache\nExpires: 0\n\n";
218my $body = join('', $tfile->template('contactforge_tutorials.html', $tvars, $userinfo));
219$wrap->render({
220 userinfo => $userinfo, page_key => 'tutorials', title => 'Help Center', body => $body,
221});
222exit;
223
224sub _h {
225 my $s = shift // '';
226 $s =~ s/&/&amp;/g; $s =~ s/</&lt;/g; $s =~ s/>/&gt;/g;
227 $s =~ s/"/&quot;/g; $s =~ s/'/&#39;/g;
228 return $s;
229}
230
231# Tiny safe-markdown subset: **bold**, *italic*, `code`, [text](url),
232# - lists, numbered lists, blank-line paragraphs. Inline HTML is
233# allowed (the body field is admin-only). Escape Perl-template tokens
234# at the end so a stray $variable or @array in the prose doesn't blow
235# up the qq~~ eval.
236#
237# Tour-style extensions (the "make this tutorial pop" syntax):
238# [step]...[/step] wraps a side-by-side panel
239# [goto: /url | Open it] CTA button -- jumps user to that page
240# [frame: /url] live iframe of /url with browser chrome
241# [mock: type] inline hand-coded UI mock (limited types)
242#
243# Inside a [step] block, [frame:] / [mock:] go to the right column and
244# everything else (prose, bullets, [goto:]) goes to the left column.
245# Outside a step block, the markers render inline as the same widgets.
246sub _render_md {
247 my ($s) = @_;
248 return '' unless defined $s && length $s;
249
250 # Pull step blocks out first so they don't get mangled by the
251 # paragraph-splitting markdown pass. Each one becomes a placeholder
252 # we splice back in after the rest of the body is rendered.
253 my @step_html;
254 while ($s =~ s/\[step\]\s*(.*?)\s*\[\/step\]/__STEP_PH_${\ scalar(@step_html)}__/s) {
255 push @step_html, _render_step_block($1);
256 }
257
258 # Force ## / ### headings onto their own paragraphs (see same fix
259 # in _render_step_block for the rationale).
260 $s =~ s/^(#{2,3}\s[^\n]*)$/\n$1\n/mg;
261
262 my @paras = split /\r?\n\s*\r?\n/, $s;
263 my @out;
264 foreach my $p (@paras) {
265 $p =~ s/^\s+//; $p =~ s/\s+$//;
266 next unless length $p;
267 # Bare step placeholder gets rendered as a top-level block.
268 if ($p =~ /\A__STEP_PH_(\d+)__\z/) {
269 push @out, $step_html[$1] || '';
270 next;
271 }
272 # Numbered list?
273 if ($p =~ /\A\d+\.\s/) {
274 my @items = split /\r?\n(?=\d+\.\s)/, $p;
275 my @li;
276 foreach my $it (@items) {
277 $it =~ s/^\d+\.\s+//;
278 $it =~ s/\r?\n/ /g;
279 push @li, '<li>' . _inline($it) . '</li>';
280 }
281 push @out, '<ol>' . join('', @li) . '</ol>';
282 next;
283 }
284 # Bulleted list?
285 if ($p =~ /\A-\s/) {
286 my @items = split /\r?\n(?=-\s)/, $p;
287 my @li;
288 foreach my $it (@items) {
289 $it =~ s/^-\s+//;
290 $it =~ s/\r?\n/ /g;
291 push @li, '<li>' . _inline($it) . '</li>';
292 }
293 push @out, '<ul>' . join('', @li) . '</ul>';
294 next;
295 }
296 # Heading?
297 if ($p =~ /\A###\s+(.+)\z/) { push @out, '<h3>' . _inline($1) . '</h3>'; next; }
298 if ($p =~ /\A##\s+(.+)\z/) { push @out, '<h2>' . _inline($1) . '</h2>'; next; }
299 # Paragraph.
300 $p =~ s/\r?\n/<br>/g;
301 push @out, '<p>' . _inline($p) . '</p>';
302 }
303 my $html = join("\n", @out);
304 # Escape template-token characters so the embedded prose can't
305 # accidentally trigger Perl variable interpolation when this string
306 # is sandwiched into a qq~~ block by the template engine.
307 $html =~ s/\$/\\\$/g;
308 $html =~ s/\@/\\\@/g;
309 return $html;
310}
311
312# Render the inside of a [step]...[/step] block as a tour-style grid:
313# left column = prose + goto CTA, right column = frame iframe or mock.
314sub _render_step_block {
315 my ($body) = @_;
316 return '' unless defined $body && length $body;
317
318 # Pull the right-column visual (frame/mock) out before markdown.
319 my $right = '';
320 if ($body =~ s/\[frame:\s*([^\]]+?)\s*\]//) {
321 $right = _render_frame($1);
322 }
323 elsif ($body =~ s/\[mock:\s*([^\]]+?)\s*\]//) {
324 $right = _render_mock($1);
325 }
326
327 # Pull the CTA button. It lives at the bottom of the left column.
328 my $cta = '';
329 if ($body =~ s/\[goto:\s*([^|\]]+?)\s*\|\s*([^\]]+?)\s*\]//) {
330 my ($url, $label) = ($1, $2);
331 for ($url, $label) { s/^\s+//; s/\s+$//; }
332 $url = _h_attr($url);
333 $label = _h($label);
334 $cta = qq~<div style="margin-top:18px"><a href="$url" class="tour-cta-link" style="display:inline-flex;align-items:center;gap:8px;padding:10px 16px;background:linear-gradient(135deg,#7c3aed,#a855f7);color:#fff;border-radius:8px;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 6px 18px rgba(124,58,237,0.45)">$label <svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.5"><polyline points="9 18 15 12 9 6"/></svg></a></div>~;
335 }
336
337 # Force every ## / ### heading onto its own paragraph by
338 # surrounding it with blank lines. Without this, "### Title\nProse"
339 # stays in a single multi-line paragraph that no regex matches and
340 # the ### prefix renders literally. The /m flag makes ^/$ match at
341 # line boundaries.
342 $body =~ s/^(#{2,3}\s[^\n]*)$/\n$1\n/mg;
343
344 # The remaining body gets the same paragraph/markdown treatment as
345 # the rest of the tutorial. Wrap each paragraph appropriately.
346 my @paras = split /\r?\n\s*\r?\n/, $body;
347 my @lhs;
348 foreach my $p (@paras) {
349 $p =~ s/^\s+//; $p =~ s/\s+$//;
350 next unless length $p;
351 if ($p =~ /\A\d+\.\s/) {
352 my @items = split /\r?\n(?=\d+\.\s)/, $p;
353 push @lhs, '<ol class="tut-step-list">'
354 . join('', map {
355 (my $it = $_) =~ s/^\d+\.\s+//;
356 $it =~ s/\r?\n/ /g;
357 '<li>' . _inline($it) . '</li>'
358 } @items)
359 . '</ol>';
360 }
361 elsif ($p =~ /\A-\s/) {
362 my @items = split /\r?\n(?=-\s)/, $p;
363 push @lhs, '<ul class="tour-bullets">'
364 . join('', map {
365 (my $it = $_) =~ s/^-\s+//;
366 $it =~ s/\r?\n/ /g;
367 # Explicit width/height on the SVG element so it
368 # never balloons to viewport-fill when the
369 # cascaded CSS rule misses (a missing class on
370 # the wrapper or a flex-item with no intrinsic
371 # size lets the SVG grow huge).
372 '<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" width="16" height="16" style="flex-shrink:0;color:#4ade80;margin-top:3px"><polyline points="20 6 9 17 4 12"/></svg><span>' . _inline($it) . '</span></li>'
373 } @items)
374 . '</ul>';
375 }
376 elsif ($p =~ /\A###\s+(.+)\z/) { push @lhs, '<div class="tour-step">' . _inline($1) . '</div>'; }
377 elsif ($p =~ /\A##\s+(.+)\z/) { push @lhs, '<h2 class="tour-title">' . _inline($1) . '</h2>'; }
378 else {
379 $p =~ s/\r?\n/<br>/g;
380 push @lhs, '<p class="tour-lead">' . _inline($p) . '</p>';
381 }
382 }
383 my $left = join("\n", @lhs) . $cta;
384
385 if ($right) {
386 return qq~<section class="tut-step-section">
387 <div class="tut-step-grid">
388 <div class="tut-step-left">$left</div>
389 <div class="tut-step-right">$right</div>
390 </div>
391</section>~;
392 }
393 # No right-column visual? Render full-width.
394 return qq~<section class="tut-step-section tut-step-section-wide">$left</section>~;
395}
396
397# Browser-chrome frame wrapping a live iframe of the target URL. Same
398# origin so cookies/auth flow through; the iframe shows the actual
399# page state at view time.
400sub _render_frame {
401 my ($url) = @_;
402 $url =~ s/^\s+//; $url =~ s/\s+$//;
403 my $url_attr = _h_attr($url);
404 my $url_disp = _h('contactforge.com' . $url);
405 # The iframe is decorative -- it shows the rep WHAT the page
406 # looks like. The "Go to it" CTA next to it is how they actually
407 # navigate. pointer-events:none on the iframe (and tabindex=-1)
408 # blocks all interaction: clicks fall through, scroll wheel goes
409 # to the parent page, tab key skips it. A transparent overlay
410 # belt-and-suspenders catches anything pointer-events misses
411 # (some older browsers honor pointer-events differently on
412 # iframes than on regular elements).
413 return qq~<div class="tour-frame">
414 <div class="tour-frame-bar">
415 <span class="tour-frame-dot" style="background:#ef4444"></span>
416 <span class="tour-frame-dot" style="background:#fbbf24"></span>
417 <span class="tour-frame-dot" style="background:#22c55e"></span>
418 <span class="tour-frame-url">$url_disp</span>
419 </div>
420 <div class="tour-frame-body" style="padding:0;background:#0a0e1c;position:relative">
421 <iframe src="$url_attr?tut_embed=1" loading="lazy" tabindex="-1" aria-hidden="true" style="width:100%;height:380px;border:none;display:block;background:#0a0e1c;pointer-events:none"></iframe>
422 <div class="tut-frame-shield" aria-hidden="true" style="position:absolute;inset:0;background:transparent;cursor:default;z-index:2"></div>
423 </div>
424</div>~;
425}
426
427# Inline mock for cases where an iframe wouldn't read well (the rep
428# is shown a snippet of UI not currently live, etc.). Limited set --
429# expand as tutorials need it.
430sub _render_mock {
431 my ($type) = @_;
432 $type =~ s/[^a-z0-9_-]//g;
433
434 if ($type eq 'ab-test-button') {
435 return qq~<div class="tour-frame">
436 <div class="tour-frame-bar">
437 <span class="tour-frame-dot" style="background:#ef4444"></span>
438 <span class="tour-frame-dot" style="background:#fbbf24"></span>
439 <span class="tour-frame-dot" style="background:#22c55e"></span>
440 <span class="tour-frame-url">contactforge.com/dashboard.cgi - Pages tab</span>
441 </div>
442 <div class="tour-frame-body" style="padding:18px">
443 <div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;background:var(--col-surface-2);border:1px solid var(--col-border);border-radius:8px;font-size:13px">
444 <div><strong style="color:var(--col-text)">About 3DShawn</strong><div class="text-xs text-dim">/about &middot; Published</div></div>
445 <div style="display:flex;gap:6px">
446 <span style="background:transparent;border:1px solid var(--col-border);color:var(--col-text-2);padding:6px 10px;border-radius:6px;font-size:11px">View</span>
447 <span style="background:transparent;border:1px solid var(--col-border);color:var(--col-text-2);padding:6px 10px;border-radius:6px;font-size:11px">Edit</span>
448 <span style="background:transparent;border:1px solid rgba(124,58,237,0.55);color:#c4b5fd;padding:6px 10px;border-radius:6px;font-size:11px;font-weight:700">+ A/B test</span>
449 </div>
450 </div>
451 </div>
452</div>~;
453 }
454 return '';
455}
456
457sub _inline {
458 my ($s) = @_;
459 # Inline goto markers (when outside a step block) -- use {} as the
460 # s/// delimiter so the literal | inside the pattern doesn't clash
461 # with the Perl delimiter.
462 $s =~ s{\[goto:\s*([^|\]]+?)\s*\|\s*([^\]]+?)\s*\]}{<a href="$1" class="text-brand">$2 &rarr;</a>}g;
463 $s =~ s/\*\*([^*]+)\*\*/<strong>$1<\/strong>/g;
464 $s =~ s/(?<![*])\*([^*]+)\*(?![*])/<em>$1<\/em>/g;
465 $s =~ s/`([^`]+)`/<code>$1<\/code>/g;
466 $s =~ s/\[([^\]]+)\]\(([^)]+)\)/<a href="$2">$1<\/a>/g;
467 return $s;
468}
469
470# HTML-attribute escape for URLs going into href / src.
471sub _h_attr {
472 my ($s) = @_;
473 $s //= '';
474 $s =~ s/&/&amp;/g;
475 $s =~ s/"/&quot;/g;
476 $s =~ s/'/&#39;/g;
477 $s =~ s/</&lt;/g;
478 $s =~ s/>/&gt;/g;
479 return $s;
480}
481
482# Wrap matches of $needle inside $hay with <mark>. Escapes hay first.
483sub _h_highlight {
484 my ($hay, $needle) = @_;
485 my $e = _h($hay);
486 return $e unless defined $needle && length $needle;
487 my $pat = quotemeta(_h($needle));
488 $e =~ s|($pat)|<mark>$1</mark>|gi;
489 return $e;
490}
Keyboard: j next diff k previous diff g top G bottom r restore c compile-check ? help