← back to NationalPaperHangers
watch the work: YT + IG reels + TikTok embeds via partial across all 7 templates
a9f49d0dce8b645de03a94602ee177a41f147ce2 · 2026-05-06 11:17:18 -0700 · Steve
Files touched
A db/migrations/014_installer_social_videos.sqlA lib/social-embed.jsM public/css/public.cssM routes/public.jsM server.jsA views/partials/social-videos.ejsM views/public/installer-tpl-bilingue.ejsM views/public/installer-tpl-concierge.ejsM views/public/installer-tpl-editorial.ejsM views/public/installer-tpl-heritage.ejsM views/public/installer-tpl-studio.ejsM views/public/installer-tpl-trade-pro.ejsM views/public/installer.ejs
Diff
commit a9f49d0dce8b645de03a94602ee177a41f147ce2
Author: Steve <steve@designerwallcoverings.com>
Date: Wed May 6 11:17:18 2026 -0700
watch the work: YT + IG reels + TikTok embeds via partial across all 7 templates
---
db/migrations/014_installer_social_videos.sql | 19 ++++
lib/social-embed.js | 119 ++++++++++++++++++++++++++
public/css/public.css | 14 +++
routes/public.js | 7 +-
server.js | 9 +-
views/partials/social-videos.ejs | 48 +++++++++++
views/public/installer-tpl-bilingue.ejs | 3 +
views/public/installer-tpl-concierge.ejs | 2 +
views/public/installer-tpl-editorial.ejs | 2 +
views/public/installer-tpl-heritage.ejs | 2 +
views/public/installer-tpl-studio.ejs | 2 +
views/public/installer-tpl-trade-pro.ejs | 2 +
views/public/installer.ejs | 4 +-
13 files changed, 230 insertions(+), 3 deletions(-)
diff --git a/db/migrations/014_installer_social_videos.sql b/db/migrations/014_installer_social_videos.sql
new file mode 100644
index 0000000..f98dac5
--- /dev/null
+++ b/db/migrations/014_installer_social_videos.sql
@@ -0,0 +1,19 @@
+-- 014 — Social video handles for installer profiles.
+-- Lets installers surface YouTube installation videos + Instagram reels +
+-- TikTok content directly on their NPH profile. Three handles are stored:
+-- youtube_handle — channel handle (e.g. "@FromentalLondon" or "UCxxxx")
+-- tiktok_handle — username (without @)
+-- featured_video_urls — jsonb array of curated direct video URLs for the
+-- "Watch the work" section. Format:
+-- [{"url":"https://...", "platform":"youtube|instagram|tiktok|vimeo", "title":"..."}]
+--
+-- instagram_handle already exists from the WIA scrape pipeline.
+
+ALTER TABLE installers
+ ADD COLUMN IF NOT EXISTS youtube_handle text,
+ ADD COLUMN IF NOT EXISTS tiktok_handle text,
+ ADD COLUMN IF NOT EXISTS featured_video_urls jsonb DEFAULT '[]'::jsonb NOT NULL;
+
+CREATE INDEX IF NOT EXISTS idx_installers_has_video
+ ON installers ((jsonb_array_length(featured_video_urls)))
+ WHERE jsonb_array_length(featured_video_urls) > 0;
diff --git a/lib/social-embed.js b/lib/social-embed.js
new file mode 100644
index 0000000..e5c8d60
--- /dev/null
+++ b/lib/social-embed.js
@@ -0,0 +1,119 @@
+// Social-video embed helper.
+// Takes a URL and returns { platform, embedUrl, html } usable in an EJS view.
+// Platforms supported: YouTube, Instagram (reels/posts), TikTok, Vimeo.
+// Returns null if URL is unsupported or malformed — caller should fall through
+// to a generic link card.
+
+function parseYouTube(u) {
+ try {
+ const url = new URL(u);
+ let id = null;
+ if (url.hostname.endsWith('youtube.com') || url.hostname === 'www.youtube.com' || url.hostname === 'youtube.com') {
+ if (url.pathname === '/watch') id = url.searchParams.get('v');
+ else if (url.pathname.startsWith('/embed/')) id = url.pathname.split('/')[2];
+ else if (url.pathname.startsWith('/shorts/')) id = url.pathname.split('/')[2];
+ } else if (url.hostname === 'youtu.be') {
+ id = url.pathname.replace(/^\//, '').split('/')[0];
+ }
+ if (!id || !/^[A-Za-z0-9_-]{6,20}$/.test(id)) return null;
+ return id;
+ } catch { return null; }
+}
+
+function parseInstagram(u) {
+ try {
+ const url = new URL(u);
+ if (!url.hostname.endsWith('instagram.com')) return null;
+ // /p/<id>/, /reel/<id>/, /tv/<id>/
+ const m = url.pathname.match(/^\/(p|reel|tv)\/([A-Za-z0-9_-]+)/);
+ if (!m) return null;
+ return { type: m[1], id: m[2] };
+ } catch { return null; }
+}
+
+function parseTikTok(u) {
+ try {
+ const url = new URL(u);
+ if (!url.hostname.endsWith('tiktok.com')) return null;
+ // /@user/video/<id> or /v/<id>.html
+ let m = url.pathname.match(/\/video\/(\d{10,25})/);
+ if (m) return m[1];
+ m = url.pathname.match(/\/v\/(\d{10,25})/);
+ if (m) return m[1];
+ return null;
+ } catch { return null; }
+}
+
+function parseVimeo(u) {
+ try {
+ const url = new URL(u);
+ if (!url.hostname.endsWith('vimeo.com')) return null;
+ const m = url.pathname.match(/^\/(\d+)/);
+ return m ? m[1] : null;
+ } catch { return null; }
+}
+
+function escapeHtml(s) {
+ return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": '''
+ }[c]));
+}
+
+function embed(input) {
+ const url = typeof input === 'string' ? input : input?.url;
+ if (!url) return null;
+ const title = (typeof input === 'object' && input.title) ? input.title : '';
+
+ const yt = parseYouTube(url);
+ if (yt) {
+ return {
+ platform: 'youtube',
+ url,
+ embedUrl: `https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1`,
+ html: `<iframe class="social-embed yt" src="https://www.youtube-nocookie.com/embed/${yt}?rel=0&modestbranding=1" title="${escapeHtml(title) || 'Installation video'}" loading="lazy" allow="accelerometer; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+ }
+
+ const ig = parseInstagram(url);
+ if (ig) {
+ return {
+ platform: 'instagram',
+ url,
+ embedUrl: `https://www.instagram.com/${ig.type}/${ig.id}/embed/`,
+ html: `<iframe class="social-embed ig" src="https://www.instagram.com/${ig.type}/${ig.id}/embed/" title="${escapeHtml(title) || 'Instagram reel'}" loading="lazy" scrolling="no" allowtransparency="true" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+ }
+
+ const tt = parseTikTok(url);
+ if (tt) {
+ return {
+ platform: 'tiktok',
+ url,
+ embedUrl: `https://www.tiktok.com/embed/v2/${tt}`,
+ html: `<iframe class="social-embed tt" src="https://www.tiktok.com/embed/v2/${tt}" title="${escapeHtml(title) || 'TikTok'}" loading="lazy" allow="encrypted-media" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+ }
+
+ const vimeo = parseVimeo(url);
+ if (vimeo) {
+ return {
+ platform: 'vimeo',
+ url,
+ embedUrl: `https://player.vimeo.com/video/${vimeo}`,
+ html: `<iframe class="social-embed vimeo" src="https://player.vimeo.com/video/${vimeo}" title="${escapeHtml(title) || 'Installation video'}" loading="lazy" allow="autoplay; fullscreen; picture-in-picture" allowfullscreen referrerpolicy="strict-origin-when-cross-origin"></iframe>`
+ };
+ }
+
+ return null;
+}
+
+function embedAll(arrayOrJsonb) {
+ let arr = arrayOrJsonb;
+ if (typeof arr === 'string') {
+ try { arr = JSON.parse(arr); } catch { arr = []; }
+ }
+ if (!Array.isArray(arr)) return [];
+ return arr.map(embed).filter(Boolean);
+}
+
+module.exports = { embed, embedAll };
diff --git a/public/css/public.css b/public/css/public.css
index 6108a2a..6327daa 100644
--- a/public/css/public.css
+++ b/public/css/public.css
@@ -186,3 +186,17 @@
/* Card credential pip (tick 7) */
.card-creds { margin: 8px 0 0; font-size: 12px; color: var(--brass, #b8860b); display: flex; align-items: center; gap: 6px; }
.card-creds .cred-pip { font-size: 14px; }
+
+/* Watch-the-work section (tick: social-video) */
+.profile-videos { margin: 48px 0; }
+.video-embed-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 18px; }
+.video-embed-card { position: relative; aspect-ratio: 9/16; border-radius: 8px; overflow: hidden; background: var(--bg-alt); border: 1px solid var(--border); }
+.video-embed-card.video-youtube { aspect-ratio: 16/9; }
+.video-embed-card.video-vimeo { aspect-ratio: 16/9; }
+.video-embed-card iframe.social-embed { width: 100%; height: 100%; display: block; border: 0; }
+.video-platform-pill { position: absolute; top: 8px; right: 8px; background: rgba(0,0,0,0.7); color: #fff; padding: 3px 8px; font-size: 10px; letter-spacing: 0.08em; border-radius: 3px; pointer-events: none; }
+.social-handle-card { display: inline-flex; align-items: center; gap: 8px; padding: 10px 16px; border: 1px solid var(--border); border-radius: 999px; text-decoration: none; color: var(--fg); transition: border-color 0.15s, transform 0.15s; }
+.social-handle-card:hover { border-color: var(--brass, #b8860b); transform: translateY(-1px); }
+.social-handle-card .social-handle-icon { color: var(--brass, #b8860b); font-size: 11px; }
+.social-handle-card .social-handle-platform { font-size: 12px; color: var(--fg-muted); text-transform: uppercase; letter-spacing: 0.06em; }
+.social-handle-card .social-handle-name { font-weight: 500; font-size: 14px; }
diff --git a/routes/public.js b/routes/public.js
index babc42c..7d4253f 100644
--- a/routes/public.js
+++ b/routes/public.js
@@ -133,6 +133,11 @@ router.get('/installer/:slug', async (req, res, next) => {
[req.params.slug]
);
if (!installer) return res.status(404).render('public/404', { title: 'Not Found' });
+ // Resolve featured installation videos to embeddable iframes (YouTube /
+ // Instagram reels / TikTok / Vimeo). Fall through gracefully when no
+ // featured_video_urls — the EJS section just doesn't render.
+ const socialEmbed = require('../lib/social-embed');
+ const featuredVideos = socialEmbed.embedAll(installer.featured_video_urls);
const portfolio = await db.many(
`SELECT * FROM installer_portfolio WHERE installer_id = $1 ORDER BY display_order, id`,
[installer.id]
@@ -196,7 +201,7 @@ router.get('/installer/:slug', async (req, res, next) => {
: `${installer.business_name} · National Paper Hangers`,
metaDescription: `${installer.business_name}${cityState ? ' is a verified wallcovering installer in ' + cityState : ' — wallcovering installer'}.${matSummary ? ' Specializing in ' + matSummary + '.' : ''} Book a consultation on National Paper Hangers.`,
canonicalPath: `/installer/${installer.slug}`,
- installer, portfolio, reviews, acceptance, credentials
+ installer, portfolio, reviews, acceptance, credentials, featuredVideos
});
} catch (err) { next(err); }
});
diff --git a/server.js b/server.js
index a2c459e..e96c649 100644
--- a/server.js
+++ b/server.js
@@ -66,7 +66,14 @@ app.use(helmet({
'https://api.stripe.com', 'https://m.stripe.network', 'https://m.stripe.com'
],
// Stripe iframes (Elements card field, 3DS challenge, hCaptcha for radar)
- frameSrc: ["'self'", 'https://js.stripe.com', 'https://hooks.stripe.com'],
+ // + social-video embeds on installer profiles (YouTube nocookie / Instagram / TikTok).
+ frameSrc: [
+ "'self'",
+ 'https://js.stripe.com', 'https://hooks.stripe.com',
+ 'https://www.youtube.com', 'https://www.youtube-nocookie.com',
+ 'https://www.instagram.com', 'https://instagram.com',
+ 'https://www.tiktok.com'
+ ],
frameAncestors: ["'none'"],
formAction: ["'self'"],
baseUri: ["'self'"]
diff --git a/views/partials/social-videos.ejs b/views/partials/social-videos.ejs
new file mode 100644
index 0000000..9082b85
--- /dev/null
+++ b/views/partials/social-videos.ejs
@@ -0,0 +1,48 @@
+<%
+ // Watch-the-work partial — included by every installer template (legacy
+ // installer.ejs + 6 installer-tpl-*.ejs). Renders nothing when there's
+ // no embed-able URL and no IG/YT/TikTok handle.
+ var _hasEmbed = (typeof featuredVideos !== 'undefined' && Array.isArray(featuredVideos) && featuredVideos.length > 0);
+ var _hasHandle = installer && (installer.youtube_handle || installer.instagram_handle || installer.tiktok_handle);
+%>
+<% if (_hasEmbed || _hasHandle) { %>
+<section class="profile-videos" aria-labelledby="videos-heading">
+ <h2 class="section-title" id="videos-heading">Watch the work</h2>
+ <p class="muted" style="margin:-12px 0 18px;font-size:13px">Reels and videos from the studio's own channels — actual installations, not stock footage.</p>
+ <% if (_hasEmbed) { %>
+ <div class="video-embed-grid">
+ <% featuredVideos.forEach(function(v){ %>
+ <div class="video-embed-card video-<%= v.platform %>">
+ <%- v.html %>
+ <div class="video-platform-pill"><%= v.platform.toUpperCase() %></div>
+ </div>
+ <% }); %>
+ </div>
+ <% } %>
+ <% if (_hasHandle) { %>
+ <div class="social-handles" style="margin-top:24px;display:flex;gap:12px;flex-wrap:wrap">
+ <% if (installer.instagram_handle) { %>
+ <a href="https://instagram.com/<%= encodeURIComponent(installer.instagram_handle.replace(/^@/, '')) %>" target="_blank" rel="noopener" class="social-handle-card ig">
+ <span class="social-handle-icon" aria-hidden="true">▶</span>
+ <span class="social-handle-platform">Instagram reels</span>
+ <span class="social-handle-name">@<%= installer.instagram_handle.replace(/^@/, '') %></span>
+ </a>
+ <% } %>
+ <% if (installer.youtube_handle) { %>
+ <a href="https://www.youtube.com/<%= installer.youtube_handle.startsWith('@') ? installer.youtube_handle : '@' + installer.youtube_handle %>" target="_blank" rel="noopener" class="social-handle-card yt">
+ <span class="social-handle-icon" aria-hidden="true">▶</span>
+ <span class="social-handle-platform">YouTube installations</span>
+ <span class="social-handle-name"><%= installer.youtube_handle.startsWith('@') ? installer.youtube_handle : '@' + installer.youtube_handle %></span>
+ </a>
+ <% } %>
+ <% if (installer.tiktok_handle) { %>
+ <a href="https://www.tiktok.com/@<%= encodeURIComponent(installer.tiktok_handle.replace(/^@/, '')) %>" target="_blank" rel="noopener" class="social-handle-card tt">
+ <span class="social-handle-icon" aria-hidden="true">▶</span>
+ <span class="social-handle-platform">TikTok</span>
+ <span class="social-handle-name">@<%= installer.tiktok_handle.replace(/^@/, '') %></span>
+ </a>
+ <% } %>
+ </div>
+ <% } %>
+</section>
+<% } %>
diff --git a/views/public/installer-tpl-bilingue.ejs b/views/public/installer-tpl-bilingue.ejs
index 3cfc3b7..504b4f4 100644
--- a/views/public/installer-tpl-bilingue.ejs
+++ b/views/public/installer-tpl-bilingue.ejs
@@ -81,6 +81,9 @@
<h2><span data-lang="en">Ready to book <%= installer.business_name %>?</span><span data-lang="es">¿Listo para contratar a <%= installer.business_name %>?</span></h2>
<a href="/installer/<%= installer.slug %>/book"><span data-lang="en">Book a consultation</span><span data-lang="es">Solicitar consulta</span></a>
</section>
+
+ <%- include('../partials/social-videos') %>
+
</article>
<script>
(function(){
diff --git a/views/public/installer-tpl-concierge.ejs b/views/public/installer-tpl-concierge.ejs
index 2877dff..f70fa9f 100644
--- a/views/public/installer-tpl-concierge.ejs
+++ b/views/public/installer-tpl-concierge.ejs
@@ -58,5 +58,7 @@
<div class="cc-cta">
<a href="/installer/<%= installer.slug %>/book">Reserve a consultation</a>
</div>
+ <%- include('../partials/social-videos') %>
+
</article>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-editorial.ejs b/views/public/installer-tpl-editorial.ejs
index cfb426e..e8b5b30 100644
--- a/views/public/installer-tpl-editorial.ejs
+++ b/views/public/installer-tpl-editorial.ejs
@@ -76,6 +76,8 @@
</section>
<% } %>
+ <%- include('../partials/social-videos') %>
+
<section class="ed-cta">
<h2>Book <%= installer.business_name %></h2>
<a class="btn-ed" href="/installer/<%= installer.slug %>/book">Open the calendar →</a>
diff --git a/views/public/installer-tpl-heritage.ejs b/views/public/installer-tpl-heritage.ejs
index f503061..e29c415 100644
--- a/views/public/installer-tpl-heritage.ejs
+++ b/views/public/installer-tpl-heritage.ejs
@@ -86,5 +86,7 @@
<h2>Commission a project</h2>
<a href="/installer/<%= installer.slug %>/book">Open the consultation calendar</a>
</section>
+ <%- include('../partials/social-videos') %>
+
</article>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-studio.ejs b/views/public/installer-tpl-studio.ejs
index c02e9fe..970853f 100644
--- a/views/public/installer-tpl-studio.ejs
+++ b/views/public/installer-tpl-studio.ejs
@@ -86,5 +86,7 @@
<h2>Bring <%= installer.business_name %> on the next project</h2>
<a href="/installer/<%= installer.slug %>/book">Book a consultation</a>
</section>
+ <%- include('../partials/social-videos') %>
+
</article>
<%- include('../partials/footer') %>
diff --git a/views/public/installer-tpl-trade-pro.ejs b/views/public/installer-tpl-trade-pro.ejs
index c87bf33..745dba6 100644
--- a/views/public/installer-tpl-trade-pro.ejs
+++ b/views/public/installer-tpl-trade-pro.ejs
@@ -90,5 +90,7 @@
<p style="margin:0 0 18px;letter-spacing:0.15em;text-transform:uppercase;font-size:13px">Specifying with <%= installer.business_name %>?</p>
<a href="/installer/<%= installer.slug %>/book">Open the work calendar →</a>
</section>
+ <%- include('../partials/social-videos') %>
+
</article>
<%- include('../partials/footer') %>
diff --git a/views/public/installer.ejs b/views/public/installer.ejs
index 9bdbb8b..88f38dc 100644
--- a/views/public/installer.ejs
+++ b/views/public/installer.ejs
@@ -221,6 +221,8 @@
</section>
<% } %>
+ <%- include('../partials/social-videos') %>
+
<% if (reviews.length > 0) { %>
<section class="profile-reviews">
<h2 class="section-title">Verified reviews</h2>
@@ -257,7 +259,7 @@
postalCode: installer.zip || undefined,
addressCountry: installer.country || 'US'
},
- knowsAbout: (installer.materials || []).map(function(m){ return m.replace(/_/g,' '); }),
+ knowsAbout: (installer.materials || []).map(function(m){ return String(m || '').replace(/_/g,' '); }).filter(Boolean),
hasCredential: (installer.accreditations || [])
};
if (safeSiteUrl) _ld.sameAs = [safeSiteUrl];
← f240b01 auth: regenerate session on OAuth sign-in (fixation defense
·
back to NationalPaperHangers
·
claude-codex r3: fix null-element crash in 4 templates, sess 4ecde9c →