Compare commits

..

29 Commits

Author SHA1 Message Date
Andres Corazon Santana Aquino bdc08499df Merge remote initial commit; keep current site content 2026-10-21 11:51:32 -04:00
Alejandro Veras 53e3e7bf81 Add booking bands, offices, official favicon and logo in light mode
- Drop a Start a Conversation band between sections on every page, since
  there were no calls to action while scrolling
- Add the AiGT offices to the contact page, with the new Dubai address
- Serve favicon.ico, PNG icons and an apple-touch icon made from the official
  DA monogram, replacing the hand-drawn SVG
- Show the official logo in light mode too
- Spanish and Arabic translations for the new copy

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 16:58:40 -04:00
Alejandro Veras daf95278a7 Fix booking calendars not growing, which hid the confirm button
The gate loaded the calendar iframes after GHL's form_embed.js ran, so it never
auto-sized them and the details step was cut off. The iframes keep their src
again; book.js runs first and removes them when the page is locked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:34:48 -04:00
Alejandro Veras 27c0189f2b Add a compact copy of the GHL form CSS that survives copy-paste
Pasting the indented CSS through Slack turned the indentation into non-breaking
spaces, so GHL kept the rules but browsers dropped their declarations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 12:17:39 -04:00
Alejandro Veras 083443a43b Drop the white backdrop behind the lead form and add dark GHL form CSS
ghl/lead-form.css is pasted into the GHL form's Custom CSS so the embed matches the
dark contact page. In light mode the form sits on a dark panel so it stays readable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:38:09 -04:00
Alejandro Veras b2eff3d3be Only show the booking calendars after the lead form is sent
/contact/book/ now needs ?submitted=1 (set in the GHL form redirect) to load the
calendars; other visitors get a prompt that links to the form. The page is noindex
and out of the sitemap, the contact page card points to the form, and visitors are
sent back to the language they filled the form in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:16:14 -04:00
Alejandro Veras 44af6329aa Update the VIP 1:1 call price to $400
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:12:44 -04:00
Alejandro Veras 09459ffa4e Give the contact page's direct email box inner padding and rounded corners
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 13:03:43 -04:00
Alejandro Veras d99d86a269 Remove Spirits from the home page
Drops the Spirits venture from the hero icons, the ventures tabs and its 3D bottle,
and from the home copy and meta description, with matching ES/AR translations.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:30:05 -04:00
Alejandro Veras 965ec4c2c5 Move language switch out of the nav into a floating corner widget
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 17:59:07 -04:00
Alejandro Veras eb9137b300 Add Spanish and Arabic versions with a language switch
- English stays at /, Spanish at /es/ and Arabic at /ar/ (right to left)
- EN · ES · AR switch in the header and mobile menu
- Pages are written in English; the build translates them through
  src/i18n/es.json and src/i18n/ar.json, including browser-script strings
- rtl.css mirrors the layout and loads Arabic fonts on /ar/ pages
- hreflang alternates on every page and a multilingual sitemap
- scripts/i18n-extract.mjs lists strings that still need translating

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 16:18:11 -04:00
Alejandro Veras 4ba5f05f75 Fix the GoHighLevel form sometimes not appearing on /contact/
form_embed.js hides each embed off-screen until the iframe reports ready.
With loading="lazy" the hidden iframe often never loaded, so it never
reported ready and the form stayed invisible. Load the form eagerly, reserve
its height, and reveal any embed still hidden after a few seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 12:12:57 -04:00
Alejandro Veras 75d8cbc7f7 Embed the GoHighLevel lead form and booking calendars
/contact/ now uses the GHL "Dylan – Lead Form", and a new /contact/book/
page lets visitors pick the free intro call or the paid 1:1 and book it in
the matching GHL calendar. Both calendars load up front so switching tabs is
instant.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 13:43:35 -04:00
Alejandro Veras 16e36f5b4f Apply remaining owner feedback
- Remove The Business Launch Playbook everywhere (home section, About card,
  Business Setup and Consulting mentions)
- Add the meSEO logo and a Korrith venture card linking to korrith.com
- Rename the press feature to Gulf Insider; the card links out once a URL is set
- Use the new photos of Dylan: podcast headshot, About, Consulting, Fractional,
  the magazine cover and a Press photo gallery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:17:08 -04:00
Alejandro Veras 6850d4bee7 Apply owner feedback: company links, Ai Growth Technologies, remove two ventures, new Instagram, full footer name
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:10:56 -04:00
Alejandro Veras 87f39eb49e Add five consulting service pages
Business Optimization, Supply Chain, Cybersecurity, Education and Fractional
Leadership, with copy verbatim from the content documents and the same layout
as the existing service pages (shared blocks in src/service.mjs). Adds them to
the Consulting menu (now two columns) and footer, restores the Consulting page
links to them, and fixes a mobile overflow from card glows that were not
anchored to their cards.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 12:09:26 -04:00
Alejandro Veras 4804d824da Raise footer watermark contrast another step
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:21:15 -04:00
Alejandro Veras 8b7da81f7f Make the footer watermark slightly more visible
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:18:59 -04:00
Alejandro Veras 8f62f11a62 Keep dark cards dark in light mode so their white titles stay readable
The glass card rule was overriding .card-dark and .closing-dark with the
light card surface, leaving white headings on a white background. Also
darkens the industry index numbers. A contrast audit of all eight pages in
both themes now finds no unreadable text.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:16:58 -04:00
Alejandro Veras 6ea0218427 Make the venture 3D panel follow the light theme
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:13:07 -04:00
Alejandro Veras a7d8e4968e Sharpen the Let's talk ribbon and center small outline buttons
The ribbon was a 1600px image stretched almost 3x. It is now upscaled 2x
with Lanczos and light sharpening (keeping transparency) and stretched less.
Small outline buttons such as Replay get symmetric padding since they have
no arrow tile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:08:28 -04:00
Alejandro Veras cc7d385c9f Make the Let's talk headline readable in light mode
The difference blend renders white on white in light mode, so the light
theme uses a solid navy headline over the ribbon instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 13:01:05 -04:00
Alejandro Veras 90be0ec4c7 Apply QA: brand-blue buttons, no DNA, theme-aware fireworks and footer
- Buttons return to the brand accent colors (primary fill, accent outline)
- Current Ventures shows only the copy beside the portfolio dashboard; the
  DNA helix and its library are removed
- The journey fireworks stage, pins and particles switch to a light palette
  in light mode
- The footer follows the theme instead of staying black

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:56:42 -04:00
Alejandro Veras d0357f1c52 Merge option 2 homepage and visual language into the site
Rebuild the homepage on option 2's sections and effects (3D DNA, spinning
cube, giant words, fireworks journey, neon type, chrome ribbon) with copy
kept verbatim from the content document. Each venture now has its own 3D
object, the portfolio dashboard sits beside the DNA, the journey uses the
original stats row and the playbook keeps our book. Inner pages adopt the
same navy, glass and gradient skin, with dark as the default theme.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 12:47:20 -04:00
Alejandro Veras e6279c2e91 Show company logos on portfolio venture cards
Add logos for READTYCH, Growth360Partners, Infinitus Texas, Skinny Spiritz and
Techie Marketing on white plates so they read in both themes; ventures
without a logo keep their monogram on the same plate.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:42:57 -04:00
Alejandro Veras 60f58cfc20 Fingerprint CSS and JS URLs so deploys never serve stale styles
Assets are cached for a week, so the browser kept the previous styles.css and
rendered the new markup with old rules. Each build now appends a content hash.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:29:31 -04:00
Alejandro Veras 2d37e3fcce Use Dylan's DM logo, switch accent to brand blue, add dark mode
Recreate the DM monogram and wordmark as SVG and type so they stay sharp in
both themes, move the accent from violet to the logo's blue, and add a small
theme toggle in the nav that follows the system until the visitor picks one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:25:52 -04:00
Alejandro Veras f73968c0dc Remove CTAs that pointed to pages outside the content package
Playbook, Poetry, Fractional Roles, and four service lines keep their copy
but no longer show a button, since those pages are not part of the site.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:13:08 -04:00
Alejandro Veras ee8e0ab417 Build dylanmazzei.com redesign as a static multi-page site
Eight pages (home, about, portfolio, consulting, marketing, business setup,
contact, press) with copy taken verbatim from the content documents, a shared
layout, and a zero-dependency Node build ready for Vercel.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-01 11:01:00 -04:00
26 changed files with 3352 additions and 115 deletions
-3
View File
@@ -1,3 +0,0 @@
node_modules
dist
.git
-9
View File
@@ -1,9 +0,0 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY . .
RUN node build.mjs
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
+18 -2
View File
@@ -26,8 +26,25 @@ build.mjs Genera dist/ + sitemap.xml + robots.txt + 404.html
| `/consulting/education/` | `src/pages/education.mjs` | | `/consulting/education/` | `src/pages/education.mjs` |
| `/consulting/fractional/` | `src/pages/fractional.mjs` | | `/consulting/fractional/` | `src/pages/fractional.mjs` |
| `/contact/` | `src/pages/contact.mjs` | | `/contact/` | `src/pages/contact.mjs` |
| `/contact/book/` | `src/pages/book.mjs` |
| `/press/` | `src/pages/press.mjs` | | `/press/` | `src/pages/press.mjs` |
## Idiomas
El sitio sale en inglés (`/`), español (`/es/`) y árabe (`/ar/`, de derecha a izquierda), con selector EN · ES · AR en la barra de navegación, `hreflang` en cada página y un `sitemap.xml` multilingüe.
Las páginas se escriben en inglés. Al construir, `build.mjs` genera las versiones traducidas sustituyendo cada texto por su traducción de `src/i18n/es.json` y `src/i18n/ar.json` (clave = texto en inglés). Los textos que ponen los scripts del navegador están en `JS_STRINGS` (`src/i18n.mjs`). Los ajustes de diseño para árabe están en `static/assets/rtl.css`.
Si cambias un texto en inglés, su traducción deja de coincidir y esa frase se ve en inglés en `/es/` y `/ar/`. El build lo avisa; para listar lo que falta:
```bash
node scripts/i18n-extract.mjs # cuántos textos faltan por idioma
node scripts/i18n-extract.mjs es falta.json # los textos en español que faltan, para traducir
npm run build -- --strict # falla si falta alguna traducción
```
El formulario y el calendario de GoHighLevel se muestran tal como están configurados en GoHighLevel (en inglés).
## Desarrollo ## Desarrollo
```bash ```bash
@@ -44,5 +61,4 @@ Importa el repo en Vercel. `vercel.json` ya define el build (`npm run build`), l
- **Press:** falta el link del artículo de Gulf Insider y confirmar su titular exacto (`FEATURE` en `src/pages/press.mjs`). - **Press:** falta el link del artículo de Gulf Insider y confirmar su titular exacto (`FEATURE` en `src/pages/press.mjs`).
- **Korrith:** falta el cargo de Dylan (el tercer campo, vacío, en `src/pages/portfolio.mjs`). - **Korrith:** falta el cargo de Dylan (el tercer campo, vacío, en `src/pages/portfolio.mjs`).
- **Páginas fuera del alcance actual:** Playbook y Poetry no forman parte del sitio. Sus secciones se muestran sin botón; si se crean esas páginas, se añade el botón con su texto original del documento. - **Páginas fuera del alcance actual:** Playbook y Poetry no forman parte del sitio. Sus secciones se muestran sin botón; si se crean esas páginas, se añade el botón con su texto original del documento.
- **Calendario:** poner el link real de agenda en `SITE.calendar`. - **Formulario y agenda (GoHighLevel):** los IDs están en `SITE.ghl` (`src/layout.mjs`). En GoHighLevel, el formulario "Dylan – Lead Form" debe redirigir a `https://dylanmazzei.com/contact/book/?submitted=1` al enviarse. Sin `?submitted=1`, `/contact/book/` muestra un aviso para llenar el formulario en vez de los calendarios (es solo un filtro en el navegador: los calendarios de GHL siguen siendo públicos, así que el cobro y la disponibilidad se controlan en GHL). La página no se indexa ni sale en el sitemap. El estilo oscuro del formulario vive en GHL: pega `ghl/lead-form.min.css` en el formulario (Custom CSS). Usa la versión compacta: al copiar la versión con sangría desde Slack se cuelan espacios invisibles (non-breaking spaces) y GHL descarta esas reglas.
- **Formulario de contacto:** hoy abre el cliente de correo con el mensaje prellenado. Para recibirlo directo hay que conectar un servicio (Formspree, Resend, etc.).
+26 -8
View File
@@ -3,11 +3,12 @@ import { mkdir, rm, writeFile, cp, readFile } from 'node:fs/promises';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { join, dirname } from 'node:path'; import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { page, eyebrow, btn } from './src/layout.mjs'; import { page, eyebrow, btn, SITE } from './src/layout.mjs';
import { LOCALES, DEFAULT_LOCALE, localize, translatePage } from './src/i18n.mjs';
const root = dirname(fileURLToPath(import.meta.url)); const root = dirname(fileURLToPath(import.meta.url));
const out = join(root, 'dist'); const out = join(root, 'dist');
const pages = ['home', 'about', 'portfolio', 'consulting', 'marketing', 'business-setup', 'business-optimization', 'supply-chain', 'cybersecurity', 'education', 'fractional', 'contact', 'press']; const pages = ['home', 'about', 'portfolio', 'consulting', 'marketing', 'business-setup', 'business-optimization', 'supply-chain', 'cybersecurity', 'education', 'fractional', 'contact', 'book', 'press'];
await rm(out, { recursive: true, force: true }); await rm(out, { recursive: true, force: true });
await mkdir(out, { recursive: true }); await mkdir(out, { recursive: true });
@@ -15,18 +16,35 @@ await cp(join(root, 'static'), out, { recursive: true });
// Fingerprint CSS/JS so a new deploy never reuses a stale cached copy. // Fingerprint CSS/JS so a new deploy never reuses a stale cached copy.
const hashOf = async (f) => createHash('sha256').update(await readFile(join(root, 'static', f))).digest('hex').slice(0, 10); const hashOf = async (f) => createHash('sha256').update(await readFile(join(root, 'static', f))).digest('hex').slice(0, 10);
const assetVersions = Object.fromEntries(await Promise.all(['styles.css', 'site.js', 'home.css', 'home.js', 'fonts.css'].map(async (f) => ['/assets/' + f, await hashOf('assets/' + f)]))); const assetVersions = Object.fromEntries(await Promise.all(['styles.css', 'site.js', 'home.css', 'home.js', 'fonts.css', 'book.js', 'rtl.css'].map(async (f) => ['/assets/' + f, await hashOf('assets/' + f)])));
const fingerprint = (html) => Object.entries(assetVersions).reduce((h, [u, v]) => h.replaceAll(`"${u}"`, `"${u}?v=${v}"`), html); const fingerprint = (html) => Object.entries(assetVersions).reduce((h, [u, v]) => h.replaceAll(`"${u}"`, `"${u}?v=${v}"`), html);
// Spanish and Arabic pages are the English pages with their text swapped through src/i18n/<locale>.json.
const dicts = Object.fromEntries(await Promise.all(LOCALES.filter((l) => l !== DEFAULT_LOCALE).map(async (l) => [l, JSON.parse(await readFile(join(root, 'src/i18n', l + '.json'), 'utf8'))])));
const missing = {};
const urls = []; const urls = [];
for (const name of pages) { for (const name of pages) {
const def = (await import(`./src/pages/${name}.mjs`)).default; const def = (await import(`./src/pages/${name}.mjs`)).default;
const file = join(out, def.path, 'index.html'); for (const locale of LOCALES) {
let html = page(def, locale);
if (locale !== DEFAULT_LOCALE) {
const t = translatePage(html, locale, dicts[locale]);
html = t.html;
for (const s of t.missing) (missing[locale] ??= new Set()).add(s);
}
const file = join(out, localize(def.path, locale), 'index.html');
await mkdir(dirname(file), { recursive: true }); await mkdir(dirname(file), { recursive: true });
await writeFile(file, fingerprint(page(def))); await writeFile(file, fingerprint(html));
urls.push(def.path); }
if (!def.noindex) urls.push(def.path);
console.log('built', def.path); console.log('built', def.path);
} }
for (const [l, set] of Object.entries(missing)) {
console.warn(`\n${l}: ${set.size} strings without translation (shown in English):`);
for (const s of set) console.warn(' ' + JSON.stringify(s));
}
if (process.argv.includes('--strict') && Object.keys(missing).length) process.exit(1);
await writeFile(join(out, '404.html'), fingerprint(page({ await writeFile(join(out, '404.html'), fingerprint(page({
path: '/404/', path: '/404/',
@@ -37,8 +55,8 @@ await writeFile(join(out, '404.html'), fingerprint(page({
const today = new Date().toISOString().slice(0, 10); const today = new Date().toISOString().slice(0, 10);
await writeFile(join(out, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?> await writeFile(join(out, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls.map((u) => ` <url><loc>https://dylanmazzei.com${u}</loc><lastmod>${today}</lastmod></url>`).join('\n')} ${urls.flatMap((u) => LOCALES.map((l) => ` <url><loc>${SITE.url}${localize(u, l)}</loc><lastmod>${today}</lastmod>${LOCALES.map((a) => `<xhtml:link rel="alternate" hreflang="${a}" href="${SITE.url}${localize(u, a)}"/>`).join('')}</url>`)).join('\n')}
</urlset> </urlset>
`); `);
await writeFile(join(out, 'robots.txt'), 'User-agent: *\nAllow: /\nSitemap: https://dylanmazzei.com/sitemap.xml\n'); await writeFile(join(out, 'robots.txt'), 'User-agent: *\nAllow: /\nSitemap: https://dylanmazzei.com/sitemap.xml\n');
+99
View File
@@ -0,0 +1,99 @@
/* Custom CSS for the GoHighLevel "Dylan – Lead Form" (xm10A7alZrxeKDtkMkD2).
Readable source. Paste ghl/lead-form.min.css (same rules, no indentation) into GHL:
copying indented CSS through Slack turns spaces into non-breaking spaces, which breaks the rules.
It makes the embedded form match the site's dark contact page: transparent
background, dark fields, two-column name/email row, brand-blue button.
GHL writes its own styles with !important and strong selectors, hence the long ones here. */
@import url("https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&display=swap");
html, body, .ghl-form-wrap { background: transparent !important; }
#_builder-form {
background: transparent !important;
border: 0 !important;
box-shadow: none !important;
padding: 0 !important;
max-width: none !important;
}
.ghl-form-wrap { max-width: none !important; }
#_builder-form, #_builder-form * { font-family: "Inter Tight", "Inter", system-ui, sans-serif !important; }
/* Name and email side by side, everything else full width */
#_builder-form .fields-container {
display: grid !important;
grid-template-columns: 1fr 1fr;
column-gap: 16px;
}
#_builder-form .form-field-wrapper { grid-column: 1 / -1; width: 100% !important; max-width: none !important; padding: 0 !important; margin-bottom: 18px !important; }
#_builder-form .form-field-wrapper:nth-child(1) { grid-column: 1; }
#_builder-form .form-field-wrapper:nth-child(2) { grid-column: 2; }
@media (max-width: 400px) {
#_builder-form .form-field-wrapper:nth-child(1),
#_builder-form .form-field-wrapper:nth-child(2) { grid-column: 1 / -1; }
}
/* Labels */
#_builder-form .form-builder--item label {
display: block !important;
line-height: 1.4 !important;
margin-top: 0 !important;
font-size: 13px !important;
font-weight: 400 !important;
color: #8896b3 !important;
margin-bottom: 8px !important;
}
#_builder-form .form-builder--item label span { color: #1fb7ff !important; font-size: inherit !important; line-height: inherit !important; vertical-align: baseline !important; }
/* Fields */
html body #_builder-form .form-builder--item .form-control[name][class] {
height: 52px !important;
padding: 14px 16px !important;
font-size: 16px !important;
font-weight: 500 !important;
color: #f3f7ff !important;
background: rgba(255, 255, 255, 0.05) !important;
border: 1px solid rgba(255, 255, 255, 0.06) !important;
border-radius: 16px !important;
box-shadow: none !important;
transition: border-color .15s ease, box-shadow .15s ease, background-color .15s ease;
}
html body #_builder-form .form-builder--item textarea.form-control[name][class] { height: auto !important; min-height: 150px; }
html body #_builder-form .form-builder--item .form-control[name][class]::placeholder { color: #6c7894 !important; opacity: 1; }
html body #_builder-form .form-builder--item .form-control[name][class]:focus {
outline: none !important;
background: rgba(255, 255, 255, 0.07) !important;
border-color: #1fb7ff !important;
box-shadow: 0 0 0 4px rgba(31, 183, 255, 0.14) !important;
}
html body #_builder-form .form-builder--item .email-input .form-control[name][class] { padding-left: 44px !important; }
#_builder-form .input-icon { left: 16px !important; color: #6c7894 !important; }
#_builder-form input:-webkit-autofill {
-webkit-text-fill-color: #f3f7ff !important;
-webkit-box-shadow: 0 0 0 1000px #0d1220 inset !important;
}
/* Submit button */
#_builder-form .form-builder--btn-submit { margin-top: 6px !important; }
#_builder-form .button-element {
min-height: 50px !important;
background: #1fb7ff !important;
border: 0 !important;
border-radius: 12px !important;
box-shadow: none !important;
transition: background-color .15s ease, transform .15s ease;
}
#_builder-form .button-element:hover { background: #5cc9ff !important; transform: translateY(-1px); }
#_builder-form .button-element .button-text div {
color: #04101c !important;
font-size: 13px !important;
font-weight: 600 !important;
letter-spacing: 0.08em !important;
text-transform: uppercase !important;
}
/* Footer text (privacy / terms links) and error messages */
#_builder-form .text-element { color: #6c7894 !important; font-size: 13px !important; }
#_builder-form .text-element a { color: #8896b3 !important; }
#_builder-form .error, #_builder-form .text-danger { color: #ff7a8a !important; }
+1
View File
@@ -0,0 +1 @@
@import url("https://fonts.googleapis.com/css2?family=Inter+Tight:wght@400;500;600;700&display=swap");html,body,.ghl-form-wrap{background:transparent!important}#_builder-form{background:transparent!important;border:0!important;box-shadow:none!important;padding:0!important;max-width:none!important}.ghl-form-wrap{max-width:none!important}#_builder-form,#_builder-form *{font-family:"Inter Tight","Inter",system-ui,sans-serif!important}#_builder-form .fields-container{display:grid!important;grid-template-columns:1fr 1fr;column-gap:16px}#_builder-form .form-field-wrapper{grid-column:1 / -1;width:100%!important;max-width:none!important;padding:0!important;margin-bottom:18px!important}#_builder-form .form-field-wrapper:nth-child(1){grid-column:1}#_builder-form .form-field-wrapper:nth-child(2){grid-column:2}@media (max-width:400px){#_builder-form .form-field-wrapper:nth-child(1),#_builder-form .form-field-wrapper:nth-child(2){grid-column:1 / -1}}#_builder-form .form-builder--item label{display:block!important;line-height:1.4!important;margin-top:0!important;font-size:13px!important;font-weight:400!important;color:#8896b3!important;margin-bottom:8px!important}#_builder-form .form-builder--item label span{color:#1fb7ff!important;font-size:inherit!important;line-height:inherit!important;vertical-align:baseline!important}html body #_builder-form .form-builder--item .form-control[name][class]{height:52px!important;padding:14px 16px!important;font-size:16px!important;font-weight:500!important;color:#f3f7ff!important;background:rgba(255,255,255,0.05)!important;border:1px solid rgba(255,255,255,0.06)!important;border-radius:16px!important;box-shadow:none!important;transition:border-color .15s ease,box-shadow .15s ease,background-color .15s ease}html body #_builder-form .form-builder--item textarea.form-control[name][class]{height:auto!important;min-height:150px}html body #_builder-form .form-builder--item .form-control[name][class]::placeholder{color:#6c7894!important;opacity:1}html body #_builder-form .form-builder--item .form-control[name][class]:focus{outline:none!important;background:rgba(255,255,255,0.07)!important;border-color:#1fb7ff!important;box-shadow:0 0 0 4px rgba(31,183,255,0.14)!important}html body #_builder-form .form-builder--item .email-input .form-control[name][class]{padding-left:44px!important}#_builder-form .input-icon{left:16px!important;color:#6c7894!important}#_builder-form input:-webkit-autofill{-webkit-text-fill-color:#f3f7ff!important;-webkit-box-shadow:0 0 0 1000px #0d1220 inset!important}#_builder-form .form-builder--btn-submit{margin-top:6px!important}#_builder-form .button-element{min-height:50px!important;background:#1fb7ff!important;border:0!important;border-radius:12px!important;box-shadow:none!important;transition:background-color .15s ease,transform .15s ease}#_builder-form .button-element:hover{background:#5cc9ff!important;transform:translateY(-1px)}#_builder-form .button-element .button-text div{color:#04101c!important;font-size:13px!important;font-weight:600!important;letter-spacing:0.08em!important;text-transform:uppercase!important}#_builder-form .text-element{color:#6c7894!important;font-size:13px!important}#_builder-form .text-element a{color:#8896b3!important}#_builder-form .error,#_builder-form .text-danger{color:#ff7a8a!important}
-17
View File
@@ -1,17 +0,0 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_types text/css application/javascript image/svg+xml application/xml;
location /assets/ {
add_header Cache-Control "public, max-age=604800, stale-while-revalidate=86400";
}
location / {
try_files $uri $uri/index.html =404;
}
error_page 404 /404.html;
}
+23
View File
@@ -0,0 +1,23 @@
// Lists every English string the translated pages need, and which ones a dictionary still lacks.
// node scripts/i18n-extract.mjs → counts per language
// node scripts/i18n-extract.mjs es out.json → writes the missing Spanish strings as {english: ""}
import { readFile, writeFile } from 'node:fs/promises';
import { page } from '../src/layout.mjs';
import { JS_STRINGS, mapStrings } from '../src/i18n.mjs';
const pages = ['home', 'about', 'portfolio', 'consulting', 'marketing', 'business-setup', 'business-optimization', 'supply-chain', 'cybersecurity', 'education', 'fractional', 'contact', 'book', 'press'];
const strings = new Set();
for (const name of pages) {
const def = (await import(`../src/pages/${name}.mjs`)).default;
mapStrings(page(def, 'es'), (s) => (strings.add(s), s));
}
JS_STRINGS.forEach((s) => strings.add(s));
const [locale, outFile] = process.argv.slice(2);
for (const l of locale ? [locale] : ['es', 'ar']) {
const dict = JSON.parse(await readFile(new URL(`../src/i18n/${l}.json`, import.meta.url), 'utf8'));
const todo = [...strings].filter((s) => !Object.hasOwn(dict, s));
const unused = Object.keys(dict).filter((k) => !strings.has(k));
console.log(`${l}: ${strings.size} strings, ${todo.length} missing, ${unused.length} unused`);
if (outFile) await writeFile(outFile, JSON.stringify(Object.fromEntries(todo.map((s) => [s, ''])), null, 1) + '\n');
}
+97
View File
@@ -0,0 +1,97 @@
// Languages. English is served from the root (/about/); Spanish and Arabic carry a prefix (/es/about/).
// Pages are written in English; each translated page is the English HTML with its text swapped
// through the dictionaries in src/i18n/<locale>.json (English text → translation).
export const LOCALES = ['en', 'es', 'ar'];
export const DEFAULT_LOCALE = 'en';
export const LOCALE_NAMES = { en: 'English', es: 'Español', ar: 'العربية' };
export const LOCALE_SHORT = { en: 'EN', es: 'ES', ar: 'AR' };
export const OG_LOCALE = { en: 'en_US', es: 'es_ES', ar: 'ar_AE' };
export const dirOf = (locale) => (locale === 'ar' ? 'rtl' : 'ltr');
/** Prefix an internal path with the locale. */
export const localize = (path, locale) => (locale === DEFAULT_LOCALE ? path : `/${locale}${path}`);
// Strings set by the browser scripts. Each page gets the ones for its language in window.__i18n.
export const JS_STRINGS = [
'Switch to light mode', 'Switch to dark mode', 'Open menu', 'Close menu', 'Copied',
'Your email app should open with the message ready to send.',
'AI Software', 'Marketing & AI Search', 'Education Technology', 'Cybersecurity', 'E-Commerce',
'MARKETING & AI SEARCH', 'AI SOFTWARE', 'EDUCATION TECHNOLOGY', 'CYBERSECURITY', 'E-COMMERCE',
'Leading teams through a constantly changing search landscape.',
'Building around new opportunities created by AI.',
'Creating technology designed for real classrooms.',
'Solving security challenges for growing businesses.',
'Turning product ideas into businesses built around real customers.',
];
// Tags that stay inside a translatable segment; any other tag ends it.
const INLINE = new Set(['b', 'strong', 'i', 'em', 'u', 'br', 'sup', 'sub', 'mark', 'abbr', 'wbr']);
const ATTRS = ['alt', 'aria-label', 'title', 'placeholder'];
const META = new Set(['description', 'og:title', 'og:description', 'twitter:title', 'twitter:description']);
const TOKEN = /<!--[\s\S]*?-->|<(script|style|svg)\b[\s\S]*?<\/\1>|<[^>]+>|[^<]+/gi;
export const norm = (s) => s.replace(/\s+/g, ' ').trim();
const hasWords = (s) => /\p{L}/u.test(s.replace(/<[^>]*>|&[a-z#0-9]+;/gi, ''));
const tagName = (t) => (t.match(/^<\/?([a-z0-9-]+)/i) || [])[1]?.toLowerCase();
/**
* Walk the HTML and call `fn(text)` for every translatable string: runs of text with inline
* markup, translatable attributes and meta descriptions. `fn` returns the replacement.
*/
export function mapStrings(html, fn) {
const out = [];
let seg = [];
const flush = () => {
if (!seg.length) return;
const raw = seg.join('');
seg = [];
if (!hasWords(raw)) return out.push(raw);
const lead = raw.match(/^\s*/)[0], trail = raw.slice(lead.length).match(/\s*$/)[0];
out.push(lead + fn(norm(raw)) + trail);
};
for (const [tok, block] of html.matchAll(TOKEN)) {
if (block || tok.startsWith('<!')) { flush(); out.push(tok); continue; }
if (tok[0] !== '<') { seg.push(tok); continue; }
const name = tagName(tok);
if (INLINE.has(name) && !/\b(class|id|data-)/.test(tok)) { seg.push(tok); continue; }
flush();
out.push(mapAttrs(tok, name, fn));
}
flush();
return out.join('');
}
function mapAttrs(tag, name, fn) {
if (/\slang="/.test(tag)) return tag; // the language switch names each language in its own script
let t = tag.replace(new RegExp(`\\s(${ATTRS.join('|')})="([^"]*)"`, 'g'), (m, a, v) =>
hasWords(v) ? ` ${a}="${fn(norm(v))}"` : m);
if (name === 'meta') {
const key = (t.match(/\s(?:name|property)="([^"]+)"/) || [])[1];
if (META.has(key)) t = t.replace(/\scontent="([^"]*)"/, (m, v) => ` content="${fn(norm(v))}"`);
}
return t;
}
/** Point internal page links at the locale's copy. Links marked with hreflang are left alone. */
export function localizeLinks(html, locale) {
if (locale === DEFAULT_LOCALE) return html;
return html.replace(/<a\b[^>]*>/g, (tag) =>
/\shreflang=/.test(tag) ? tag : tag.replace(/\shref="(\/(?![^"#?]*\.[a-z0-9]+(?:[#?"]))[^"]*)"/i, (m, p) => ` href="${localize(p, locale)}"`));
}
/** Translate a rendered English page. Returns the HTML and the strings with no translation. */
export function translatePage(html, locale, dict) {
const missing = new Set();
const tr = (s) => {
if (Object.hasOwn(dict, s)) return dict[s];
missing.add(s);
return s;
};
const jsDict = Object.fromEntries(JS_STRINGS.filter((s) => Object.hasOwn(dict, s)).map((s) => [s, dict[s]]));
// Leave <head>'s inline scripts and JSON-LD alone; translate the rest.
let out = mapStrings(html, tr);
out = localizeLinks(out, locale);
out = out.replace('<script>window.__i18n={}</script>', `<script>window.__i18n=${JSON.stringify(jsDict).replace(/</g, '\\u003c')}</script>`);
return { html: out, missing: [...missing] };
}
+1338
View File
File diff suppressed because it is too large Load Diff
+1338
View File
File diff suppressed because it is too large Load Diff
+96 -16
View File
@@ -1,14 +1,25 @@
// Shared layout, icons and small HTML helpers for every page. // Shared layout, icons and small HTML helpers for every page.
import { LOCALES, LOCALE_NAMES, LOCALE_SHORT, OG_LOCALE, dirOf, localize } from './i18n.mjs';
export const SITE = { export const SITE = {
url: 'https://dylanmazzei.com', url: 'https://dylanmazzei.com',
gaId: 'G-P9H24L6WM5',
email: 'dylan@dylanmazzei.com', email: 'dylan@dylanmazzei.com',
pressEmail: 'press@dylanmazzei.com', pressEmail: 'press@dylanmazzei.com',
linkedin: 'https://www.linkedin.com/in/dylanmazzei/', linkedin: 'https://www.linkedin.com/in/dylanmazzei/',
instagram: 'https://www.instagram.com/thedylanmazzei', instagram: 'https://www.instagram.com/thedylanmazzei',
// Replace with the real scheduling link (Calendly, Cal.com, etc.). // GoHighLevel (LeadConnector) widgets. Contacts land in Dylan's GHL account.
calendar: 'mailto:dylan@dylanmazzei.com?subject=Scheduling%20a%20conversation', ghl: {
form: 'xm10A7alZrxeKDtkMkD2', // "Dylan – Lead Form"
freeCalendar: '81JqpCRit6APOwavApzh', // Free 15-min intro call with Dylan's team
vipCalendar: 'lK9IYtCRGIuA7W93WjMF', // 15-min 1:1 with Dylan ($400)
},
// Same offices as aigrowthtechnologies.com/contact.
offices: [
{ city: 'Dubai', tag: 'Global HQ', desc: 'Our global headquarters and innovation hub in the MENA region.', address: ['Blue Bay Tower, Office 312, 3rd Floor', 'Marasi Drive Street, Business Bay', 'Dubai, UAE'], phone: '+971 55 647 0342' },
{ city: 'Houston', tag: 'Partner Office', desc: 'A US point of contact for businesses across Texas and beyond.', address: ['Downtown Houston, TX, USA'], phone: '+1 832-350-0465' },
{ city: 'Miami', tag: 'Partner Office', desc: 'A US point of contact based in South Florida.', address: ['Brickell Avenue, FL, USA'], phone: '+1 954-235-4974' },
{ city: 'New York City', tag: 'Partner Office', desc: 'A US point of contact in one of the world’s leading business markets.', address: ['Manhattan Financial District, NY, USA'], phone: '+1 713-289-4577' },
],
}; };
const ICONS = { const ICONS = {
@@ -56,7 +67,9 @@ const SMALL = new Set(['arrow', 'arrow-ur', 'check', 'plus', 'chev', 'brace']);
export function icon(name, cls = '') { export function icon(name, cls = '') {
const vb = SMALL.has(name) ? '0 0 16 16' : name === 'menu' || name === 'close' ? '0 0 18 18' : '0 0 24 24'; const vb = SMALL.has(name) ? '0 0 16 16' : name === 'menu' || name === 'close' ? '0 0 18 18' : '0 0 24 24';
const sw = SMALL.has(name) || name === 'menu' || name === 'close' ? '' : ' stroke-width="1.75"'; const sw = SMALL.has(name) || name === 'menu' || name === 'close' ? '' : ' stroke-width="1.75"';
return `<svg class="${cls}" viewBox="${vb}" fill="none" stroke="currentColor"${sw} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`; // Arrows point the other way on right-to-left pages (rtl.css).
const c = name.startsWith('arrow') ? (cls ? cls + ' flip' : 'flip') : cls;
return `<svg class="${c}" viewBox="${vb}" fill="none" stroke="currentColor"${sw} stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICONS[name]}</svg>`;
} }
// Dylan Mazzei monogram: italic "D" (inherits text color) and the sky-blue blade. // Dylan Mazzei monogram: italic "D" (inherits text color) and the sky-blue blade.
@@ -106,7 +119,16 @@ const NAV = [
{ label: 'Press', href: '/press/' }, { label: 'Press', href: '/press/' },
]; ];
function nav(path) { // EN · ES · AR, floating bottom corner on every page. Plain links: each language is its own static page.
function langSwitch(path, locale) {
return `<nav class="lang-switch" aria-label="Language">${LOCALES.map((l) =>
l === locale
? `<span aria-current="true" lang="${l}" title="${LOCALE_NAMES[l]}">${LOCALE_SHORT[l]}</span>`
: `<a href="${localize(path, l)}" hreflang="${l}" lang="${l}" title="${LOCALE_NAMES[l]}">${LOCALE_SHORT[l]}</a>`
).join('')}</nav>`;
}
function nav(path, locale) {
const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href)); const isActive = (href) => (href === '/' ? path === '/' : path.startsWith(href));
const desktop = NAV.map((item) => { const desktop = NAV.map((item) => {
if (!item.children) { if (!item.children) {
@@ -188,15 +210,17 @@ function footer() {
<span class="eyebrow">WHERE I WORK</span> <span class="eyebrow">WHERE I WORK</span>
<p class="places">Texas<i>•</i>Florida<i>•</i>New York<i>•</i>Dubai</p> <p class="places">Texas<i>•</i>Florida<i>•</i>New York<i>•</i>Dubai</p>
</div> </div>
<small>© ${new Date().getFullYear()} Dylan Mazzei</small> <small>© <span>${new Date().getFullYear()}</span> Dylan Mazzei</small>
</div> </div>
</div> </div>
<div class="foot-word" aria-hidden="true">DYLAN MAZZEI</div> <div class="foot-word" aria-hidden="true">DYLAN MAZZEI</div>
</footer>`; </footer>`;
} }
export function page({ path, title, description, body, schema = [], head = '', scripts = '' }) { export function page({ path, title, description, body, schema = [], head = '', scripts = '', noindex = false, ctaBands = true }, locale = 'en') {
const canonical = SITE.url + path; const canonical = SITE.url + localize(path, locale);
const alternates = noindex ? '' : [...LOCALES.map((l) => [l, l]), ['x-default', 'en']]
.map(([hl, l]) => `\n<link rel="alternate" hreflang="${hl}" href="${SITE.url + localize(path, l)}">`).join('');
const person = { const person = {
'@context': 'https://schema.org', '@context': 'https://schema.org',
'@type': 'Person', '@type': 'Person',
@@ -210,19 +234,18 @@ export function page({ path, title, description, body, schema = [], head = '', s
}; };
const ld = [person, ...schema].map((s) => `<script type="application/ld+json">${JSON.stringify(s)}</script>`).join('\n'); const ld = [person, ...schema].map((s) => `<script type="application/ld+json">${JSON.stringify(s)}</script>`).join('\n');
return `<!doctype html> return `<!doctype html>
<html lang="en"> <html lang="${locale}" dir="${dirOf(locale)}">
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
<script async src="https://www.googletagmanager.com/gtag/js?id=${SITE.gaId}"></script>
<script>window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments)}gtag('js',new Date());gtag('config','${SITE.gaId}');</script>
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<title>${title}</title> <title>${title}</title>
<meta name="description" content="${description}"> <meta name="description" content="${description}">
<link rel="canonical" href="${canonical}"> <link rel="canonical" href="${canonical}">${alternates}${noindex ? '\n<meta name="robots" content="noindex">' : ''}
<meta name="theme-color" content="#04060c"> <meta name="theme-color" content="#04060c">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta property="og:type" content="website"> <meta property="og:type" content="website">
<meta property="og:site_name" content="Dylan Mazzei"> <meta property="og:site_name" content="Dylan Mazzei">
<meta property="og:locale" content="${OG_LOCALE[locale]}">
<meta property="og:title" content="${title}"> <meta property="og:title" content="${title}">
<meta property="og:description" content="${description}"> <meta property="og:description" content="${description}">
<meta property="og:url" content="${canonical}"> <meta property="og:url" content="${canonical}">
@@ -231,28 +254,85 @@ export function page({ path, title, description, body, schema = [], head = '', s
<meta name="twitter:title" content="${title}"> <meta name="twitter:title" content="${title}">
<meta name="twitter:description" content="${description}"> <meta name="twitter:description" content="${description}">
<meta name="twitter:image" content="${SITE.url}/assets/og.jpg"> <meta name="twitter:image" content="${SITE.url}/assets/og.jpg">
<link rel="icon" href="/favicon.svg" type="image/svg+xml"> <link rel="icon" href="/favicon.ico" sizes="48x48">
<link rel="icon" href="/icon-192.png" type="image/png" sizes="192x192">
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Unbounded:wght@700&display=swap"> <link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@500&family=Unbounded:wght@700&display=swap">
<link rel="preload" as="font" type="font/woff2" crossorigin href="/assets/fonts/inter-tight-latin-500-normal.woff2"> <link rel="preload" as="font" type="font/woff2" crossorigin href="/assets/fonts/inter-tight-latin-500-normal.woff2">
<link rel="stylesheet" href="/assets/fonts.css"> <link rel="stylesheet" href="/assets/fonts.css">
<link rel="stylesheet" href="/assets/styles.css"> <link rel="stylesheet" href="/assets/styles.css">${locale === 'ar' ? '\n<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Noto+Kufi+Arabic:wght@600;700&family=Noto+Sans+Arabic:wght@400;500;600;700&display=swap">\n<link rel="stylesheet" href="/assets/rtl.css">' : ''}
${head} ${head}
<script>(function(){var r=document.documentElement;try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')r.setAttribute('data-theme',t)}catch(e){}if(!r.hasAttribute('data-theme'))r.setAttribute('data-theme','dark')})();document.documentElement.classList.add('js');setTimeout(function(){if(!window.__site)document.documentElement.classList.remove('js')},2500)</script> <script>(function(){var r=document.documentElement;try{var t=localStorage.getItem('theme');if(t==='dark'||t==='light')r.setAttribute('data-theme',t)}catch(e){}if(!r.hasAttribute('data-theme'))r.setAttribute('data-theme','dark')})();document.documentElement.classList.add('js');setTimeout(function(){if(!window.__site)document.documentElement.classList.remove('js')},2500)</script>
<script>window.__i18n={}</script>
${ld} ${ld}
</head> </head>
<body> <body>
<div class="bgfx" aria-hidden="true"></div> <div class="bgfx" aria-hidden="true"></div>
<div class="progress" id="progress" aria-hidden="true"></div> <div class="progress" id="progress" aria-hidden="true"></div>
${nav(path)} ${nav(path, locale)}
<main id="main"> <main id="main">
${body} ${ctaBands === false ? body : withCtaBands(body)}
</main> </main>
${footer()} ${footer()}
${langSwitch(path, locale)}
<script src="/assets/site.js" defer></script> <script src="/assets/site.js" defer></script>
${scripts} ${scripts}
</body> </body>
</html> </html>
`; `;
} }
// GoHighLevel embeds. form_embed.js resizes the iframes to their content.
export const GHL_SCRIPT = '<script src="https://link.msgsndr.com/js/form_embed.js" defer></script>';
export const ghlForm = (id, name) =>
`<iframe class="ghl-frame" src="https://api.leadconnectorhq.com/widget/form/${id}" id="inline-${id}" title="${name}" data-layout="{'id':'INLINE'}" data-trigger-type="alwaysShow" data-trigger-value="" data-activation-type="alwaysActivated" data-activation-value="" data-deactivation-type="neverDeactivate" data-deactivation-value="" data-form-name="${name}" data-layout-iframe-id="inline-${id}" data-form-id="${id}"></iframe>`;
export const ghlCalendar = (id, title) =>
`<iframe class="ghl-frame ghl-cal" src="https://api.leadconnectorhq.com/widget/booking/${id}" id="${id}_cal" title="${title}" scrolling="no"></iframe>`;
// Call-to-action band dropped between sections so a booking link is always close while scrolling.
export const ctaBand = () => `
<aside class="cta-band" aria-label="Start a conversation">
<div class="wrap cta-band-inner reveal">
<div class="cta-band-copy">
<p class="cta-band-title">Have Something Worth Talking About?</p>
<p class="cta-band-body">Book a call with my team or with me directly. It starts with understanding where your business stands.</p>
</div>
<div class="btn-row">${btn('START A CONVERSATION', '/contact/')}</div>
</div>
</aside>
`;
/** Insert a band after the 2nd top-level section and every 3rd after that, keeping clear of the closing sections. */
function withCtaBands(body) {
const parts = body.split(/(?<=\n<\/section>\n)/);
if (parts.length < 4) return body;
return parts.map((part, i) => (i % 3 === 1 && i < parts.length - 3 ? part + ctaBand() : part)).join('');
}
/** Office cards, mirroring the offices section on aigrowthtechnologies.com/contact. */
export const officesSection = () => `
<section class="section" aria-labelledby="offices-title">
<div class="wrap">
<div class="sec-head reveal">
<div class="stack-sm">${eyebrow('GLOBAL PRESENCE')}<h2 id="offices-title" class="h-display">Wherever You’re Growing, Start the Conversation Here.</h2></div>
</div>
<div class="offices reveal">
${SITE.offices
.map(
(o) => `<article class="office">
<div class="office-head"><h3 class="h-md">${o.city}</h3><span class="tag tag-line">${o.tag}</span></div>
<p class="office-desc">${o.desc}</p>
<address class="office-address">
${o.address.map((l) => `<span>${l}</span>`).join('')}
<span><em>Phone</em><a href="tel:${o.phone.replace(/[^\d+]/g, '')}">${o.phone}</a></span>
<span><em>Email</em><a href="mailto:${SITE.email}">${SITE.email}</a></span>
</address>
</article>`
)
.join('')}
</div>
</div>
</section>
`;
+84
View File
@@ -0,0 +1,84 @@
import { icon, eyebrow, SITE, ghlCalendar, GHL_SCRIPT } from '../layout.mjs';
// Step 2 of the GoHighLevel booking flow. The lead form redirects here after submit,
// to /contact/book/?submitted=1. Visitors who did not come from the form see a prompt
// to fill it in instead of the calendars. This only hides the page: the calendars are
// still public GHL widgets, so the real limits (payment, availability) live in GHL.
// Copy mirrors the "Book Your Call" step Gabriela built in GoHighLevel.
const calls = [
{ id: 'free', tag: 'FREE', h: 'Free 15-min intro call with Dylan’s team', p: 'Discover how we can support your business goals and see if we are the right match for your journey.', cta: 'Book free call', cal: SITE.ghl.freeCalendar, icon: 'users' },
{ id: 'vip', tag: 'VIP', h: '15-min 1:1 with Dylan ($400)', p: 'Get direct strategic direction, tactical feedback on your bottlenecks, and bespoke 1:1 guidance.', cta: 'Book with Dylan', cal: SITE.ghl.vipCalendar, icon: 'spark', featured: true },
];
export default {
path: '/contact/book/',
ctaBands: false,
noindex: true,
head: `<script>
(function () {
// Gate: ?submitted=1 (from the GHL redirect) unlocks the calendars for this browser tab.
var root = document.documentElement, ok = false;
try {
var q = new URLSearchParams(location.search);
if (q.has('submitted')) {
sessionStorage.setItem('dm-form-sent', '1');
// GHL redirects to one URL, so send the visitor back to the language they filled the form in.
var lang = sessionStorage.getItem('dm-lang') || root.lang;
var want = (lang === 'en' ? '' : '/' + lang) + '/contact/book/';
if (want !== location.pathname && /^(en|es|ar)$/.test(lang)) { location.replace(want + location.hash); return; }
history.replaceState(null, '', location.pathname + location.hash);
}
ok = sessionStorage.getItem('dm-form-sent') === '1';
} catch (e) { ok = true; }
root.setAttribute('data-gate', ok ? 'open' : 'locked');
})();
</script>`,
title: 'Book Your Call | Dylan Mazzei',
description: 'Book a free 15-minute intro call with Dylan Mazzei’s team or a 15-minute 1:1 session with Dylan.',
// book.js before form_embed.js, so a locked page removes the calendars first.
scripts: '<script src="/assets/book.js" defer></script>\n' + GHL_SCRIPT,
body: `
<section class="page-hero" aria-labelledby="bk-hero">
<div class="wrap">
<div class="stack center anim" style="align-items:center;text-align:center;max-width:760px;margin:0 auto">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/contact/">Contact</a>${icon('chev')}<span>Book your call</span></nav>
${eyebrow('BOOK YOUR CALL')}
<div class="gate-open stack center" style="align-items:center">
<h1 id="bk-hero" class="h-display-lg">Thanks! Now choose your call.</h1>
<p class="lead">Pick the option that fits you best.</p>
</div>
<div class="gate-locked stack center" style="align-items:center">
<h2 class="h-display-lg">Tell Me a Bit About You First.</h2>
<p class="lead">Send the short form on the contact page and you’ll pick a time for your call right after.</p>
<a class="btn btn-primary" href="/contact/#inquiry">GO TO THE FORM<span class="go">${icon('arrow')}</span></a>
</div>
</div>
<div class="calls anim gate-open" role="tablist" aria-label="Call options">
${calls
.map(
(c, i) => `<button class="call${c.featured ? ' call-vip' : ''}" type="button" role="tab" id="tab-${c.id}" aria-controls="cal-${c.id}" aria-selected="${i === 0}" data-call="${c.id}">
<span class="call-top"><span class="tag ${c.featured ? 'tag-accent' : 'tag-white'}">${c.tag}</span><span class="ic">${icon(c.icon)}</span></span>
<span class="call-h">${c.h}</span>
<span class="call-p">${c.p}</span>
<span class="btn ${c.featured ? 'btn-primary' : 'btn-ghost'} btn-sm">${c.cta}<span class="go">${icon('arrow')}</span></span>
</button>`
)
.join('')}
</div>
</div>
</section>
<section class="section gate-open" id="schedule" aria-label="Choose a time" style="padding-top:0">
<div class="wrap">
${calls
.map(
(c, i) => `<div class="panel cal-panel" role="tabpanel" id="cal-${c.id}" aria-labelledby="tab-${c.id}"${i ? ' data-off' : ''}>
<div class="panel-bar"><span class="lights" aria-hidden="true"><i></i><i></i><i></i></span><strong>${c.h}</strong></div>
<div class="ghl-wrap ghl-wrap-cal">${ghlCalendar(c.cal, c.h)}</div>
</div>`
)
.join('')}
</div>
</section>
`,
};
+8 -22
View File
@@ -1,8 +1,10 @@
import { icon, eyebrow, btn, SITE } from '../layout.mjs'; import { icon, eyebrow, btn, SITE, ghlForm, GHL_SCRIPT, officesSection } from '../layout.mjs';
export default { export default {
path: '/contact/', path: '/contact/',
title: 'Contact Dylan Mazzei | Business &amp; Partnerships', title: 'Contact Dylan Mazzei | Business &amp; Partnerships',
scripts: GHL_SCRIPT,
ctaBands: false,
description: 'Contact Dylan Mazzei about business opportunities, strategic partnerships, consulting, media inquiries, or other conversations worth having.', description: 'Contact Dylan Mazzei about business opportunities, strategic partnerships, consulting, media inquiries, or other conversations worth having.',
body: ` body: `
<section class="page-hero" aria-labelledby="ct-hero"> <section class="page-hero" aria-labelledby="ct-hero">
@@ -28,40 +30,24 @@ export default {
</div> </div>
<div class="stack anim"> <div class="stack anim">
<form class="panel form-card" id="inquiry" data-to="${SITE.email}" novalidate> <div class="panel form-card ghl-card" id="inquiry">
<div class="panel-bar"><span class="lights" aria-hidden="true"><i></i><i></i><i></i></span><span class="panel-url">${SITE.email}</span></div> <div class="panel-bar"><span class="lights" aria-hidden="true"><i></i><i></i><i></i></span><span class="panel-url">${SITE.email}</span></div>
<div class="stack-xs"> <div class="stack-xs">
<h2 class="h-md">Send an Inquiry</h2> <h2 class="h-md">Send an Inquiry</h2>
<p class="body">Give me enough context to understand what you’re working on, where things stand, and what you’d like to discuss.</p> <p class="body">Give me enough context to understand what you’re working on, where things stand, and what you’d like to discuss.</p>
</div> </div>
<div class="form-grid"> <div class="ghl-wrap ghl-wrap-form">${ghlForm(SITE.ghl.form, 'Dylan – Lead Form')}</div>
<div class="field"><label for="f-name">Name</label><input id="f-name" name="name" type="text" autocomplete="name" required></div>
<div class="field"><label for="f-email">Email</label><input id="f-email" name="email" type="email" autocomplete="email" required></div>
<div class="field"><label for="f-company">Company</label><input id="f-company" name="company" type="text" autocomplete="organization"></div>
<div class="field"><label for="f-topic">Topic</label>
<select id="f-topic" name="topic">
<option value="">Select one</option>
<option>Business opportunity</option>
<option>Strategic partnership</option>
<option>Consulting</option>
<option>Media inquiry</option>
<option>Something else</option>
</select>
</div> </div>
<div class="field field-full"><label for="f-message">Message</label><textarea id="f-message" name="message" required></textarea></div>
</div>
<button class="btn btn-primary btn-block" type="submit">SEND AN INQUIRY<span class="go">${icon('arrow')}</span></button>
<p class="form-status" id="form-status" role="status" aria-live="polite"></p>
</form>
<a class="cal-card" href="${SITE.calendar}"> <a class="cal-card" href="#inquiry">
<div><h3>Rather Put It on the Calendar?</h3><p>If you already know a conversation makes sense, skip the back-and-forth and find a time that works.</p></div> <div><h3>Rather Put It on the Calendar?</h3><p>Send the form above and you’ll pick a time that works right after, no back-and-forth.</p></div>
<span class="go">${icon('calendar')}</span> <span class="go">${icon('calendar')}</span>
</a> </a>
</div> </div>
</div> </div>
</section> </section>
${officesSection()}
<section class="section" aria-labelledby="where"> <section class="section" aria-labelledby="where">
<div class="wrap"> <div class="wrap">
<div class="showcase reveal"> <div class="showcase reveal">
+4 -6
View File
@@ -3,7 +3,6 @@ import { icon, eyebrow, btn, textLink, mark } from '../layout.mjs';
const ventures = [ const ventures = [
['search', 'MARKETING &amp; AI SEARCH'], ['search', 'MARKETING &amp; AI SEARCH'],
['chip', 'AI SOFTWARE'], ['chip', 'AI SOFTWARE'],
['bottle', 'SPIRITS'],
['edu', 'EDUCATION TECHNOLOGY'], ['edu', 'EDUCATION TECHNOLOGY'],
['shield', 'CYBERSECURITY'], ['shield', 'CYBERSECURITY'],
['bag', 'E-COMMERCE'], ['bag', 'E-COMMERCE'],
@@ -35,7 +34,7 @@ const arrowUR = '<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stro
export default { export default {
path: '/', path: '/',
title: 'Dylan Mazzei | Founder, Operator &amp; Entrepreneur', title: 'Dylan Mazzei | Founder, Operator &amp; Entrepreneur',
description: 'Meet Dylan Mazzei, a founder and operator leading ventures across AI, software, spirits, education, cybersecurity, e-commerce, and more.', description: 'Meet Dylan Mazzei, a founder and operator leading ventures across AI, software, education, cybersecurity, e-commerce, and more.',
head: '<link rel="stylesheet" href="/assets/home.css">', head: '<link rel="stylesheet" href="/assets/home.css">',
scripts: '<script src="/assets/o2/three.min.js" defer></script>\n<script src="/assets/home.js" defer></script>', scripts: '<script src="/assets/o2/three.min.js" defer></script>\n<script src="/assets/home.js" defer></script>',
body: ` body: `
@@ -48,7 +47,7 @@ export default {
<div class="emblem" id="emblem"><img alt="Dylan Mazzei monogram" src="/assets/o2/dylan-monogram.webp" width="720" height="480"></div> <div class="emblem" id="emblem"><img alt="Dylan Mazzei monogram" src="/assets/o2/dylan-monogram.webp" width="720" height="480"></div>
${eyebrow('DYLAN MAZZEI | FOUNDER &amp; OPERATOR')} ${eyebrow('DYLAN MAZZEI | FOUNDER &amp; OPERATOR')}
<h1 id="hero-title"><span class="grad">One Good Idea Has Never</span> <span class="grad2">Been Enough for Me.</span></h1> <h1 id="hero-title"><span class="grad">One Good Idea Has Never</span> <span class="grad2">Been Enough for Me.</span></h1>
<p class="lead">I’ve spent the last eight years turning ideas into companies across marketing, AI, software, spirits, education, cybersecurity, e-commerce, and more. Four have been sold. Others are growing. And I’m nowhere near finished building.</p> <p class="lead">I’ve spent the last eight years turning ideas into companies across marketing, AI, software, education, cybersecurity, e-commerce, and more. Four have been sold. Others are growing. And I’m nowhere near finished building.</p>
<div class="btn-row">${btn('MY STORY', '/about/')}${btn('EXPLORE MY VENTURES', '#ventures', 'ghost')}</div> <div class="btn-row">${btn('MY STORY', '/about/')}${btn('EXPLORE MY VENTURES', '#ventures', 'ghost')}</div>
<div class="icon-row" id="iconRow" aria-hidden="true"></div> <div class="icon-row" id="iconRow" aria-hidden="true"></div>
</div> </div>
@@ -114,12 +113,11 @@ export default {
<div class="vhead reveal"> <div class="vhead reveal">
${eyebrow('CURRENT VENTURES')} ${eyebrow('CURRENT VENTURES')}
<h2 id="ventures-title" class="sr-only">Current ventures</h2> <h2 id="ventures-title" class="sr-only">Current ventures</h2>
<p class="lead">Today, I lead businesses across AI, marketing, software, spirits, education technology, cybersecurity, and e-commerce. They operate in very different worlds, which keeps me curious.</p> <p class="lead">Today, I lead businesses across AI, marketing, software, education technology, cybersecurity, and e-commerce. They operate in very different worlds, which keeps me curious.</p>
</div> </div>
<ul class="sr-only"> <ul class="sr-only">
<li>MARKETING &amp; AI SEARCH Leading teams through a constantly changing search landscape.</li> <li>MARKETING &amp; AI SEARCH Leading teams through a constantly changing search landscape.</li>
<li>AI SOFTWARE Building around new opportunities created by AI.</li> <li>AI SOFTWARE Building around new opportunities created by AI.</li>
<li>SPIRITS Taking a consumer brand from idea to market.</li>
<li>EDUCATION TECHNOLOGY Creating technology designed for real classrooms.</li> <li>EDUCATION TECHNOLOGY Creating technology designed for real classrooms.</li>
<li>CYBERSECURITY Solving security challenges for growing businesses.</li> <li>CYBERSECURITY Solving security challenges for growing businesses.</li>
<li>E-COMMERCE Turning product ideas into businesses built around real customers.</li> <li>E-COMMERCE Turning product ideas into businesses built around real customers.</li>
@@ -127,7 +125,7 @@ export default {
<div class="vtabs reveal" id="vtabs" role="tablist" aria-label="Ventures"></div> <div class="vtabs reveal" id="vtabs" role="tablist" aria-label="Ventures"></div>
<div class="vpanel reveal" id="vpanel"> <div class="vpanel reveal" id="vpanel">
<div class="vinfo"> <div class="vinfo">
<div><span class="vnum" id="vnum">01 / 06</span></div> <div><span class="vnum" id="vnum">01 / 05</span></div>
<div class="vt" id="vt" aria-live="polite"><h3 id="vname">MARKETING &amp; AI SEARCH</h3><p id="vdesc">Leading teams through a constantly changing search landscape.</p></div> <div class="vt" id="vt" aria-live="polite"><h3 id="vname">MARKETING &amp; AI SEARCH</h3><p id="vdesc">Leading teams through a constantly changing search landscape.</p></div>
<div class="btn-row">${btn('EXPLORE MY VENTURES', '/portfolio/', 'primary', 'btn-sm')}</div> <div class="btn-row">${btn('EXPLORE MY VENTURES', '/portfolio/', 'primary', 'btn-sm')}</div>
</div> </div>
Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+28
View File
@@ -0,0 +1,28 @@
(function () {
// Without the lead form, drop the calendars before GHL's form_embed.js picks them up.
// The iframes keep their src in the HTML: form_embed.js only sizes iframes that already
// point at /booking when it runs, and an unsized calendar hides its confirm button.
if (document.documentElement.getAttribute('data-gate') !== 'open') {
document.querySelectorAll('iframe.ghl-cal').forEach(function (f) { f.remove(); });
return;
}
var tabs = document.querySelectorAll('[data-call]');
var select = function (id, scroll) {
tabs.forEach(function (t) {
var on = t.getAttribute('data-call') === id;
t.setAttribute('aria-selected', String(on));
var panel = document.getElementById('cal-' + t.getAttribute('data-call'));
if (panel) { if (on) panel.removeAttribute('data-off'); else panel.setAttribute('data-off', ''); }
});
if (scroll) document.getElementById('schedule').scrollIntoView({ behavior: 'smooth', block: 'start' });
};
tabs.forEach(function (t) {
t.addEventListener('click', function () {
var id = t.getAttribute('data-call');
select(id, true);
try { history.replaceState(null, '', '#' + id); } catch (e) {}
});
});
var hash = location.hash.replace('#', '');
if (hash === 'free' || hash === 'vip') select(hash, false);
})();
+11 -21
View File
@@ -8,18 +8,18 @@
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches; const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
const clamp = (v, a, b) => Math.min(b, Math.max(a, v)); const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const lerp = (a, b, t) => a + (b - a) * t; const lerp = (a, b, t) => a + (b - a) * t;
const T = (s) => (window.__i18n && window.__i18n[s]) || s;
/* ---------- hero icons: one per venture ---------- */ /* ---------- hero icons: one per venture ---------- */
const svg = (p) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + p + '</svg>'; const svg = (p) => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">' + p + '</svg>';
const ICONS = [ const ICONS = [
{ n: 'AI Software', side: 'l', x: '8%', y: '24%', r: '6deg', d: '0s', p: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/><path d="M9.6 15v-3.6L12 9.4l2.4 2V15M9.6 13.4h4.8"/>' }, { n: 'AI Software', side: 'l', x: '8%', y: '24%', r: '6deg', d: '0s', p: '<rect x="6" y="6" width="12" height="12" rx="2.5"/><path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3"/><path d="M9.6 15v-3.6L12 9.4l2.4 2V15M9.6 13.4h4.8"/>' },
{ n: 'Marketing & AI Search', side: 'l', x: '15%', y: '50%', r: '-7deg', d: '-2s', p: '<circle cx="10.5" cy="10.5" r="6.2"/><path d="m15.2 15.2 5 5"/><path d="m7.6 12.6 2.2-2.4 1.8 1.6 2.6-3"/>' }, { n: 'Marketing & AI Search', side: 'l', x: '15%', y: '50%', r: '-7deg', d: '-2s', p: '<circle cx="10.5" cy="10.5" r="6.2"/><path d="m15.2 15.2 5 5"/><path d="m7.6 12.6 2.2-2.4 1.8 1.6 2.6-3"/>' },
{ n: 'Spirits', side: 'l', x: '6%', y: '73%', r: '8deg', d: '-4s', p: '<path d="M10 2.5h4M10.5 2.5v4.2L8.6 9.4a3 3 0 0 0-.6 1.8V20a1.5 1.5 0 0 0 1.5 1.5h5A1.5 1.5 0 0 0 16 20v-8.8a3 3 0 0 0-.6-1.8l-1.9-2.7V2.5"/><path d="M8 13.5h8v4H8z"/>' }, { n: 'Education Technology', side: 'r', x: '8%', y: '30%', r: '-6deg', d: '-1s', p: '<path d="m2.5 9.5 9.5-5 9.5 5-9.5 5z"/><path d="M6.5 12v4.2c0 1.2 2.5 2.8 5.5 2.8s5.5-1.6 5.5-2.8V12"/><path d="M21.5 9.5v6"/>' },
{ n: 'Education Technology', side: 'r', x: '8%', y: '22%', r: '-6deg', d: '-1s', p: '<path d="m2.5 9.5 9.5-5 9.5 5-9.5 5z"/><path d="M6.5 12v4.2c0 1.2 2.5 2.8 5.5 2.8s5.5-1.6 5.5-2.8V12"/><path d="M21.5 9.5v6"/>' }, { n: 'Cybersecurity', side: 'r', x: '12%', y: '62%', r: '7deg', d: '-3s', p: '<path d="M12 3 4.5 6v5.5c0 4.5 3.2 7.8 7.5 9.5 4.3-1.7 7.5-5 7.5-9.5V6z"/><rect x="9" y="10.8" width="6" height="5" rx="1"/><path d="M10.2 10.8V9.4a1.8 1.8 0 0 1 3.6 0v1.4"/>' },
{ n: 'Cybersecurity', side: 'r', x: '15%', y: '50%', r: '7deg', d: '-3s', p: '<path d="M12 3 4.5 6v5.5c0 4.5 3.2 7.8 7.5 9.5 4.3-1.7 7.5-5 7.5-9.5V6z"/><rect x="9" y="10.8" width="6" height="5" rx="1"/><path d="M10.2 10.8V9.4a1.8 1.8 0 0 1 3.6 0v1.4"/>' }, { n: 'E-Commerce', side: 'l', x: '6%', y: '73%', r: '8deg', d: '-5s', p: '<path d="M5 8h14l-1 12H6z"/><path d="M9 8V7a3 3 0 0 1 6 0v1"/>' },
{ n: 'E-Commerce', side: 'r', x: '6%', y: '73%', r: '-8deg', d: '-5s', p: '<path d="M5 8h14l-1 12H6z"/><path d="M9 8V7a3 3 0 0 1 6 0v1"/>' },
]; ];
const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + i.n.replace('&', '&amp;') + '</span>'; const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + T(i.n).replace('&', '&amp;') + '</span>';
const fl = $('#floaters'), ir = $('#iconRow'); const fl = $('#floaters'), ir = $('#iconRow');
if (fl && ir) { if (fl && ir) {
ICONS.forEach((i) => { ICONS.forEach((i) => {
@@ -214,7 +214,6 @@
const V = [ const V = [
{ n: 'MARKETING & AI SEARCH', d: 'Leading teams through a constantly changing search landscape.', c: '#1fb7ff', m: 'magnifier' }, { n: 'MARKETING & AI SEARCH', d: 'Leading teams through a constantly changing search landscape.', c: '#1fb7ff', m: 'magnifier' },
{ n: 'AI SOFTWARE', d: 'Building around new opportunities created by AI.', c: '#7a7bff', m: 'chip' }, { n: 'AI SOFTWARE', d: 'Building around new opportunities created by AI.', c: '#7a7bff', m: 'chip' },
{ n: 'SPIRITS', d: 'Taking a consumer brand from idea to market.', c: '#3fa9ff', m: 'bottle' },
{ n: 'EDUCATION TECHNOLOGY', d: 'Creating technology designed for real classrooms.', c: '#27d9ff', m: 'cap' }, { n: 'EDUCATION TECHNOLOGY', d: 'Creating technology designed for real classrooms.', c: '#27d9ff', m: 'cap' },
{ n: 'CYBERSECURITY', d: 'Solving security challenges for growing businesses.', c: '#8a6bff', m: 'lock' }, { n: 'CYBERSECURITY', d: 'Solving security challenges for growing businesses.', c: '#8a6bff', m: 'lock' },
{ n: 'E-COMMERCE', d: 'Turning product ideas into businesses built around real customers.', c: '#1a8bff', m: 'bag' }, { n: 'E-COMMERCE', d: 'Turning product ideas into businesses built around real customers.', c: '#1a8bff', m: 'bag' },
@@ -225,7 +224,7 @@
let vi = 0, vSwitch = () => {}; let vi = 0, vSwitch = () => {};
V.forEach((v, i) => { V.forEach((v, i) => {
const b = document.createElement('button'); const b = document.createElement('button');
b.type = 'button'; b.setAttribute('role', 'tab'); b.textContent = v.n; b.type = 'button'; b.setAttribute('role', 'tab'); b.textContent = T(v.n);
b.addEventListener('click', () => setV(i)); b.addEventListener('click', () => setV(i));
vtabs.appendChild(b); vtabs.appendChild(b);
}); });
@@ -233,7 +232,7 @@
function setV(i, first) { function setV(i, first) {
vi = (i + V.length) % V.length; vi = (i + V.length) % V.length;
vbtns.forEach((b, n) => b.setAttribute('aria-selected', String(n === vi))); vbtns.forEach((b, n) => b.setAttribute('aria-selected', String(n === vi)));
const apply = () => { vname.textContent = V[vi].n; vdesc.textContent = V[vi].d; vnum.textContent = '0' + (vi + 1) + ' / 06'; vpanel.style.setProperty('--ac', V[vi].c); vt.classList.remove('out'); }; const apply = () => { vname.textContent = T(V[vi].n); vdesc.textContent = T(V[vi].d); vnum.textContent = '0' + (vi + 1) + ' / 0' + V.length; vpanel.style.setProperty('--ac', V[vi].c); vt.classList.remove('out'); };
if (first || reduce) apply(); else { vt.classList.add('out'); setTimeout(apply, 200); } if (first || reduce) apply(); else { vt.classList.add('out'); setTimeout(apply, 200); }
vSwitch(vi); vSwitch(vi);
const sel = vbtns[vi]; const sel = vbtns[vi];
@@ -242,10 +241,12 @@
$('#vprev').addEventListener('click', () => setV(vi - 1)); $('#vprev').addEventListener('click', () => setV(vi - 1));
$('#vnext').addEventListener('click', () => setV(vi + 1)); $('#vnext').addEventListener('click', () => setV(vi + 1));
vpanel.setAttribute('tabindex', '0'); vpanel.setAttribute('tabindex', '0');
vpanel.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') setV(vi + 1); if (e.key === 'ArrowLeft') setV(vi - 1); }); // In Arabic (right to left) "next" is to the left.
const fwd = document.documentElement.dir === 'rtl' ? -1 : 1;
vpanel.addEventListener('keydown', (e) => { if (e.key === 'ArrowRight') setV(vi + fwd); if (e.key === 'ArrowLeft') setV(vi - fwd); });
let sx0 = null; let sx0 = null;
vpanel.addEventListener('touchstart', (e) => { sx0 = e.touches[0].clientX; }, { passive: true }); vpanel.addEventListener('touchstart', (e) => { sx0 = e.touches[0].clientX; }, { passive: true });
vpanel.addEventListener('touchend', (e) => { if (sx0 === null) return; const dx = e.changedTouches[0].clientX - sx0; if (Math.abs(dx) > 50) setV(vi + (dx < 0 ? 1 : -1)); sx0 = null; }); vpanel.addEventListener('touchend', (e) => { if (sx0 === null) return; const dx = e.changedTouches[0].clientX - sx0; if (Math.abs(dx) > 50) setV(vi + (dx < 0 ? fwd : -fwd)); sx0 = null; });
setV(0, true); setV(0, true);
if (typeof THREE === 'undefined') return; if (typeof THREE === 'undefined') return;
@@ -312,17 +313,6 @@
g.rotation.x = -.35; g.rotation.x = -.35;
return g; return g;
}, },
// Spirits: a bottle (lathe profile) with a chrome cap
bottle() {
const g = new THREE.Group();
const pts = [[0, -1.7], [.78, -1.7], [.86, -1.55], [.86, .35], [.7, .7], [.32, 1.05], [.27, 1.55], [0, 1.55]].map(([x, y]) => new THREE.Vector2(x, y));
g.add(M(new THREE.LatheGeometry(pts, 48), glass));
const liquid = M(new THREE.CylinderGeometry(.78, .78, 1.6, 48), metal); liquid.position.y = -.85; g.add(liquid);
const label = M(new THREE.CylinderGeometry(.875, .875, .55, 48, 1, true), glow); label.position.y = -.35; g.add(label);
const cap = M(new THREE.CylinderGeometry(.3, .3, .4, 32), chrome); cap.position.y = 1.72; g.add(cap);
g.scale.setScalar(.85);
return g;
},
// Education Technology: a graduation cap // Education Technology: a graduation cap
cap() { cap() {
const g = new THREE.Group(); const g = new THREE.Group();
+77
View File
@@ -0,0 +1,77 @@
/* Arabic: right-to-left layout and type. Loaded only on /ar/ pages, after styles.css. */
:root {
--font-sans: "Noto Sans Arabic", "Inter Tight", "Inter", ui-sans-serif, system-ui, "Geeza Pro", Tahoma, sans-serif;
}
/* Arabic letters join; tracking (positive or negative) breaks them apart. */
body *:not(.brand-word):not(.brand-word *):not(.foot-word) { letter-spacing: 0 !important; }
.h-display-lg, .h-display, .h-lg, .h-md, .rule h3, .book h3, .shout q {
font-family: "Noto Kufi Arabic", var(--font-sans); line-height: 1.35;
}
.statement, .poem, .quote-line { line-height: 1.6; }
.eyebrow, .btn, .text-link, .tag-upper { font-size: 13.5px; }
/* The logo, code samples, URLs and the giant footer word stay left to right. */
.brand, .foot-word, .file, .qlist p, .panel-url, .lang-switch, [dir="ltr"] { direction: ltr; }
/* Arrows */
.flip { transform: scaleX(-1); }
.btn:hover svg.flip, .text-link:hover svg.flip { transform: translateX(-3px) scaleX(-1); }
.btn:hover .go { transform: translateX(-3px); }
.tab-btn .arr { transform: translateX(4px) scaleX(-1); }
.tab-btn[aria-selected="true"] .arr { transform: scaleX(-1); }
#vprev svg, #vnext svg { transform: scaleX(-1); }
/* Physical offsets mirrored */
.skip { left: auto; right: 16px; }
.mobile-menu .sub a { padding-left: 2px; padding-right: 18px; }
.panel-url { margin-left: 0; margin-right: auto; }
.cursor { margin-left: 0; margin-right: 2px; }
.cluster { text-align: right; }
.dash { left: auto; right: 228px; }
.profile { left: auto; right: 0; }
.code { right: auto; left: 0; }
.feature::before { left: auto; right: 0; }
.stat { padding: 28px 0 0 24px; }
.stat b sup { margin-left: 0; margin-right: 2px; }
.quote-line { padding-left: 0; border-left: 0; padding-right: 20px; border-right: 2px solid var(--accent); }
.flow { left: auto; right: -32px; }
.process-step:not(:last-child)::after { right: auto; left: -16px; }
.tab-btn { text-align: right; }
.principle { padding: 32px 0 8px 32px; }
.industries { border-left: 0; border-right: 1px solid var(--mist); }
.industry { border-right: 0; border-left: 1px solid var(--mist); }
.progress { transform-origin: 100% 50%; }
@media (min-width: 641px) {
.stat + .stat { padding-left: 0; border-left: 0; padding-right: 24px; border-right: 1px solid var(--mist); }
}
@media (max-width: 1100px) {
.stat-note { border-right: 0 !important; }
}
@media (min-width: 901px) {
.principle + .principle { padding-left: 0; border-left: 0; padding-right: 32px; border-right: 1px solid var(--mist); }
}
@media (max-width: 900px) {
.flow { right: 16px; }
.process-step:not(:last-child)::after { left: auto; right: 58px; }
}
@media (max-width: 1100px) {
.cluster .panel { right: auto; }
}
/* Homepage */
.cluster-fit .dash { left: auto; right: 176px; }
.cluster-fit .profile { left: auto; right: 0; }
.cluster-fit .code { right: auto; left: 0; }
@media (max-width: 1100px) {
.cluster-fit .panel { right: auto; left: auto; }
}
.w-side { right: auto; left: max(var(--gutter), calc((100vw - var(--page-max)) / 2 + var(--gutter))); }
.w-top { left: auto; right: max(var(--gutter), calc((100vw - var(--page-max)) / 2 + var(--gutter))); }
@media (max-width: 700px) { .w-side { left: var(--gutter); right: var(--gutter); } }
.varrows { right: auto; left: 1.2rem; }
.jfinal { left: auto; right: 1.2rem; }
.jreplay { right: auto; left: 1rem; }
.cc::before { right: auto; left: -40px; }
/* Scroll-driven word tracks move left to right in every language. */
.wmask, .pbword { direction: ltr; }
+30 -4
View File
@@ -1,6 +1,10 @@
(function () { (function () {
window.__site = true; window.__site = true;
var root = document.documentElement; var root = document.documentElement;
// The booking page uses this to keep visitors in the language they filled the form in.
if (!/\/contact\/book\//.test(location.pathname)) { try { sessionStorage.setItem('dm-lang', root.lang || 'en'); } catch (e) {} }
// Translations for the current page's language, written into the page by the build.
var T = function (s) { return (window.__i18n && window.__i18n[s]) || s; };
var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches; var reduce = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Theme toggle: remembers the visitor's choice; otherwise follows the system. // Theme toggle: remembers the visitor's choice; otherwise follows the system.
@@ -9,7 +13,7 @@
var paint = function () { var paint = function () {
if (!themeBtn) return; if (!themeBtn) return;
var dark = current() === 'dark'; var dark = current() === 'dark';
themeBtn.setAttribute('aria-label', dark ? 'Switch to light mode' : 'Switch to dark mode'); themeBtn.setAttribute('aria-label', T(dark ? 'Switch to light mode' : 'Switch to dark mode'));
themeBtn.setAttribute('aria-pressed', String(dark)); themeBtn.setAttribute('aria-pressed', String(dark));
}; };
var apply = function (t, save) { var apply = function (t, save) {
@@ -41,7 +45,7 @@
var open = menu.hidden; var open = menu.hidden;
menu.hidden = !open; menu.hidden = !open;
menuBtn.setAttribute('aria-expanded', String(open)); menuBtn.setAttribute('aria-expanded', String(open));
menuBtn.setAttribute('aria-label', open ? 'Close menu' : 'Open menu'); menuBtn.setAttribute('aria-label', T(open ? 'Close menu' : 'Open menu'));
}); });
document.addEventListener('keydown', function (e) { document.addEventListener('keydown', function (e) {
if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus(); } if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus(); }
@@ -116,7 +120,7 @@
b.addEventListener('click', function () { b.addEventListener('click', function () {
var text = b.getAttribute('data-copy'); var text = b.getAttribute('data-copy');
var label = b.textContent; var label = b.textContent;
var done = function () { b.textContent = 'Copied'; setTimeout(function () { b.textContent = label; }, 1600); }; var done = function () { b.textContent = T('Copied'); setTimeout(function () { b.textContent = label; }, 1600); };
try { navigator.clipboard.writeText(text).then(done, function () {}); } catch (e) {} try { navigator.clipboard.writeText(text).then(done, function () {}); } catch (e) {}
}); });
}); });
@@ -139,7 +143,29 @@
var subject = 'Inquiry from ' + d.get('name') + (d.get('topic') ? ' — ' + d.get('topic') : ''); var subject = 'Inquiry from ' + d.get('name') + (d.get('topic') ? ' — ' + d.get('topic') : '');
window.location.href = 'mailto:' + form.getAttribute('data-to') + '?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body); window.location.href = 'mailto:' + form.getAttribute('data-to') + '?subject=' + encodeURIComponent(subject) + '&body=' + encodeURIComponent(body);
var status = document.getElementById('form-status'); var status = document.getElementById('form-status');
if (status) status.textContent = 'Your email app should open with the message ready to send.'; if (status) status.textContent = T('Your email app should open with the message ready to send.');
});
}
// GoHighLevel's form_embed.js hides each embed until the iframe reports it is
// ready. If that message never arrives, show the embed anyway so the form is
// never left invisible.
var reveal = function (f) {
f.style.opacity = '1';
f.style.visibility = 'visible';
f.style.pointerEvents = 'auto';
f.style.position = 'relative';
if (f.style.left === '-9999px') f.style.left = '';
};
var ghl = document.querySelectorAll('.ghl-frame');
if (ghl.length) {
[2500, 6000].forEach(function (ms) {
setTimeout(function () {
ghl.forEach(function (f) {
if (f.closest('[data-off]')) return;
if (getComputedStyle(f).visibility === 'hidden') reveal(f);
});
}, ms);
}); });
} }
+72 -4
View File
@@ -710,8 +710,9 @@ html:not(.js) .tabs { grid-template-columns: 1fr; }
/* ---------- Contact ---------- */ /* ---------- Contact ---------- */
.contact-grid { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 64px; align-items: start; } .contact-grid { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 64px; align-items: start; }
.contact-grid > * { min-width: 0; } .contact-grid > * { min-width: 0; }
.direct { display: grid; gap: 0; border-top: 1px solid var(--mist); } .direct { display: grid; gap: 0; border-radius: var(--radius-panel); overflow: hidden; background: linear-gradient(160deg, var(--glass), transparent 120%); }
.direct-row { display: grid; gap: 8px; padding-block: 22px; border-bottom: 1px solid var(--mist); } .direct-row { display: grid; gap: 8px; padding: 26px clamp(20px, 3vw, 32px); border-bottom: 1px solid var(--mist); }
.direct-row:last-child { border-bottom: 0; }
.direct-row small { font-size: 12px; color: var(--smoke); letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; } .direct-row small { font-size: 12px; color: var(--smoke); letter-spacing: var(--tracking-eyebrow); text-transform: uppercase; }
.direct-row .val { font-size: clamp(20px, 2.2vw, 26px); font-weight: 700; letter-spacing: -0.02em; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; } .direct-row .val { font-size: clamp(20px, 2.2vw, 26px); font-weight: 700; letter-spacing: -0.02em; display: flex; flex-wrap: wrap; align-items: center; gap: 12px; }
.direct-row a.val:hover { color: var(--accent); } .direct-row a.val:hover { color: var(--accent); }
@@ -889,11 +890,21 @@ h1, h2, h3, h4 { font-weight: 600; }
.dropdown { background: var(--nav-bg); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); border-color: var(--bd); } .dropdown { background: var(--nav-bg); -webkit-backdrop-filter: blur(16px); backdrop-filter: blur(16px); border-color: var(--bd); }
:root[data-theme="dark"] .dropdown { background: rgba(10, 14, 26, .96); } :root[data-theme="dark"] .dropdown { background: rgba(10, 14, 26, .96); }
.theme-btn, .menu-btn { border-radius: 10px; border-color: var(--bd); background: var(--glass); } .theme-btn, .menu-btn { border-radius: 10px; border-color: var(--bd); background: var(--glass); }
.lang-switch {
position: fixed; right: max(16px, env(safe-area-inset-right, 0px)); bottom: max(16px, env(safe-area-inset-bottom, 0px)); z-index: 90;
display: inline-flex; align-items: center; gap: 2px; padding: 4px; border: 1px solid var(--bd); border-radius: 12px;
background: var(--nav-bg, var(--glass)); -webkit-backdrop-filter: blur(14px) saturate(1.4); backdrop-filter: blur(14px) saturate(1.4);
box-shadow: 0 8px 28px rgba(2, 8, 23, .28);
}
.lang-switch a, .lang-switch span { display: inline-grid; place-items: center; min-width: 36px; height: 34px; padding: 0 8px; border-radius: 8px; font: 600 12px/1 var(--font-sans); letter-spacing: 0.08em; color: var(--slate); transition: color .15s ease, background-color .15s ease; }
.lang-switch a:hover { color: var(--ink); }
.lang-switch [aria-current] { color: var(--ink); background: rgba(26, 127, 255, .22); }
/* Real logo on dark, vector recreation on light */ /* Real logo on dark, vector recreation on light */
.logo-img { display: none; height: 30px; width: auto; } .logo-img { display: none; height: 30px; width: auto; }
:root[data-theme="dark"] .brand .logo-img { display: block; } /* The official logo in both themes: its dark outline keeps it readable on light backgrounds. */
:root[data-theme="dark"] .brand .logo-mark, :root[data-theme="dark"] .brand .brand-word { display: none; } .brand .logo-img { display: block; }
.brand .logo-mark, .brand .brand-word { display: none; }
.footer { border-top: 1px solid var(--bd); } .footer { border-top: 1px solid var(--bd); }
@@ -1019,3 +1030,60 @@ h1, h2, h3, h4 { font-weight: 600; }
@media (max-width: 900px) { .gallery { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 900px) { .gallery { grid-template-columns: repeat(2, 1fr); } }
.feature-press { position: relative; } .feature-press { position: relative; }
.fp-link { position: absolute; inset: 0; z-index: 3; border-radius: inherit; } .fp-link { position: absolute; inset: 0; z-index: 3; border-radius: inherit; }
/* GoHighLevel embeds (lead form + booking calendars) */
/* The lead form is styled dark inside GHL (ghl/lead-form.css), so it sits on the card with no
backdrop; in light mode it gets a dark panel so its light text stays readable. */
.ghl-wrap { border-radius: 14px; overflow: hidden; background: #ffffff; }
.ghl-wrap-form { background: transparent; border-radius: 0; overflow: visible; }
[data-theme="light"] .ghl-wrap-form { background: #0b1120; border-radius: 16px; padding: 22px; }
.ghl-frame { display: block; width: 100%; border: 0; background: transparent; color-scheme: light; }
.ghl-wrap-form .ghl-frame { min-height: 340px; }
.ghl-wrap-cal { padding: 8px; }
.ghl-wrap-cal .ghl-frame { min-height: 720px; }
.ghl-card { gap: 20px; }
.calls { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; max-width: 980px; margin: 56px auto 0; }
.call {
all: unset; box-sizing: border-box; cursor: pointer; display: flex; flex-direction: column; gap: 14px;
padding: 32px; border-radius: 28px; border: 1px solid var(--bd);
background: linear-gradient(160deg, var(--glass), transparent 120%), var(--cloud); color: var(--ink);
transition: border-color .2s ease, box-shadow .2s ease, transform .2s ease;
}
.call:hover { transform: translateY(-2px); border-color: rgba(31, 183, 255, .45); }
.call:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
.call[aria-selected="true"] { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 0 50px rgba(26, 127, 255, .18); }
.call-top { display: flex; justify-content: space-between; align-items: center; }
.call-top .ic { width: 44px; height: 44px; border-radius: 50%; }
.call-h { font-size: var(--text-heading); font-weight: 500; letter-spacing: -0.03em; line-height: 1.15; }
.call-p { color: var(--slate); }
.call .btn { align-self: flex-start; margin-top: 6px; pointer-events: none; }
.cal-panel { max-width: 980px; margin: 0 auto; border-radius: 24px; }
@media (max-width: 760px) { .calls { grid-template-columns: 1fr; } .call { padding: 24px; } }
#schedule .wrap { position: relative; }
[data-gate="locked"] .gate-open, [data-gate="open"] .gate-locked, html:not([data-gate]) .gate-locked { display: none !important; }
.cal-panel[data-off] { position: absolute; left: -10000px; top: 0; width: min(980px, 100%); visibility: hidden; pointer-events: none; }
.ghl-wrap-form { min-height: 340px; }
.ghl-wrap-cal { min-height: 720px; }
/* Call-to-action band between sections */
.cta-band { position: relative; z-index: 2; padding-block: clamp(28px, 4vw, 44px); border-block: 1px solid var(--bd);
background: radial-gradient(ellipse at 85% 50%, var(--glow), transparent 60%), var(--cloud); }
.cta-band-inner { display: flex; align-items: center; justify-content: space-between; gap: 32px; }
.cta-band-copy { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.cta-band-title { font-size: clamp(20px, 2.2vw, 28px); font-weight: 500; letter-spacing: -0.03em; line-height: 1.2; color: var(--ink); text-wrap: balance; }
.cta-band-body { max-width: 62ch; color: var(--slate); }
.cta-band .btn-row { flex: none; }
@media (max-width: 760px) { .cta-band-inner { flex-direction: column; align-items: flex-start; } }
/* Offices (same as aigrowthtechnologies.com/contact) */
.offices { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); border-top: 1px solid var(--bd); }
.office { display: flex; flex-direction: column; gap: 16px; padding: 28px 24px 8px; border-inline-start: 1px solid var(--bd); min-width: 0; }
.office:first-child { padding-inline-start: 0; border-inline-start: 0; }
.office-head { display: flex; flex-direction: column; align-items: flex-start; gap: 12px; }
.office-desc { color: var(--slate); font-size: 15px; }
.office-address { display: flex; flex-direction: column; gap: 8px; margin-top: auto; padding-top: 18px; border-top: 1px solid var(--bd); font-style: normal; font-size: 14px; color: var(--graphite); }
.office-address em { display: block; font-style: normal; font-size: 11px; letter-spacing: 0.12em; text-transform: uppercase; color: var(--smoke); }
.office-address a { color: var(--ink); overflow-wrap: anywhere; }
.office-address a:hover { color: var(--accent); }
@media (max-width: 1023px) { .offices { grid-template-columns: repeat(2, minmax(0, 1fr)); } .office:nth-child(3) { padding-inline-start: 0; border-inline-start: 0; } .office:nth-child(n + 3) { border-top: 1px solid var(--bd); } }
@media (max-width: 640px) { .offices { grid-template-columns: 1fr; } .office, .office:nth-child(3) { padding: 24px 0; border-inline-start: 0; } .office + .office { border-top: 1px solid var(--bd); } }
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#0b0b0d"/><g transform="translate(6 15) scale(.5)"><path fill="#fff" fill-rule="evenodd" d="M9 4h55c10 0 13 7 9 14L58 46c-3 6-8 8-15 8.5L3 57zM21.5 15.5 18.3 45.8 40 44.5c4-.3 6-1.5 7.5-4.3l12-21.4c1.2-2.2.3-3.3-2-3.3z"/><path fill="#33b9ff" d="M24 59 102 1 91 52 72 54.5 79 33z"/><path fill="#8fdcff" d="M24 59 102 1 79 33z"/></g></svg>

Before

Width:  |  Height:  |  Size: 441 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB