Merge pull request 'Feat/ghost integration' (#1) from feat/ghost-integration into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Ghost CMS — read at build time only (Node), never bundled into client JS.
|
||||
# Copy to .env for local dev; set the same vars in the Vercel project settings.
|
||||
|
||||
# Base URL of the Ghost instance, no trailing slash.
|
||||
GHOST_API_URL=https://your-site.ghost.io
|
||||
|
||||
# Ghost Content API key (read-only). Ghost Admin → Settings → Integrations.
|
||||
GHOST_CONTENT_API_KEY=your_content_api_key
|
||||
|
||||
# Public origin of this site, used for canonical URLs and Article schema.
|
||||
SITE_URL=https://lynkeduppro.com
|
||||
@@ -41,6 +41,8 @@ pnpm-debug.log*
|
||||
.pnp.*
|
||||
.vercel
|
||||
.env*
|
||||
# Re-included last: the .env* rule above would otherwise ignore the template.
|
||||
!.env.example
|
||||
|
||||
# Playwright
|
||||
/test-results/
|
||||
@@ -49,3 +51,4 @@ pnpm-debug.log*
|
||||
/playwright/.cache/
|
||||
|
||||
.vercel
|
||||
.env*.local
|
||||
|
||||
Submodule
+1
Submodule CampaignX_Sigil added at 2a984adfb3
@@ -0,0 +1,8 @@
|
||||
# Post pages generated from Ghost at build time (see src/ghost/build.ts).
|
||||
# Ignore every post directory, then re-include the hand-authored ones.
|
||||
/*/
|
||||
!/cost-of-paperwork/
|
||||
!/48-hour-storm-checklist/
|
||||
!/why-claims-get-denied/
|
||||
|
||||
.ghost-generated.json
|
||||
@@ -31,16 +31,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../../index.html#product">Product</a>
|
||||
@@ -51,7 +42,7 @@
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="../../index.html#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -151,7 +142,7 @@
|
||||
<h2>Get <em>hail alerts</em> for your territory.</h2>
|
||||
<p>Radar-verified swaths land in your inbox the morning after the storm, so your crews are the first ones knocking.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="../../index.html#book">Book a demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="../../index.html#intel">See the intelligence map</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,14 +263,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -299,7 +286,7 @@
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="../../index.html#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../../index.html#book">Careers</a>
|
||||
<a href="../../index.html#book">Contact</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
{{HEAD}}
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M8.8 6 3.6 9v6l5.2 3 5.2-3V9z' fill='none' stroke='%232EC5DD' stroke-width='1.8'/%3E%3Cpath d='M15.2 6 10 9v6l5.2 3 5.2-3V9z' fill='none' stroke='%23FFB300' stroke-width='1.8'/%3E%3C/svg%3E">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../../index.html#product">Product</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="../../index.html#intel">Intelligence</a>
|
||||
<a href="../../index.html#pricing">Pricing</a>
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="mprog" aria-hidden="true"><i id="mprogBar"></i></div>
|
||||
|
||||
<nav class="rail" id="rail" aria-label="Section index">
|
||||
<div class="rail-track" id="railTrack">
|
||||
<div class="rail-carriage" id="railCar"><span class="rail-read" id="railRead"></span></div>
|
||||
</div>
|
||||
</nav>
|
||||
<main id="top">
|
||||
|
||||
<!-- ============ POST HEADER ============ -->
|
||||
<div class="page-hero" id="intro" data-rail="01 · INTRO">
|
||||
<div class="wrap">
|
||||
<div class="dim-rule reveal" aria-hidden="true"><i class="l"></i><span>{{RAIL_LABEL}}</span><i class="r"></i></div>
|
||||
<div class="sec-head reveal">
|
||||
<span class="tag">{{TAG_LABEL}}</span>
|
||||
<h1>{{TITLE}}</h1>
|
||||
<p class="post-meta">
|
||||
{{POST_META}}
|
||||
</p>
|
||||
</div>
|
||||
{{COVER}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="post-body reveal">
|
||||
{{CONTENT}}
|
||||
</div>
|
||||
|
||||
{{TAGS}}
|
||||
|
||||
<div class="founder-card terms-cta reveal">
|
||||
<span class="stamp-tag ok">✓ SEE IT ON YOUR OWN NUMBERS</span>
|
||||
<h2>Run the <em>same comparison</em> on your last job.</h2>
|
||||
<p>Book a 30-minute walkthrough and we'll build the before/after timeline using one of your own recent files.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="../../index.html#chaos">See the Cost of Chaos</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="post-foot-nav">
|
||||
{{FOOT_NAV}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<svg class="skyline2" viewBox="0 0 1440 150" preserveAspectRatio="xMidYMax slice" aria-hidden="true">
|
||||
<g class="sk-ln">
|
||||
<line x1="0" y1="148" x2="1440" y2="148"/>
|
||||
<g>
|
||||
<rect x="52" y="78" width="86" height="70"/>
|
||||
<polygon points="47,78 95,52 143,78"/>
|
||||
<rect x="88" y="122" width="16" height="26"/>
|
||||
<rect class="w-on" x="62" y="88" width="10" height="12"/>
|
||||
<rect class="w-off" x="80" y="88" width="10" height="12"/>
|
||||
<rect class="w-on w-tw" x="112" y="88" width="10" height="12"/>
|
||||
<rect class="w-off" x="62" y="112" width="10" height="12"/>
|
||||
<rect class="w-on" x="112" y="112" width="10" height="12"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="186" y="104" width="112" height="44"/>
|
||||
<polygon points="180,104 242,80 304,104"/>
|
||||
<rect x="222" y="68" width="12" height="24"/>
|
||||
<rect class="w-off" x="198" y="114" width="11" height="12"/>
|
||||
<rect class="w-on" x="222" y="114" width="11" height="12"/>
|
||||
<rect class="w-off" x="246" y="114" width="11" height="12"/>
|
||||
<rect x="272" y="118" width="14" height="30"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="340" y1="148" x2="340" y2="102"/>
|
||||
<line x1="340" y1="102" x2="352" y2="102"/>
|
||||
<circle class="lamp" cx="354" cy="104" r="3"/>
|
||||
<circle class="lamp-halo" cx="354" cy="106" r="10"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="392" y="66" width="64" height="82"/>
|
||||
<polygon points="388,66 424,44 460,66"/>
|
||||
<rect class="w-on" x="402" y="76" width="10" height="12"/>
|
||||
<rect class="w-off" x="432" y="76" width="10" height="12"/>
|
||||
<rect class="w-off" x="402" y="102" width="10" height="12"/>
|
||||
<rect class="w-on w-tw2" x="432" y="102" width="10" height="12"/>
|
||||
<rect x="416" y="124" width="15" height="24"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="512" y="96" width="96" height="52"/>
|
||||
<polygon points="506,96 534,74 586,74 614,96"/>
|
||||
<rect class="w-on" x="524" y="108" width="11" height="12"/>
|
||||
<rect class="w-on" x="548" y="108" width="11" height="12"/>
|
||||
<rect class="w-off" x="572" y="108" width="11" height="12"/>
|
||||
<rect x="616" y="112" width="52" height="36"/>
|
||||
<polygon points="612,112 642,96 672,112"/>
|
||||
<rect class="w-off" x="630" y="122" width="24" height="20"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="712" y1="148" x2="712" y2="120"/>
|
||||
<circle cx="712" cy="106" r="16"/>
|
||||
<circle cx="700" cy="116" r="10"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="762" y="72" width="110" height="76"/>
|
||||
<polygon points="756,72 817,46 878,72"/>
|
||||
<line x1="758" y1="120" x2="876" y2="120"/>
|
||||
<line x1="770" y1="120" x2="770" y2="148"/>
|
||||
<line x1="817" y1="120" x2="817" y2="148"/>
|
||||
<line x1="864" y1="120" x2="864" y2="148"/>
|
||||
<rect class="w-on" x="774" y="82" width="10" height="12"/>
|
||||
<rect class="w-on" x="798" y="82" width="10" height="12"/>
|
||||
<rect class="w-off" x="826" y="82" width="10" height="12"/>
|
||||
<rect class="w-on" x="850" y="82" width="10" height="12"/>
|
||||
<rect class="w-off" x="774" y="128" width="10" height="12"/>
|
||||
<rect class="w-on w-tw" x="850" y="128" width="10" height="12"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="918" y="116" width="44" height="32"/>
|
||||
<polygon points="914,116 940,102 966,116"/>
|
||||
<rect class="w-off" x="932" y="124" width="16" height="16"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="1006" y1="148" x2="1006" y2="102"/>
|
||||
<line x1="1006" y1="102" x2="1018" y2="102"/>
|
||||
<circle class="lamp" cx="1020" cy="104" r="3"/>
|
||||
<circle class="lamp-halo" cx="1020" cy="106" r="10"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1058" y="92" width="84" height="56"/>
|
||||
<polygon points="1053,92 1100,66 1147,92"/>
|
||||
<rect class="w-on" x="1070" y="102" width="10" height="12"/>
|
||||
<rect class="w-off" x="1094" y="102" width="10" height="12"/>
|
||||
<rect class="w-on" x="1118" y="102" width="10" height="12"/>
|
||||
<rect x="1094" y="124" width="14" height="24"/>
|
||||
</g>
|
||||
<g>
|
||||
<rect x="1198" y="80" width="92" height="68"/>
|
||||
<polygon points="1192,80 1244,54 1296,80"/>
|
||||
<rect x="1258" y="42" width="12" height="26"/>
|
||||
<rect class="w-off" x="1210" y="92" width="10" height="12"/>
|
||||
<rect class="w-on w-tw2" x="1234" y="92" width="10" height="12"/>
|
||||
<rect class="w-on" x="1258" y="92" width="10" height="12"/>
|
||||
<rect class="w-off" x="1210" y="118" width="10" height="12"/>
|
||||
<rect class="w-on" x="1258" y="118" width="10" height="12"/>
|
||||
</g>
|
||||
<g>
|
||||
<line x1="1352" y1="148" x2="1352" y2="118"/>
|
||||
<circle cx="1352" cy="102" r="18"/>
|
||||
<circle cx="1368" cy="114" r="11"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
<div class="wrap">
|
||||
<div class="foot-glass">
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
<a href="../../index.html" aria-label="YouTube"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3" y="6" width="18" height="12" rx="3.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.4 9.4v5.2l4.6-2.6Z" fill="currentColor"/></svg></a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-col">
|
||||
<b>Platform</b>
|
||||
<a href="../../index.html#hero">Instant estimate</a>
|
||||
<a href="../../index.html#intel">Intelligence map</a>
|
||||
<a href="../../index.html#product">For contractors</a>
|
||||
<a href="../../index.html#industries">For homeowners</a>
|
||||
<a href="../../index.html#pricing">Pricing</a>
|
||||
</div>
|
||||
<div class="foot-col">
|
||||
<b>Company</b>
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../../index.html#book">Careers</a>
|
||||
<a href="../../index.html#book">Contact</a>
|
||||
</div>
|
||||
<div class="foot-col">
|
||||
<b>Resources & legal</b>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
<a href="../../index.html">Help center</a>
|
||||
<a href="../../index.html">Security</a>
|
||||
<a href="../../index.html">Privacy policy</a>
|
||||
<a href="../../terms/">Terms of service</a>
|
||||
<a href="../../index.html">Drone flight safety</a>
|
||||
</div>
|
||||
</div>
|
||||
<form class="foot-alerts" id="zipForm">
|
||||
<div class="fa-copy">
|
||||
<b>Hail alerts for your territory</b>
|
||||
<span class="tag">Radar-verified swaths for your ZIP, the morning after the storm</span>
|
||||
</div>
|
||||
<div class="fa-row">
|
||||
<input id="zipInput" inputmode="numeric" autocomplete="postal-code" maxlength="5" placeholder="75248" aria-label="ZIP code">
|
||||
<button class="btn solid" type="submit">Watch my ZIP</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="foot-base">
|
||||
<span class="tag">© 2026 LynkedUp Pro · All rights reserved · Powered by LynkedUp Technologies</span>
|
||||
<span class="tag">Zero-trust architecture · data encrypted at rest and in transit</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
<script type="module" src="../../src/blog.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -31,16 +31,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../../index.html#product">Product</a>
|
||||
@@ -51,7 +42,7 @@
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="../../index.html#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -152,7 +143,7 @@
|
||||
<h2>Run the <em>same comparison</em> on your last job.</h2>
|
||||
<p>Book a 30-minute walkthrough and we'll build the before/after timeline using one of your own recent files.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="../../index.html#book">Book a demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="../../index.html#chaos">See the Cost of Chaos</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -273,14 +264,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -300,7 +287,7 @@
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="../../index.html#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../../index.html#book">Careers</a>
|
||||
<a href="../../index.html#book">Contact</a>
|
||||
</div>
|
||||
|
||||
+12
-53
@@ -22,16 +22,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../index.html#product">Product</a>
|
||||
@@ -42,7 +33,7 @@
|
||||
<a href="../blog/">Blog</a>
|
||||
<a href="../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="../index.html#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -70,41 +61,13 @@
|
||||
<div class="wrap">
|
||||
<div class="blog-grid">
|
||||
|
||||
<a class="feat blog-card reveal" href="./cost-of-paperwork/" aria-label="Read: The Real Cost of Paperwork">
|
||||
<div class="feat-media">
|
||||
<img class="feat-thumb" src="../img/props/Storm_Worn_Roof.jpg" alt="Weathered roof shingles after storm exposure" loading="lazy" width="900" height="510">
|
||||
<span class="cap">OPERATIONS · JUN 2, 2026 · 6 MIN READ</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>The Real Cost of Paperwork: Why Contractors Lose Margin Before the Job Even Starts</h3>
|
||||
<p>Every manual follow-up, delayed estimate, and missing document chips away at the number that actually matters. Here's exactly where the 34% goes.</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>
|
||||
<!-- Every card here comes from Ghost (tag: lynkeduppro-new), injected at
|
||||
build time by the ghost-listing plugin in vite.config.ts.
|
||||
|
||||
<a class="feat blog-card reveal" href="./48-hour-storm-checklist/" aria-label="Read: The First 48 Hours After Hail">
|
||||
<div class="feat-media">
|
||||
<img class="feat-thumb" src="../img/props/Hail_Damaged_Shingles.jpg" alt="Close up of hail damaged shingles" loading="lazy" width="900" height="510">
|
||||
<span class="cap">STORM RESPONSE · JUN 9, 2026 · 7 MIN READ</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>The First 48 Hours After Hail: A Field Checklist That Protects the Claim</h3>
|
||||
<p>The window between "storm passed" and "adjuster arrives" decides whether your evidence holds up. Here's exactly what to capture, and in what order.</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>
|
||||
|
||||
<a class="feat blog-card reveal" href="./why-claims-get-denied/" aria-label="Read: Why Adjusters Reject 40 Percent of Roof Claims">
|
||||
<div class="feat-media">
|
||||
<img class="feat-thumb" src="../img/props/Broken_Roof_Surface.jpg" alt="Damaged roof membrane surface" loading="lazy" width="900" height="510">
|
||||
<span class="cap">CLAIMS · JUN 16, 2026 · 5 MIN READ</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>Why Adjusters Reject 40% of Roof Claims (And How to Fix It)</h3>
|
||||
<p>"Lack of maintenance" is the single most common denial reason in the industry — and it's almost always a documentation problem, not a roof problem.</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>
|
||||
The hand-authored posts under blog/<slug>/ are intentionally NOT listed
|
||||
here any more. Their pages are still built and their URLs still work —
|
||||
they are just no longer linked from this grid. -->
|
||||
<!-- GHOST_CARDS -->
|
||||
|
||||
</div>
|
||||
<p class="blog-foot-note tag reveal">MORE FIELD NOTES ADDED REGULARLY</p>
|
||||
@@ -221,14 +184,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -248,7 +207,7 @@
|
||||
<a href="../index.html#founder">About</a>
|
||||
<a href="../blog/">Blog</a>
|
||||
<a href="../index.html#chaos">Why it works</a>
|
||||
<a href="../index.html#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../index.html#book">Careers</a>
|
||||
<a href="../index.html#book">Contact</a>
|
||||
</div>
|
||||
|
||||
@@ -31,16 +31,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../../index.html#product">Product</a>
|
||||
@@ -51,7 +42,7 @@
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="../../index.html#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -150,7 +141,7 @@
|
||||
<h2>Stop building the file <em>after</em> the denial.</h2>
|
||||
<p>See how a phone-based scan builds a claim-ready record on day one, before there's ever a dispute to fight.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="../../index.html#book">Book a demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="../../index.html#product">See the product</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,14 +262,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="../../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -298,7 +285,7 @@
|
||||
<a href="../../index.html#founder">About</a>
|
||||
<a href="../../blog/">Blog</a>
|
||||
<a href="../../index.html#chaos">Why it works</a>
|
||||
<a href="../../index.html#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../../index.html#book">Careers</a>
|
||||
<a href="../../index.html#book">Contact</a>
|
||||
</div>
|
||||
|
||||
@@ -37,16 +37,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="./index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="./img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="./index.html#product">Product</a>
|
||||
@@ -57,7 +48,7 @@
|
||||
<a href="./blog/">Blog</a>
|
||||
<a href="./faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="./index.html#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -462,7 +453,7 @@
|
||||
<h2>Bring your <em>real questions.</em></h2>
|
||||
<p>Get a 30-minute walkthrough of the platform, or a 1-hour buildout call if your workflow needs custom work.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="./index.html#book">Book a demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="tel:8662596533">Call (866) 259-6533</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -579,14 +570,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="./index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="./img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="./index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="./index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -606,7 +593,7 @@
|
||||
<a href="./index.html#founder">About</a>
|
||||
<a href="./blog/">Blog</a>
|
||||
<a href="./index.html#chaos">Why it works</a>
|
||||
<a href="./index.html#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="./index.html#book">Careers</a>
|
||||
<a href="./index.html#book">Contact</a>
|
||||
</div>
|
||||
|
||||
+51
-27
@@ -53,16 +53,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="#top" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="./img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="#product">Product</a>
|
||||
@@ -73,7 +64,7 @@
|
||||
<a href="./blog/">Blog</a>
|
||||
<a href="./faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -102,7 +93,7 @@
|
||||
built to keep every job moving <strong>and every dollar accounted for.</strong>
|
||||
</p>
|
||||
<div class="hero-ctas">
|
||||
<a class="btn solid" href="#book">Book a demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="#pricing">See pricing</a>
|
||||
</div>
|
||||
<div class="hero-proof">
|
||||
@@ -923,14 +914,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="faq-note disc-note reveal" id="disclaimer"><p><strong>Disclaimer:</strong> All claims, results, performance statements, platform capabilities, and customer references are provided for informational and promotional purposes only. Claims may be based on a combination of internal testing, platform development data, field-tested data, and feedback from founding users. Individual results may vary based on company size, user adoption, data quality, market conditions, workflow setup, and other business-specific factors.</p>
|
||||
<p>Some user names, company names, examples, or identifying details may have been changed, modified, or withheld to protect user privacy, confidential business information, proprietary workflows, and trade secrets.</p>
|
||||
<p>The Lifetime Founders License applies to one user license and one individual user account unless otherwise stated in writing. The Founders License includes access to core CRM features and the platform features made available under the applicable license terms. The Lifetime Founders License does not guarantee access to every future product, premium module, third-party integration, usage-based service, or separately priced feature.</p>
|
||||
<p>SMS usage, data usage, storage, GPU processing, AI compute, automation volume, messaging volume, third-party API costs, and other usage-based services may incur additional monthly charges. These charges may be billed separately based on actual usage, service level, selected features, or third-party provider costs.</p>
|
||||
<p>Advanced or specialized features, including but not limited to Campaign-X, LiDAR scans, digital twin technology, AI-generated content, 3D property scans, mapping tools, premium automation, and other enhanced modules, may require additional fees, setup costs, subscriptions, or usage-based billing.</p>
|
||||
<p>Features, pricing, availability, and platform capabilities are subject to change. No statement shall be interpreted as a guarantee of revenue, cost savings, insurance savings, operational results, business growth, or specific customer outcomes. Use of the platform is subject to the final written agreement, subscription terms, billing terms, and applicable service terms provided by Lynked Up Technologies.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============ KEY TERMS · CAROUSEL ============ -->
|
||||
<section id="terms">
|
||||
<div class="wrap">
|
||||
<span class="sheet num" aria-hidden="true">12</span>
|
||||
<div class="dim-rule reveal" aria-hidden="true"><i class="l"></i><span>SEC 12 · GLOSSARY</span><i class="r"></i></div>
|
||||
<span class="sheet num" aria-hidden="true">13</span>
|
||||
<div class="dim-rule reveal" aria-hidden="true"><i class="l"></i><span>SEC 13 · GLOSSARY</span><i class="r"></i></div>
|
||||
<div class="sec-head reveal">
|
||||
<span class="tag">Key terms, explained simply</span>
|
||||
<h2>No jargon. <em>Just what it means</em> for your business.</h2>
|
||||
@@ -996,7 +995,7 @@
|
||||
<section class="final" id="book">
|
||||
<canvas class="cta-fx" id="ctaFx" aria-hidden="true"></canvas>
|
||||
<div class="wrap">
|
||||
<div class="dim-rule reveal" aria-hidden="true"><i class="l"></i><span>SEC 13 · BOOK</span><i class="r"></i></div>
|
||||
<div class="dim-rule reveal" aria-hidden="true"><i class="l"></i><span>SEC 14 · BOOK</span><i class="r"></i></div>
|
||||
<div class="faq reveal" aria-label="Frequently asked questions">
|
||||
<details>
|
||||
<summary>Do I need a drone?<i aria-hidden="true"></i></summary>
|
||||
@@ -1093,12 +1092,41 @@
|
||||
<p>Whether you protect one home or manage a thousand roofs. We run your address live:
|
||||
mesh, measurements, and a signature-ready proposal before the call ends.</p>
|
||||
<form class="cta-form" id="ctaForm">
|
||||
<input id="ctaAddr" type="text" autocomplete="off" spellcheck="false"
|
||||
placeholder="Your address, e.g. 950 Ridgeline Rd, McKinney TX" aria-label="Address to estimate">
|
||||
<button class="btn solid" type="submit">Estimate it now</button>
|
||||
<select id="ctaAddr" aria-label="Pick a sample address to estimate"
|
||||
data-area="3400" data-squares="37" data-pitch="6:12" data-material="Architectural shingle" data-low="14200" data-high="16800">
|
||||
<option value="" data-placeholder="1">Pick a sample address…</option>
|
||||
<option value="950 Ridgeline Rd, McKinney TX"
|
||||
data-area="3400" data-squares="37" data-pitch="6:12" data-material="Architectural shingle" data-low="14200" data-high="16800" selected>950 Ridgeline Rd, McKinney TX</option>
|
||||
<option value="1420 Maple Grove Dr, Frisco TX"
|
||||
data-area="2650" data-squares="29" data-pitch="5:12" data-material="Architectural shingle" data-low="10900" data-high="13100">1420 Maple Grove Dr, Frisco TX</option>
|
||||
<option value="78 Harbor View Ln, Plano TX"
|
||||
data-area="4820" data-squares="53" data-pitch="8:12" data-material="Standing-seam metal" data-low="31400" data-high="37200">78 Harbor View Ln, Plano TX</option>
|
||||
<option value="305 Prairie Wind Ct, Allen TX"
|
||||
data-area="1980" data-squares="22" data-pitch="4:12" data-material="3-tab shingle" data-low="7600" data-high="9200">305 Prairie Wind Ct, Allen TX</option>
|
||||
<option value="6110 Cedar Bluff Way, Denton TX"
|
||||
data-area="6240" data-squares="68" data-pitch="7:12" data-material="Architectural shingle" data-low="26800" data-high="31500">6110 Cedar Bluff Way, Denton TX</option>
|
||||
</select>
|
||||
<button class="btn solid" type="button" id="ctaEstimateBtn">Estimate it now</button>
|
||||
</form>
|
||||
<div class="cta-estimate" id="ctaEstimate" hidden aria-live="polite">
|
||||
<div class="est-head">
|
||||
<span class="stamp-tag ok">✓ LIVE ESTIMATE</span>
|
||||
<span class="est-addr" id="estAddr"></span>
|
||||
</div>
|
||||
<div class="est-grid">
|
||||
<div class="est-cell"><span class="est-k">Roof area</span><span class="est-v" id="estArea"></span></div>
|
||||
<div class="est-cell"><span class="est-k">Squares</span><span class="est-v" id="estSquares"></span></div>
|
||||
<div class="est-cell"><span class="est-k">Predominant pitch</span><span class="est-v" id="estPitch"></span></div>
|
||||
<div class="est-cell"><span class="est-k">Material</span><span class="est-v" id="estMaterial"></span></div>
|
||||
</div>
|
||||
<div class="est-total">
|
||||
<span class="est-k">Signature-ready range</span>
|
||||
<span class="est-price" id="estPrice"></span>
|
||||
</div>
|
||||
<p class="cta-fine tag">Sample figures from aerial mesh · final numbers confirm on your live address</p>
|
||||
</div>
|
||||
<div class="cta-alt">
|
||||
<a class="btn" href="#hero">Book the 15-min demo</a>
|
||||
<a class="btn" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book the 15-min demo</a>
|
||||
<a class="btn" href="tel:8662596533">Call (866) 259-6533</a>
|
||||
</div>
|
||||
<p class="cta-fine tag">Founders Lifetime License available · no credit card · works offline in the field</p>
|
||||
@@ -1227,14 +1255,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="#top" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="./img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="#top" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="#top" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -1254,7 +1278,7 @@
|
||||
<a href="#founder">About</a>
|
||||
<a href="./blog/">Blog</a>
|
||||
<a href="#chaos">Why it works</a>
|
||||
<a href="#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="#book">Careers</a>
|
||||
<a href="#book">Contact</a>
|
||||
</div>
|
||||
|
||||
Generated
+371
@@ -15,6 +15,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
},
|
||||
@@ -799,6 +801,335 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "26.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz",
|
||||
"integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~8.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-aix-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-darwin-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-freebsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
|
||||
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-loong64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
|
||||
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-mips64el": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
|
||||
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-ppc64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
|
||||
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-riscv64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
|
||||
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-s390x": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
|
||||
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-linux-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-netbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-openbsd-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-sunos-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-arm64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
|
||||
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript/typescript-win32-x64": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
|
||||
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1019,6 +1350,46 @@
|
||||
"integrity": "sha512-cc96IlVYGydeceu0e5xq70H8/yoVT/tXBxV/W8A/U6uOq7DXc4/s1Mkmnu6SqoYGhSRWWYFOhVwvq6V0VtbplA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "7.0.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
|
||||
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=16.20.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@typescript/typescript-aix-ppc64": "7.0.2",
|
||||
"@typescript/typescript-darwin-arm64": "7.0.2",
|
||||
"@typescript/typescript-darwin-x64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-freebsd-x64": "7.0.2",
|
||||
"@typescript/typescript-linux-arm": "7.0.2",
|
||||
"@typescript/typescript-linux-arm64": "7.0.2",
|
||||
"@typescript/typescript-linux-loong64": "7.0.2",
|
||||
"@typescript/typescript-linux-mips64el": "7.0.2",
|
||||
"@typescript/typescript-linux-ppc64": "7.0.2",
|
||||
"@typescript/typescript-linux-riscv64": "7.0.2",
|
||||
"@typescript/typescript-linux-s390x": "7.0.2",
|
||||
"@typescript/typescript-linux-x64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-netbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-arm64": "7.0.2",
|
||||
"@typescript/typescript-openbsd-x64": "7.0.2",
|
||||
"@typescript/typescript-sunos-x64": "7.0.2",
|
||||
"@typescript/typescript-win32-arm64": "7.0.2",
|
||||
"@typescript/typescript-win32-x64": "7.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "8.3.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz",
|
||||
"integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||
|
||||
+4
-1
@@ -7,7 +7,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/inter": "^5.0.18",
|
||||
@@ -17,6 +18,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^26.1.1",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^5.3.1"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.2 KiB |
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Build-time generation of the Ghost-backed blog.
|
||||
*
|
||||
* Runs under Node from vite.config.ts. For each post tagged `lynkeduppro-new`
|
||||
* it writes blog/<slug>/index.html from the shared shell template, so every post
|
||||
* ships as real static HTML with its SEO tags present in the source.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fetchSitePosts, readGhostConfig } from "./client";
|
||||
import { normalizePosts } from "./normalize";
|
||||
import { renderPostCard, renderPostPage } from "./render";
|
||||
import type { BlogPost } from "./types";
|
||||
|
||||
const BLOG_DIR = path.resolve("blog");
|
||||
const TEMPLATE_PATH = path.join(BLOG_DIR, "_post.template.html");
|
||||
const MANIFEST_PATH = path.join(BLOG_DIR, ".ghost-generated.json");
|
||||
|
||||
/**
|
||||
* Hand-authored posts that predate the Ghost integration. They own their routes:
|
||||
* their directories are never generated or removed, and a Ghost post reusing one
|
||||
* of these slugs is skipped rather than overwriting the static page.
|
||||
*/
|
||||
export const STATIC_POST_SLUGS = [
|
||||
"cost-of-paperwork",
|
||||
"48-hour-storm-checklist",
|
||||
"why-claims-get-denied",
|
||||
];
|
||||
|
||||
export interface GhostBuildResult {
|
||||
posts: BlogPost[];
|
||||
/** Rendered cards for injection into blog/index.html. */
|
||||
cardsHtml: string;
|
||||
/** Absolute paths of generated pages, for Vite's rollup inputs. */
|
||||
generatedPages: string[];
|
||||
}
|
||||
|
||||
/** Removes pages generated by a previous run so deleted/untagged posts disappear. */
|
||||
function cleanPreviousRun(): void {
|
||||
let previous: string[] = [];
|
||||
try {
|
||||
previous = JSON.parse(fs.readFileSync(MANIFEST_PATH, "utf8")) as string[];
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const slug of previous) {
|
||||
if (STATIC_POST_SLUGS.includes(slug)) continue;
|
||||
fs.rmSync(path.join(BLOG_DIR, slug), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches, renders and writes every Ghost post for this site.
|
||||
*
|
||||
* @param allowStaleFallback Use the last cached response if Ghost is unreachable
|
||||
* (dev only).
|
||||
*/
|
||||
export async function buildGhostBlog(
|
||||
env: Record<string, string | undefined>,
|
||||
{ allowStaleFallback = false }: { allowStaleFallback?: boolean } = {}
|
||||
): Promise<GhostBuildResult> {
|
||||
const config = readGhostConfig(env);
|
||||
const raw = await fetchSitePosts(config, { allowStaleFallback });
|
||||
const posts = normalizePosts(raw, config, STATIC_POST_SLUGS);
|
||||
|
||||
const skipped = raw.length - posts.length;
|
||||
if (skipped > 0) {
|
||||
console.warn(
|
||||
`[ghost] skipped ${skipped} post(s): slug missing, or colliding with a static post ` +
|
||||
`(${STATIC_POST_SLUGS.join(", ")}).`
|
||||
);
|
||||
}
|
||||
|
||||
cleanPreviousRun();
|
||||
|
||||
const template = fs.readFileSync(TEMPLATE_PATH, "utf8");
|
||||
const generatedPages: string[] = [];
|
||||
|
||||
posts.forEach((post, i) => {
|
||||
const dir = path.join(BLOG_DIR, post.slug);
|
||||
const file = path.join(dir, "index.html");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(file, renderPostPage(template, post, posts[i + 1] ?? null), "utf8");
|
||||
generatedPages.push(file);
|
||||
});
|
||||
|
||||
fs.writeFileSync(MANIFEST_PATH, JSON.stringify(posts.map((p) => p.slug), null, 2), "utf8");
|
||||
|
||||
console.log(
|
||||
`[ghost] generated ${posts.length} post page(s) from tag "lynkeduppro-new"` +
|
||||
(posts.length ? `: ${posts.map((p) => p.slug).join(", ")}` : "")
|
||||
);
|
||||
|
||||
return {
|
||||
posts,
|
||||
cardsHtml: posts.map(renderPostCard).join("\n\n"),
|
||||
generatedPages,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Ghost Content API client. Runs at build time under Node only — the API key is
|
||||
* read from the environment here and never reaches the browser bundle.
|
||||
*
|
||||
* Direct fetch is used rather than @tryghost/content-api: this site needs one
|
||||
* read-only endpoint, and the SDK would add a dependency for a single GET.
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { GhostConfig, GhostPost, GhostPostsResponse } from "./types";
|
||||
|
||||
/** Only posts carrying this tag are published to this site. */
|
||||
export const SITE_TAG = "lynkeduppro-new";
|
||||
|
||||
const CACHE_DIR = path.resolve("node_modules/.cache/ghost");
|
||||
const CACHE_FILE = path.join(CACHE_DIR, `posts-${SITE_TAG}.json`);
|
||||
|
||||
/**
|
||||
* Reads Ghost credentials from the environment.
|
||||
* @throws if either required variable is missing.
|
||||
*/
|
||||
export function readGhostConfig(env: Record<string, string | undefined>): GhostConfig {
|
||||
const apiUrl = env.GHOST_API_URL?.trim();
|
||||
const contentApiKey = env.GHOST_CONTENT_API_KEY?.trim();
|
||||
|
||||
const missing = [
|
||||
!apiUrl && "GHOST_API_URL",
|
||||
!contentApiKey && "GHOST_CONTENT_API_KEY",
|
||||
].filter(Boolean);
|
||||
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`Missing Ghost environment variable(s): ${missing.join(", ")}. ` +
|
||||
`Copy .env.example to .env and fill them in (see README of .env.example).`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
apiUrl: apiUrl!.replace(/\/+$/, ""),
|
||||
contentApiKey: contentApiKey!,
|
||||
siteUrl: (env.SITE_URL?.trim() || "https://lynkeduppro.com").replace(/\/+$/, ""),
|
||||
};
|
||||
}
|
||||
|
||||
function readCache(): GhostPost[] | null {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(CACHE_FILE, "utf8")) as GhostPost[];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(posts: GhostPost[]): void {
|
||||
try {
|
||||
fs.mkdirSync(CACHE_DIR, { recursive: true });
|
||||
fs.writeFileSync(CACHE_FILE, JSON.stringify(posts), "utf8");
|
||||
} catch {
|
||||
/* Cache is an optimisation; a failure to persist must not fail the build. */
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches every published post tagged {@link SITE_TAG}, newest first.
|
||||
*
|
||||
* Always hits Ghost — one request per dev-server start or build is necessary, not
|
||||
* wasteful, and serving a TTL-cached copy here would silently hide freshly
|
||||
* published posts. The on-disk copy is only a fallback for when Ghost is
|
||||
* unreachable, so a dropped connection doesn't stop local work.
|
||||
*
|
||||
* @param allowStaleFallback Fall back to the last good response if the fetch
|
||||
* fails. Dev only: a production build must fail loudly rather than publish
|
||||
* stale content.
|
||||
*/
|
||||
export async function fetchSitePosts(
|
||||
config: GhostConfig,
|
||||
{ allowStaleFallback = false }: { allowStaleFallback?: boolean } = {}
|
||||
): Promise<GhostPost[]> {
|
||||
const url = new URL(`${config.apiUrl}/ghost/api/content/posts/`);
|
||||
url.searchParams.set("key", config.contentApiKey);
|
||||
url.searchParams.set("filter", `tag:${SITE_TAG}`);
|
||||
url.searchParams.set("include", "tags,authors");
|
||||
url.searchParams.set("limit", "all");
|
||||
url.searchParams.set("order", "published_at DESC");
|
||||
|
||||
try {
|
||||
const res = await fetch(url, { headers: { "Accept-Version": "v5.0" } });
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Ghost API responded ${res.status} ${res.statusText} for ${config.apiUrl}. ${body.slice(0, 300)}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as GhostPostsResponse;
|
||||
const posts = data.posts ?? [];
|
||||
writeCache(posts);
|
||||
return posts;
|
||||
} catch (error) {
|
||||
if (!allowStaleFallback) throw error;
|
||||
const cached = readCache();
|
||||
if (!cached) throw error;
|
||||
console.warn(
|
||||
`[ghost] fetch failed (${error instanceof Error ? error.message : String(error)}).\n` +
|
||||
`[ghost] Falling back to the last cached response (${cached.length} post(s)) — may be out of date.`
|
||||
);
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
/**
|
||||
* Turns raw Ghost payloads into {@link BlogPost} values that are always safe to
|
||||
* render: missing authors, images, excerpts and SEO fields are resolved to
|
||||
* sensible fallbacks here so the templates stay branch-free.
|
||||
*/
|
||||
|
||||
import type { BlogPost, GhostConfig, GhostPost, GhostTag, PostSeo } from "./types";
|
||||
import { SITE_TAG } from "./client";
|
||||
|
||||
const FALLBACK_AUTHOR = "LynkedUp Pro Team";
|
||||
const FALLBACK_EXCERPT =
|
||||
"Field notes for contractors from the LynkedUp Pro team.";
|
||||
|
||||
/** Strips HTML tags and collapses whitespace — used to derive text from Ghost HTML. */
|
||||
function toPlainText(html: string): string {
|
||||
return html
|
||||
.replace(/<[^>]*>/g, " ")
|
||||
.replace(/ /g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): string {
|
||||
if (text.length <= max) return text;
|
||||
const cut = text.slice(0, max);
|
||||
const lastSpace = cut.lastIndexOf(" ");
|
||||
return `${cut.slice(0, lastSpace > 0 ? lastSpace : max).trimEnd()}…`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Upgrades a Ghost-hosted `http://` URL to `https://`.
|
||||
*
|
||||
* Ghost builds asset URLs from its own configured `url` setting. If that is set
|
||||
* to http (as api.lynkedup.dev currently is), every image comes back insecure and
|
||||
* browsers block it as mixed content on our HTTPS pages — silently, before the
|
||||
* server's 301 to https can help.
|
||||
*
|
||||
* Only the configured Ghost host is upgraded: an arbitrary external http image in
|
||||
* post content may genuinely have no https, and forcing it would break it.
|
||||
*/
|
||||
export function secureGhostUrl(url: string | null, ghostHost: string): string | null {
|
||||
if (!url || !url.startsWith("http://")) return url;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
if (parsed.host !== ghostHost) return url;
|
||||
parsed.protocol = "https:";
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
/** Applies {@link secureGhostUrl} to src/srcset/href attributes inside post HTML. */
|
||||
function secureHtmlUrls(html: string, ghostHost: string): string {
|
||||
return html.replace(
|
||||
/(\s(?:src|href)=")(http:\/\/[^"]+)(")/gi,
|
||||
(match, pre: string, url: string, post: string) => {
|
||||
const secure = secureGhostUrl(url, ghostHost);
|
||||
return secure ? `${pre}${secure}${post}` : match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites a Ghost image URL to a resized variant. Ghost serves derivatives from
|
||||
* `/content/images/size/w<width>/...`; any non-Ghost or already-sized URL is
|
||||
* returned untouched.
|
||||
*/
|
||||
export function ghostImage(url: string | null, width: number): string | null {
|
||||
if (!url) return null;
|
||||
if (!url.includes("/content/images/")) return url;
|
||||
if (/\/content\/images\/size\/w\d+\//.test(url)) return url;
|
||||
return url.replace("/content/images/", `/content/images/size/w${width}/`);
|
||||
}
|
||||
|
||||
/** Builds a `srcset` across Ghost's derivative widths for responsive loading. */
|
||||
export function ghostSrcset(url: string | null, widths: number[]): string | null {
|
||||
if (!url || !url.includes("/content/images/")) return null;
|
||||
return widths
|
||||
.map((w) => `${ghostImage(url, w)} ${w}w`)
|
||||
.join(", ");
|
||||
}
|
||||
|
||||
/** Ghost's estimate, or a ~200wpm fallback derived from the body text. */
|
||||
function resolveReadingTime(post: GhostPost, plain: string): number {
|
||||
if (post.reading_time && post.reading_time > 0) return post.reading_time;
|
||||
const words = plain ? plain.split(" ").length : 0;
|
||||
return Math.max(1, Math.round(words / 200));
|
||||
}
|
||||
|
||||
function resolveAuthor(post: GhostPost): string {
|
||||
const name = post.primary_author?.name?.trim() || post.authors?.[0]?.name?.trim();
|
||||
return name || FALLBACK_AUTHOR;
|
||||
}
|
||||
|
||||
/** Public tags, with the routing tag and Ghost's internal `#` tags removed. */
|
||||
function resolveTags(post: GhostPost): GhostTag[] {
|
||||
return (post.tags ?? []).filter(
|
||||
(t) => t.slug !== SITE_TAG && t.visibility === "public" && !t.name.startsWith("#")
|
||||
);
|
||||
}
|
||||
|
||||
function resolveExcerpt(post: GhostPost, plain: string): string {
|
||||
const raw = post.custom_excerpt?.trim() || post.excerpt?.trim();
|
||||
if (raw) return truncate(raw.replace(/\s+/g, " "), 200);
|
||||
if (plain) return truncate(plain, 200);
|
||||
return FALLBACK_EXCERPT;
|
||||
}
|
||||
|
||||
/** Prefers Ghost's SEO fields, falling back to post content. */
|
||||
function resolveSeo(
|
||||
post: GhostPost,
|
||||
config: GhostConfig,
|
||||
ghostHost: string,
|
||||
title: string,
|
||||
excerpt: string
|
||||
): PostSeo {
|
||||
const metaTitle = post.meta_title?.trim() || `${title} - LynkedUp Pro Blog`;
|
||||
const metaDescription = post.meta_description?.trim() || excerpt;
|
||||
const ogImage = secureGhostUrl(ghostImage(post.og_image || post.feature_image, 1200), ghostHost);
|
||||
|
||||
return {
|
||||
metaTitle,
|
||||
metaDescription,
|
||||
ogTitle: post.og_title?.trim() || metaTitle,
|
||||
ogDescription: post.og_description?.trim() || metaDescription,
|
||||
ogImage,
|
||||
twitterTitle: post.twitter_title?.trim() || metaTitle,
|
||||
twitterDescription: post.twitter_description?.trim() || metaDescription,
|
||||
twitterImage: secureGhostUrl(ghostImage(post.twitter_image, 1200), ghostHost) || ogImage,
|
||||
// Ghost's canonical_url wins when an editor has set one (e.g. syndicated
|
||||
// posts); otherwise the canonical is this site's own URL for the slug.
|
||||
canonicalUrl: post.canonical_url?.trim() || `${config.siteUrl}/blog/${post.slug}/`,
|
||||
};
|
||||
}
|
||||
|
||||
const DATE_FORMAT = new Intl.DateTimeFormat("en-US", {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
|
||||
/** The Ghost host (e.g. "api.lynkedup.dev"), for upgrading its http asset URLs. */
|
||||
function ghostHostFrom(config: GhostConfig): string {
|
||||
try {
|
||||
return new URL(config.apiUrl).host;
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizePost(post: GhostPost, config: GhostConfig): BlogPost {
|
||||
const ghostHost = ghostHostFrom(config);
|
||||
const rawHtml = post.html ?? "";
|
||||
const html = secureHtmlUrls(rawHtml, ghostHost);
|
||||
const plain = toPlainText(rawHtml);
|
||||
const title = post.title?.trim() || "Untitled";
|
||||
const excerpt = resolveExcerpt(post, plain);
|
||||
const tags = resolveTags(post);
|
||||
const publishedAt = post.published_at ? new Date(post.published_at) : null;
|
||||
const validDate = publishedAt && !Number.isNaN(publishedAt.getTime()) ? publishedAt : null;
|
||||
|
||||
return {
|
||||
slug: post.slug,
|
||||
title,
|
||||
html,
|
||||
excerpt,
|
||||
featureImage: secureGhostUrl(post.feature_image, ghostHost),
|
||||
featureImageAlt: post.feature_image_alt?.trim() || title,
|
||||
author: resolveAuthor(post),
|
||||
publishedAt: validDate,
|
||||
publishedLabel: validDate ? DATE_FORMAT.format(validDate) : "",
|
||||
publishedIso: validDate ? validDate.toISOString() : "",
|
||||
readingTime: resolveReadingTime(post, plain),
|
||||
tags,
|
||||
primaryTagLabel: (tags[0]?.name || "Field notes").toUpperCase(),
|
||||
seo: resolveSeo(post, config, ghostHost, title, excerpt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalises a batch, dropping posts Ghost returned without a slug (they have no
|
||||
* addressable URL) and de-duplicating against the hand-authored static posts,
|
||||
* which own their routes.
|
||||
*/
|
||||
export function normalizePosts(
|
||||
posts: GhostPost[],
|
||||
config: GhostConfig,
|
||||
reservedSlugs: string[] = []
|
||||
): BlogPost[] {
|
||||
const reserved = new Set(reservedSlugs);
|
||||
return posts
|
||||
.filter((p) => Boolean(p.slug) && !reserved.has(p.slug))
|
||||
.map((p) => normalizePost(p, config));
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Renders Ghost posts into the site's existing markup. Every class name used
|
||||
* here already exists in src/styles.css — this module reproduces the structure
|
||||
* of the hand-authored posts rather than introducing any new design.
|
||||
*/
|
||||
|
||||
import type { BlogPost } from "./types";
|
||||
import { ghostImage, ghostSrcset } from "./normalize";
|
||||
|
||||
/** Escapes text destined for an HTML text node or a double-quoted attribute. */
|
||||
export function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/**
|
||||
* Defence-in-depth cleanup of Ghost's HTML. Ghost content is first-party
|
||||
* (authored by the team in our own CMS), so this strips the script vectors a
|
||||
* compromised or careless editor could introduce rather than attempting to be a
|
||||
* full sanitiser for untrusted input.
|
||||
*/
|
||||
export function sanitizeGhostHtml(html: string): string {
|
||||
return html
|
||||
.replace(/<script\b[\s\S]*?<\/script\s*>/gi, "")
|
||||
.replace(/<style\b[\s\S]*?<\/style\s*>/gi, "")
|
||||
.replace(/\son[a-z]+\s*=\s*"[^"]*"/gi, "")
|
||||
.replace(/\son[a-z]+\s*=\s*'[^']*'/gi, "")
|
||||
.replace(/\son[a-z]+\s*=\s*[^\s>]+/gi, "")
|
||||
.replace(/(<a\b[^>]*\bhref\s*=\s*")\s*javascript:[^"]*(")/gi, "$1#$2");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tags each top-level `<h2>` with `data-rail` so the existing section-rail
|
||||
* script (src/blog.js) builds its index from Ghost content the same way it does
|
||||
* for the hand-authored posts.
|
||||
*/
|
||||
function addRailAnchors(html: string): string {
|
||||
let index = 1;
|
||||
return html.replace(/<h2\b([^>]*)>([\s\S]*?)<\/h2>/gi, (match, attrs: string, inner: string) => {
|
||||
if (/\bdata-rail=/i.test(attrs)) return match;
|
||||
index += 1;
|
||||
const label = inner.replace(/<[^>]*>/g, "").replace(/\s+/g, " ").trim();
|
||||
const short = label.length > 18 ? `${label.slice(0, 18).trimEnd()}…` : label;
|
||||
const rail = `${String(index).padStart(2, "0")} · ${escapeHtml(short.toUpperCase())}`;
|
||||
return `<h2${attrs} data-rail="${rail}">${inner}</h2>`;
|
||||
});
|
||||
}
|
||||
|
||||
/** Adds native lazy-loading and async decoding to images inside Ghost content. */
|
||||
function optimizeContentImages(html: string): string {
|
||||
return html.replace(/<img\b([^>]*)>/gi, (match, attrs: string) => {
|
||||
let next = attrs;
|
||||
if (!/\bloading=/i.test(next)) next += ' loading="lazy"';
|
||||
if (!/\bdecoding=/i.test(next)) next += ' decoding="async"';
|
||||
return `<img${next}>`;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderPostContent(post: BlogPost): string {
|
||||
return optimizeContentImages(addRailAnchors(sanitizeGhostHtml(post.html)));
|
||||
}
|
||||
|
||||
/** The card markup for the listing grid — mirrors blog/index.html exactly. */
|
||||
export function renderPostCard(post: BlogPost): string {
|
||||
const caption = [post.primaryTagLabel, post.publishedLabel.toUpperCase(), `${post.readingTime} MIN READ`]
|
||||
.filter(Boolean)
|
||||
.join(" · ");
|
||||
|
||||
const src = ghostImage(post.featureImage, 900);
|
||||
const srcset = ghostSrcset(post.featureImage, [600, 900, 1200]);
|
||||
|
||||
// Posts without a feature image keep the card's media block (and so its
|
||||
// aspect ratio and grid rhythm) by falling back to the site OG image.
|
||||
const media = src
|
||||
? `<img class="feat-thumb" src="${escapeHtml(src)}"${
|
||||
srcset ? ` srcset="${escapeHtml(srcset)}" sizes="(max-width: 900px) 100vw, 900px"` : ""
|
||||
} alt="${escapeHtml(post.featureImageAlt)}" loading="lazy" decoding="async" width="900" height="510">`
|
||||
: `<img class="feat-thumb" src="../og.jpg" alt="${escapeHtml(post.title)}" loading="lazy" decoding="async" width="900" height="510">`;
|
||||
|
||||
return ` <a class="feat blog-card reveal" href="./${escapeHtml(post.slug)}/" aria-label="Read: ${escapeHtml(post.title)}">
|
||||
<div class="feat-media">
|
||||
${media}
|
||||
<span class="cap">${caption}</span>
|
||||
</div>
|
||||
<div class="feat-body">
|
||||
<h3>${escapeHtml(post.title)}</h3>
|
||||
<p>${escapeHtml(post.excerpt)}</p>
|
||||
<span class="feat-link">Read the field note <span aria-hidden="true">▸</span></span>
|
||||
</div>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown in place of the card grid when Ghost returns no posts. The listing no
|
||||
* longer has hand-authored cards to fall back on, so without this the grid would
|
||||
* render as a blank gap.
|
||||
*/
|
||||
export function renderEmptyState(): string {
|
||||
return ` <div class="blog-empty reveal">
|
||||
<span class="tag">No field notes published yet</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderMetaTag(attr: "name" | "property", key: string, value: string | null): string {
|
||||
if (!value) return "";
|
||||
return `<meta ${attr}="${key}" content="${escapeHtml(value)}">`;
|
||||
}
|
||||
|
||||
/** Article structured data (requirement 6). */
|
||||
function renderArticleSchema(post: BlogPost): string {
|
||||
const schema: Record<string, unknown> = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "Article",
|
||||
headline: post.title,
|
||||
description: post.seo.metaDescription,
|
||||
author: { "@type": "Person", name: post.author },
|
||||
publisher: { "@type": "Organization", name: "LynkedUp Pro" },
|
||||
mainEntityOfPage: { "@type": "WebPage", "@id": post.seo.canonicalUrl },
|
||||
url: post.seo.canonicalUrl,
|
||||
};
|
||||
if (post.seo.ogImage) schema.image = post.seo.ogImage;
|
||||
if (post.publishedIso) schema.datePublished = post.publishedIso;
|
||||
if (post.tags.length) schema.keywords = post.tags.map((t) => t.name).join(", ");
|
||||
|
||||
// </script> inside JSON would close the tag early.
|
||||
return JSON.stringify(schema, null, 2).replace(/</g, "\\u003c");
|
||||
}
|
||||
|
||||
function renderHead(post: BlogPost): string {
|
||||
const { seo } = post;
|
||||
return [
|
||||
`<title>${escapeHtml(seo.metaTitle)}</title>`,
|
||||
renderMetaTag("name", "description", seo.metaDescription),
|
||||
`<link rel="canonical" href="${escapeHtml(seo.canonicalUrl)}">`,
|
||||
renderMetaTag("property", "og:title", seo.ogTitle),
|
||||
renderMetaTag("property", "og:description", seo.ogDescription),
|
||||
renderMetaTag("property", "og:type", "article"),
|
||||
renderMetaTag("property", "og:url", seo.canonicalUrl),
|
||||
renderMetaTag("property", "og:image", seo.ogImage ?? "../../og.jpg"),
|
||||
post.publishedIso
|
||||
? renderMetaTag("property", "article:published_time", post.publishedIso)
|
||||
: "",
|
||||
renderMetaTag("name", "twitter:card", "summary_large_image"),
|
||||
renderMetaTag("name", "twitter:title", seo.twitterTitle),
|
||||
renderMetaTag("name", "twitter:description", seo.twitterDescription),
|
||||
renderMetaTag("name", "twitter:image", seo.twitterImage ?? "../../og.jpg"),
|
||||
renderMetaTag("name", "theme-color", "#0B0D10"),
|
||||
renderMetaTag("name", "author", post.author),
|
||||
`<script type="application/ld+json">\n${renderArticleSchema(post)}\n</script>`,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function renderCover(post: BlogPost): string {
|
||||
if (!post.featureImage) return "";
|
||||
const src = ghostImage(post.featureImage, 1600);
|
||||
const srcset = ghostSrcset(post.featureImage, [800, 1200, 1600, 2000]);
|
||||
return `<figure class="post-cover reveal">
|
||||
<img src="${escapeHtml(src!)}"${
|
||||
srcset ? ` srcset="${escapeHtml(srcset)}" sizes="(max-width: 1100px) 100vw, 1100px"` : ""
|
||||
} alt="${escapeHtml(post.featureImageAlt)}" loading="eager" fetchpriority="high" width="1600" height="762">
|
||||
</figure>`;
|
||||
}
|
||||
|
||||
function renderPostMeta(post: BlogPost): string {
|
||||
const parts = [`<span>${escapeHtml(post.author)}</span>`];
|
||||
if (post.publishedLabel) {
|
||||
parts.push(`<span><time datetime="${escapeHtml(post.publishedIso)}">${escapeHtml(post.publishedLabel)}</time></span>`);
|
||||
}
|
||||
parts.push(`<span>${post.readingTime} min read</span>`);
|
||||
return parts.join("<i></i>");
|
||||
}
|
||||
|
||||
/** Tag chips, reusing the existing `.tag` pill style. */
|
||||
function renderTags(post: BlogPost): string {
|
||||
if (!post.tags.length) return "";
|
||||
return `<div class="post-tags">${post.tags
|
||||
.map((t) => `<span class="tag">${escapeHtml(t.name)}</span>`)
|
||||
.join("")}</div>`;
|
||||
}
|
||||
|
||||
function renderFootNav(post: BlogPost, next: BlogPost | null): string {
|
||||
const links = [`<a href="../">← All field notes</a>`];
|
||||
if (next) {
|
||||
links.push(
|
||||
`<a href="../${escapeHtml(next.slug)}/">Next: ${escapeHtml(next.title)} →</a>`
|
||||
);
|
||||
}
|
||||
return links.join("\n ");
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills the shared post shell (blog/_post.template.html) for one post.
|
||||
* @param next The following post, linked from the footer nav; null for the last.
|
||||
*/
|
||||
export function renderPostPage(template: string, post: BlogPost, next: BlogPost | null): string {
|
||||
const replacements: Record<string, string> = {
|
||||
HEAD: renderHead(post),
|
||||
RAIL_LABEL: escapeHtml(post.primaryTagLabel),
|
||||
TAG_LABEL: escapeHtml(post.tags[0]?.name || "Field notes"),
|
||||
TITLE: escapeHtml(post.title),
|
||||
POST_META: renderPostMeta(post),
|
||||
COVER: renderCover(post),
|
||||
CONTENT: renderPostContent(post),
|
||||
TAGS: renderTags(post),
|
||||
FOOT_NAV: renderFootNav(post, next),
|
||||
};
|
||||
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (match, key: string) =>
|
||||
key in replacements ? replacements[key] : match
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/** Shapes returned by the Ghost Content API (only the fields this site consumes). */
|
||||
|
||||
export interface GhostTag {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
visibility: string;
|
||||
}
|
||||
|
||||
export interface GhostAuthor {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
profile_image: string | null;
|
||||
}
|
||||
|
||||
export interface GhostPost {
|
||||
id: string;
|
||||
uuid: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
html: string | null;
|
||||
excerpt: string | null;
|
||||
custom_excerpt: string | null;
|
||||
feature_image: string | null;
|
||||
feature_image_alt: string | null;
|
||||
published_at: string | null;
|
||||
updated_at: string | null;
|
||||
reading_time: number | null;
|
||||
url: string | null;
|
||||
canonical_url: string | null;
|
||||
meta_title: string | null;
|
||||
meta_description: string | null;
|
||||
og_title: string | null;
|
||||
og_description: string | null;
|
||||
og_image: string | null;
|
||||
twitter_title: string | null;
|
||||
twitter_description: string | null;
|
||||
twitter_image: string | null;
|
||||
tags?: GhostTag[];
|
||||
primary_author?: GhostAuthor | null;
|
||||
authors?: GhostAuthor[];
|
||||
}
|
||||
|
||||
export interface GhostPostsResponse {
|
||||
posts: GhostPost[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A post normalised for rendering: every field is resolved to a usable value so
|
||||
* templates never have to branch on missing Ghost data.
|
||||
*/
|
||||
export interface BlogPost {
|
||||
slug: string;
|
||||
title: string;
|
||||
html: string;
|
||||
excerpt: string;
|
||||
featureImage: string | null;
|
||||
featureImageAlt: string;
|
||||
author: string;
|
||||
publishedAt: Date | null;
|
||||
/** Pre-formatted for display, e.g. "Jun 2, 2026". Empty when unpublished. */
|
||||
publishedLabel: string;
|
||||
/** ISO-8601, for <time datetime> and Article schema. */
|
||||
publishedIso: string;
|
||||
readingTime: number;
|
||||
/** Public tags only; the routing tag is stripped out. */
|
||||
tags: GhostTag[];
|
||||
/** Uppercased primary tag used in the card caption, e.g. "OPERATIONS". */
|
||||
primaryTagLabel: string;
|
||||
seo: PostSeo;
|
||||
}
|
||||
|
||||
export interface PostSeo {
|
||||
metaTitle: string;
|
||||
metaDescription: string;
|
||||
ogTitle: string;
|
||||
ogDescription: string;
|
||||
ogImage: string | null;
|
||||
twitterTitle: string;
|
||||
twitterDescription: string;
|
||||
twitterImage: string | null;
|
||||
canonicalUrl: string;
|
||||
}
|
||||
|
||||
export interface GhostConfig {
|
||||
apiUrl: string;
|
||||
contentApiKey: string;
|
||||
/** Public origin of this site, used to build canonical URLs. */
|
||||
siteUrl: string;
|
||||
}
|
||||
+31
@@ -6,6 +6,37 @@ import "./styles.css";
|
||||
|
||||
console.log("%cCreated by Sarthak", "font-size:14px;font-weight:700;color:#F2A83B;");
|
||||
|
||||
/* ---------- CTA sample-address estimate (standalone so it always runs) ---------- */
|
||||
(function(){
|
||||
function init(){
|
||||
var form = document.getElementById("ctaForm");
|
||||
var sel = document.getElementById("ctaAddr");
|
||||
var btn = document.getElementById("ctaEstimateBtn");
|
||||
var panel = document.getElementById("ctaEstimate");
|
||||
if(!sel || !panel) return;
|
||||
var money = function(n){ return "$" + Number(n).toLocaleString("en-US"); };
|
||||
var set = function(id, val){ var el = document.getElementById(id); if(el) el.textContent = val; };
|
||||
function render(scroll){
|
||||
var opt = sel.options[sel.selectedIndex];
|
||||
if(!opt || opt.dataset.placeholder){ panel.hidden = true; return; }
|
||||
set("estAddr", opt.value);
|
||||
set("estArea", Number(opt.dataset.area).toLocaleString("en-US") + " sq ft");
|
||||
set("estSquares", opt.dataset.squares + " sq");
|
||||
set("estPitch", opt.dataset.pitch);
|
||||
set("estMaterial", opt.dataset.material);
|
||||
set("estPrice", money(opt.dataset.low) + " – " + money(opt.dataset.high));
|
||||
panel.hidden = false;
|
||||
if(scroll) panel.scrollIntoView({behavior:"smooth", block:"nearest"});
|
||||
}
|
||||
function go(e){ if(e) e.preventDefault(); render(true); }
|
||||
if(form) form.addEventListener("submit", go);
|
||||
if(btn) btn.addEventListener("click", go);
|
||||
sel.addEventListener("change", function(){ if(!panel.hidden) render(false); });
|
||||
}
|
||||
if(document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
})();
|
||||
|
||||
(function(){
|
||||
var reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
var finePointer = matchMedia("(pointer: fine)").matches;
|
||||
|
||||
+42
-4
@@ -91,8 +91,8 @@
|
||||
}
|
||||
.nav{display:flex;align-items:center;gap:28px;height:60px}
|
||||
.logo{display:flex;align-items:center;gap:10px;font-weight:700;font-size:17px;letter-spacing:-.01em}
|
||||
.logo svg{width:24px;height:24px}
|
||||
.logo span b{color:var(--amber);font-weight:700}
|
||||
/* Wordmark lockup (195x42): fix the height and let width follow the aspect ratio. */
|
||||
.logo img{height:26px;width:auto;display:block}
|
||||
.nav-links{display:flex;gap:26px;margin-left:auto}
|
||||
.nav-links a{font-size:14px;color:var(--dim);transition:color .12s ease;position:relative}
|
||||
.nav-links a::before{
|
||||
@@ -1225,6 +1225,13 @@
|
||||
.faq-cat .faq details.hide{display:none}
|
||||
.faq-note{max-width:720px;padding:22px 24px;border-left:2px solid var(--sky);margin-top:26px}
|
||||
.faq-note p{color:var(--dim);font-size:13px;line-height:1.7;margin-top:10px}
|
||||
.disc-note{max-width:1000px}
|
||||
.disc-note p{break-inside:avoid}
|
||||
@media(min-width:721px){
|
||||
.disc-note{column-count:2;column-gap:52px}
|
||||
.disc-note>.tag{column-span:all}
|
||||
.disc-note p:first-of-type{margin-top:14px}
|
||||
}
|
||||
.faq-cta{max-width:560px;margin:0 auto;text-align:center;padding:40px 36px}
|
||||
.faq-cta h2{margin:14px 0 10px}
|
||||
.faq-cta p{color:var(--dim);margin-bottom:22px;max-width:44ch;margin-left:auto;margin-right:auto}
|
||||
@@ -1468,14 +1475,42 @@
|
||||
.cta h2{margin-top:16px}
|
||||
.cta > p{color:var(--dim);max-width:46ch;margin:14px auto 24px;font-size:14.5px}
|
||||
.cta-form{display:flex;gap:8px;max-width:520px;margin:0 auto 14px}
|
||||
.cta-form input{
|
||||
.cta-form input,
|
||||
.cta-form select{
|
||||
flex:1;min-width:0;background:rgba(11,13,16,.75);
|
||||
border:1px solid var(--border-strong);border-radius:6px;
|
||||
color:var(--text);font-family:var(--mono);font-size:12.5px;
|
||||
padding:12px 14px;letter-spacing:.02em;
|
||||
}
|
||||
.cta-form select{
|
||||
appearance:none;-webkit-appearance:none;cursor:pointer;
|
||||
padding-right:36px;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%2345afc9' stroke-width='1.6' d='M1 1.5 6 6.5 11 1.5'/%3E%3C/svg%3E");
|
||||
background-repeat:no-repeat;background-position:right 14px center;
|
||||
}
|
||||
.cta-form select option{background:#0b0d10;color:var(--text)}
|
||||
.cta-form input::placeholder{color:var(--faint)}
|
||||
.cta-form input:focus{outline:none;border-color:var(--sky);box-shadow:0 0 0 3px rgba(69,175,201,.13)}
|
||||
.cta-form input:focus,
|
||||
.cta-form select:focus{outline:none;border-color:var(--sky);box-shadow:0 0 0 3px rgba(69,175,201,.13)}
|
||||
|
||||
.cta-estimate{
|
||||
max-width:520px;margin:0 auto 16px;text-align:left;
|
||||
background:rgba(11,13,16,.55);border:1px solid var(--border-strong);
|
||||
border-radius:8px;padding:16px 18px;
|
||||
animation:estIn .32s ease both;
|
||||
}
|
||||
@keyframes estIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
|
||||
.est-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:14px}
|
||||
.est-addr{font-family:var(--mono);font-size:12px;color:var(--dim);letter-spacing:.02em}
|
||||
.est-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px 18px;margin-bottom:14px}
|
||||
.est-cell{display:flex;flex-direction:column;gap:3px}
|
||||
.est-k{font-family:var(--mono);font-size:9.5px;text-transform:uppercase;letter-spacing:.09em;color:var(--faint)}
|
||||
.est-v{font-size:14px;color:var(--text);font-weight:600}
|
||||
.est-total{
|
||||
display:flex;align-items:center;justify-content:space-between;gap:12px;
|
||||
padding-top:14px;border-top:1px solid var(--border);
|
||||
}
|
||||
.est-price{font-size:19px;font-weight:700;color:var(--sky);letter-spacing:.01em}
|
||||
.cta-alt{display:flex;gap:10px;justify-content:center;flex-wrap:wrap;margin-bottom:16px}
|
||||
.cta-fine{font-size:9.5px}
|
||||
|
||||
@@ -1716,6 +1751,7 @@
|
||||
.blog-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:20px;margin-top:8px}
|
||||
a.feat.blog-card{text-decoration:none;color:inherit}
|
||||
.blog-foot-note{margin-top:36px;text-align:center}
|
||||
.blog-empty{grid-column:1/-1;text-align:center;padding:56px 24px;border:1px dashed var(--border);border-radius:10px}
|
||||
|
||||
/* ---------- blog post: header, cover, prose ---------- */
|
||||
.post-meta{
|
||||
@@ -1747,6 +1783,8 @@
|
||||
}
|
||||
.post-foot-nav a{color:var(--sky);font-family:var(--mono);font-size:12px;letter-spacing:.04em;transition:color .12s ease}
|
||||
.post-foot-nav a:hover{color:var(--sky-bright)}
|
||||
.post-tags{display:flex;flex-wrap:wrap;gap:8px;margin-top:40px;max-width:680px}
|
||||
.post-tags .tag{border:1px solid var(--border);border-radius:999px;padding:5px 11px}
|
||||
|
||||
/* ---------- terms: quicknav + numbered clause cards with icon thumbnails ---------- */
|
||||
.terms-grid{display:grid;gap:16px;max-width:800px;margin-top:8px}
|
||||
|
||||
+7
-20
@@ -22,16 +22,7 @@
|
||||
<header>
|
||||
<div class="wrap nav">
|
||||
<a class="logo" href="../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<defs>
|
||||
<linearGradient id="lgA" x1="0" y1="0" x2="1" y2="1">
|
||||
<stop offset="0" stop-color="#5BD9EE"/><stop offset="1" stop-color="#1B7E9B"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="url(#lgA)" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
<img src="../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<nav class="nav-links" aria-label="Primary">
|
||||
<a href="../index.html#product">Product</a>
|
||||
@@ -42,7 +33,7 @@
|
||||
<a href="../blog/">Blog</a>
|
||||
<a href="../faq.html">FAQ</a>
|
||||
</nav>
|
||||
<a class="btn solid" href="../index.html#book">Book demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book demo</a>
|
||||
<button class="menu-btn" id="menuBtn" aria-label="Menu" aria-expanded="false"><i></i><i></i><i></i></button>
|
||||
</div>
|
||||
</header>
|
||||
@@ -192,7 +183,7 @@
|
||||
<h2>Talk to a <em>real person.</em></h2>
|
||||
<p>Legal questions, data requests, or account-specific terms — our team can walk you through any section above.</p>
|
||||
<div class="cta-alt">
|
||||
<a class="btn solid" href="../index.html#book">Book a demo</a>
|
||||
<a class="btn solid" href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a class="btn" href="tel:8662596533">Call (866) 259-6533</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -309,14 +300,10 @@
|
||||
<div class="foot-grid">
|
||||
<div class="foot-col brand">
|
||||
<a class="logo" href="../index.html" aria-label="LynkedUp Pro home">
|
||||
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
|
||||
<path d="M8.8 5.6 3.4 8.75v6.5L8.8 18.4l5.4-3.15v-6.5Z" stroke="#17607A" stroke-width="1.7"/>
|
||||
<path d="M15.2 5.6 9.8 8.75v6.5l5.4 3.15 5.4-3.15v-6.5Z" stroke="#2EC5DD" stroke-width="1.7"/>
|
||||
</svg>
|
||||
<span>LynkedUp <b>Pro</b></span>
|
||||
</a>
|
||||
<img src="../img/lynkedup-logo.png" alt="" width="195" height="42" decoding="async">
|
||||
</a>
|
||||
<p class="foot-tag">The construction CRM that does the paperwork.</p>
|
||||
<span class="tag">16990 Dallas Pkwy, Suite 206<br>Dallas, TX 75248<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<span class="tag">Dallas, Texas<br>(866) 259-6533<br>Info@LynkedUpPro.com</span>
|
||||
<div class="foot-social" aria-label="Social links">
|
||||
<a href="../index.html" aria-label="Instagram"><svg viewBox="0 0 24 24" aria-hidden="true"><rect x="3.5" y="3.5" width="17" height="17" rx="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="12" cy="12" r="4" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="17.2" cy="6.8" r="1.2" fill="currentColor"/></svg></a>
|
||||
<a href="../index.html" aria-label="X"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4.5 4.5 19.5 19.5M19.5 4.5l-15 15" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></svg></a>
|
||||
@@ -336,7 +323,7 @@
|
||||
<a href="../index.html#founder">About</a>
|
||||
<a href="../blog/">Blog</a>
|
||||
<a href="../index.html#chaos">Why it works</a>
|
||||
<a href="../index.html#book">Book a demo</a>
|
||||
<a href="https://book.lynkeduppro.com/" target="_blank" rel="noopener">Book a demo</a>
|
||||
<a href="../index.html#book">Careers</a>
|
||||
<a href="../index.html#book">Contact</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"types": ["node"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": false,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"allowJs": false
|
||||
},
|
||||
"include": ["src/ghost/**/*.ts", "vite.config.ts"]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { defineConfig } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
base: "./",
|
||||
server: { port: 4190 },
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(root, "index.html"),
|
||||
faq: path.resolve(root, "faq.html"),
|
||||
blog: path.resolve(root, "blog/index.html"),
|
||||
blogCostOfPaperwork: path.resolve(root, "blog/cost-of-paperwork/index.html"),
|
||||
blogStormChecklist: path.resolve(root, "blog/48-hour-storm-checklist/index.html"),
|
||||
blogClaimsDenied: path.resolve(root, "blog/why-claims-get-denied/index.html"),
|
||||
terms: path.resolve(root, "terms/index.html"),
|
||||
},
|
||||
output: {
|
||||
manualChunks: { three: ["three"], gsap: ["gsap"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
import { defineConfig, loadEnv, type Plugin } from "vite";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { buildGhostBlog } from "./src/ghost/build";
|
||||
import { renderEmptyState } from "./src/ghost/render";
|
||||
|
||||
const root = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
/** Marker in blog/index.html where Ghost cards are injected. */
|
||||
const CARDS_MARKER = "<!-- GHOST_CARDS -->";
|
||||
|
||||
/**
|
||||
* Every blog/<slug>/index.html — the hand-authored posts plus the pages just
|
||||
* generated from Ghost. Globbed rather than listed so new posts need no config
|
||||
* change.
|
||||
*/
|
||||
function blogPageInputs(): Record<string, string> {
|
||||
const blogDir = path.resolve(root, "blog");
|
||||
const inputs: Record<string, string> = {};
|
||||
|
||||
for (const entry of fs.readdirSync(blogDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const file = path.join(blogDir, entry.name, "index.html");
|
||||
if (fs.existsSync(file)) inputs[`blog-${entry.name}`] = file;
|
||||
}
|
||||
return inputs;
|
||||
}
|
||||
|
||||
/** Injects the rendered Ghost cards into the blog listing grid. */
|
||||
function ghostListingPlugin(cardsHtml: string): Plugin {
|
||||
return {
|
||||
name: "ghost-listing",
|
||||
transformIndexHtml: {
|
||||
order: "pre",
|
||||
handler(html, ctx) {
|
||||
if (!ctx.filename.replace(/\\/g, "/").endsWith("/blog/index.html")) return html;
|
||||
if (!html.includes(CARDS_MARKER)) {
|
||||
console.warn(`[ghost] ${CARDS_MARKER} not found in blog/index.html; cards not injected.`);
|
||||
return html;
|
||||
}
|
||||
// Empty when Ghost has no tagged posts, or (in dev) when it was
|
||||
// unreachable. The grid has no static cards behind it, so show the
|
||||
// empty state rather than a blank gap.
|
||||
return html.replace(CARDS_MARKER, cardsHtml || renderEmptyState());
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(async ({ mode, command }) => {
|
||||
// Third arg "" loads unprefixed vars too: these are read here in Node at build
|
||||
// time and never inlined into the client bundle, so the Ghost key stays server-side.
|
||||
const env = { ...process.env, ...loadEnv(mode, root, "") };
|
||||
|
||||
let cardsHtml = "";
|
||||
try {
|
||||
// Always fetch fresh, so a newly published post shows up on the next dev
|
||||
// restart or deploy. In dev only, fall back to the last cached response if
|
||||
// Ghost is unreachable.
|
||||
({ cardsHtml } = await buildGhostBlog(env, { allowStaleFallback: command === "serve" }));
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (command === "build") {
|
||||
// Fail the build rather than publish a blog with posts silently missing.
|
||||
// On Vercel a failed build leaves the previous deployment serving.
|
||||
throw new Error(`[ghost] blog generation failed: ${message}`);
|
||||
}
|
||||
// In dev, keep the site usable — the hand-authored posts still render.
|
||||
console.warn(`[ghost] ${message}\n[ghost] Continuing without Ghost posts (dev only).`);
|
||||
}
|
||||
|
||||
return {
|
||||
base: "./",
|
||||
server: { port: 4190 },
|
||||
plugins: [ghostListingPlugin(cardsHtml)],
|
||||
build: {
|
||||
rollupOptions: {
|
||||
input: {
|
||||
main: path.resolve(root, "index.html"),
|
||||
faq: path.resolve(root, "faq.html"),
|
||||
blog: path.resolve(root, "blog/index.html"),
|
||||
terms: path.resolve(root, "terms/index.html"),
|
||||
...blogPageInputs(),
|
||||
},
|
||||
output: {
|
||||
manualChunks: { three: ["three"], gsap: ["gsap"] },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user