added on local at 2026-07-01 21:47:29
| 1 | #!/usr/bin/perl | |
| 2 | #====================================================================== | |
| 3 | # RePricer -- Help Center (creator-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 | #====================================================================== | |
| 9 | use strict; | |
| 10 | use warnings; | |
| 11 | ||
| 12 | use lib '/var/www/vhosts/3dshawn.com/repricer.3dshawn.com'; | |
| 13 | use CGI; | |
| 14 | use MODS::Template; | |
| 15 | use MODS::DBConnect; | |
| 16 | use MODS::Login; | |
| 17 | use MODS::RePricer::Config; | |
| 18 | use MODS::RePricer::Wrapper; | |
| 19 | ||
| 20 | $| = 1; | |
| 21 | ||
| 22 | my $q = CGI->new; | |
| 23 | my $form = $q->Vars; | |
| 24 | my $auth = MODS::Login->new; | |
| 25 | my $tfile = MODS::Template->new; | |
| 26 | my $db = MODS::DBConnect->new; | |
| 27 | my $cfg = MODS::RePricer::Config->new; | |
| 28 | my $wrap = MODS::RePricer::Wrapper->new; | |
| 29 | my $DB = $cfg->settings('database_name'); | |
| 30 | ||
| 31 | my $userinfo = $auth->login_verify(); | |
| 32 | unless ($userinfo) { print "Status: 302 Found\nLocation: /login.cgi\n\n"; exit; } | |
| 33 | my $uid = $userinfo->{user_id}; $uid =~ s/[^0-9]//g; | |
| 34 | ||
| 35 | my $dbh = $db->db_connect(); | |
| 36 | ||
| 37 | # Schema probe -- the tutorials table is new. | |
| 38 | my $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 | } | |
| 46 | unless ($have_tut) { | |
| 47 | $db->db_disconnect($dbh); | |
| 48 | 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"; | |
| 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> § 5.15c, then refresh.</p> | |
| 54 | </div></div>~, | |
| 55 | }); | |
| 56 | exit; | |
| 57 | } | |
| 58 | ||
| 59 | # Route 1: single-tutorial view (?slug=...). | |
| 60 | my $slug = $form->{slug} || ''; | |
| 61 | $slug =~ s/[^a-z0-9\-_]//g; | |
| 62 | ||
| 63 | # Route 2: search (?q=...). | |
| 64 | my $search_q = $form->{q} || ''; | |
| 65 | $search_q =~ s/^\s+|\s+$//g; | |
| 66 | $search_q = substr($search_q, 0, 120); | |
| 67 | ||
| 68 | if (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('repricer_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. | |
| 116 | my @rows; | |
| 117 | my @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). | |
| 131 | my @hits; | |
| 132 | if (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. | |
| 154 | my @section_order = qw(getting_started organizing monetization reach operations optimization); | |
| 155 | my %section_title = ( | |
| 156 | getting_started => 'Getting started', | |
| 157 | organizing => 'Organize your catalog', | |
| 158 | monetization => 'Make money', | |
| 159 | reach => 'Reach buyers', | |
| 160 | operations => 'Day-to-day operations', | |
| 161 | optimization => 'A/B & MVT testing', | |
| 162 | ); | |
| 163 | my %by_section; | |
| 164 | foreach 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 | } | |
| 174 | my @sections; | |
| 175 | foreach 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. | |
| 186 | foreach 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 | ||
| 195 | my @hit_rows; | |
| 196 | foreach 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 | ||
| 206 | my $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 | ||
| 217 | 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"; | |
| 218 | my $body = join('', $tfile->template('repricer_tutorials.html', $tvars, $userinfo)); | |
| 219 | $wrap->render({ | |
| 220 | userinfo => $userinfo, page_key => 'tutorials', title => 'Help Center', body => $body, | |
| 221 | }); | |
| 222 | exit; | |
| 223 | ||
| 224 | sub _h { | |
| 225 | my $s = shift // ''; | |
| 226 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 227 | $s =~ s/"/"/g; $s =~ s/'/'/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. | |
| 246 | sub _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. | |
| 314 | sub _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,#0f766e,#a855f7);color:#fff;border-radius:8px;font-weight:700;font-size:13px;text-decoration:none;box-shadow:0 6px 18px rgba(15,118,110,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. | |
| 400 | sub _render_frame { | |
| 401 | my ($url) = @_; | |
| 402 | $url =~ s/^\s+//; $url =~ s/\s+$//; | |
| 403 | my $url_attr = _h_attr($url); | |
| 404 | my $url_disp = _h('repricer.com' . $url); | |
| 405 | # The iframe is decorative -- it shows the seller 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 seller | |
| 428 | # is shown a snippet of UI not currently live, etc.). Limited set -- | |
| 429 | # expand as tutorials need it. | |
| 430 | sub _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">repricer.com/products.cgi - Listings 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 · 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(15,118,110,0.55);color:#6ee7b7;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 | ||
| 457 | sub _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 →</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. | |
| 471 | sub _h_attr { | |
| 472 | my ($s) = @_; | |
| 473 | $s //= ''; | |
| 474 | $s =~ s/&/&/g; | |
| 475 | $s =~ s/"/"/g; | |
| 476 | $s =~ s/'/'/g; | |
| 477 | $s =~ s/</</g; | |
| 478 | $s =~ s/>/>/g; | |
| 479 | return $s; | |
| 480 | } | |
| 481 | ||
| 482 | # Wrap matches of $needle inside $hay with <mark>. Escapes hay first. | |
| 483 | sub _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 | } |