Compare commits
29 Commits
3601bc88e7
...
bdc08499df
| Author | SHA1 | Date | |
|---|---|---|---|
| bdc08499df | |||
| 53e3e7bf81 | |||
| daf95278a7 | |||
| 27c0189f2b | |||
| 083443a43b | |||
| b2eff3d3be | |||
| 44af6329aa | |||
| 09459ffa4e | |||
| d99d86a269 | |||
| 965ec4c2c5 | |||
| eb9137b300 | |||
| 4ba5f05f75 | |||
| 75d8cbc7f7 | |||
| 16e36f5b4f | |||
| 6850d4bee7 | |||
| 87f39eb49e | |||
| 4804d824da | |||
| 8b7da81f7f | |||
| 8f62f11a62 | |||
| 6ea0218427 | |||
| a7d8e4968e | |||
| cc7d385c9f | |||
| 90be0ec4c7 | |||
| d0357f1c52 | |||
| e6279c2e91 | |||
| 60f58cfc20 | |||
| 2d37e3fcce | |||
| f73968c0dc | |||
| ee8e0ab417 |
@@ -1,3 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.git
|
||||
@@ -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
|
||||
@@ -26,8 +26,25 @@ build.mjs Genera dist/ + sitemap.xml + robots.txt + 404.html
|
||||
| `/consulting/education/` | `src/pages/education.mjs` |
|
||||
| `/consulting/fractional/` | `src/pages/fractional.mjs` |
|
||||
| `/contact/` | `src/pages/contact.mjs` |
|
||||
| `/contact/book/` | `src/pages/book.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
|
||||
|
||||
```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`).
|
||||
- **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.
|
||||
- **Calendario:** poner el link real de agenda en `SITE.calendar`.
|
||||
- **Formulario de contacto:** hoy abre el cliente de correo con el mensaje prellenado. Para recibirlo directo hay que conectar un servicio (Formspree, Resend, etc.).
|
||||
- **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.
|
||||
|
||||
@@ -3,11 +3,12 @@ import { mkdir, rm, writeFile, cp, readFile } from 'node:fs/promises';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, dirname } from 'node:path';
|
||||
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 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 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.
|
||||
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);
|
||||
|
||||
// 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 = [];
|
||||
for (const name of pages) {
|
||||
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 writeFile(file, fingerprint(page(def)));
|
||||
urls.push(def.path);
|
||||
await writeFile(file, fingerprint(html));
|
||||
}
|
||||
if (!def.noindex) urls.push(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({
|
||||
path: '/404/',
|
||||
@@ -37,8 +55,8 @@ await writeFile(join(out, '404.html'), fingerprint(page({
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
await writeFile(join(out, 'sitemap.xml'), `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
${urls.map((u) => ` <url><loc>https://dylanmazzei.com${u}</loc><lastmod>${today}</lastmod></url>`).join('\n')}
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">
|
||||
${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>
|
||||
`);
|
||||
await writeFile(join(out, 'robots.txt'), 'User-agent: *\nAllow: /\nSitemap: https://dylanmazzei.com/sitemap.xml\n');
|
||||
|
||||
@@ -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; }
|
||||
Vendored
+1
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+1338
File diff suppressed because it is too large
Load Diff
+96
-16
@@ -1,14 +1,25 @@
|
||||
// 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 = {
|
||||
url: 'https://dylanmazzei.com',
|
||||
gaId: 'G-P9H24L6WM5',
|
||||
email: 'dylan@dylanmazzei.com',
|
||||
pressEmail: 'press@dylanmazzei.com',
|
||||
linkedin: 'https://www.linkedin.com/in/dylanmazzei/',
|
||||
instagram: 'https://www.instagram.com/thedylanmazzei',
|
||||
// Replace with the real scheduling link (Calendly, Cal.com, etc.).
|
||||
calendar: 'mailto:dylan@dylanmazzei.com?subject=Scheduling%20a%20conversation',
|
||||
// GoHighLevel (LeadConnector) widgets. Contacts land in Dylan's GHL account.
|
||||
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 = {
|
||||
@@ -56,7 +67,9 @@ const SMALL = new Set(['arrow', 'arrow-ur', 'check', 'plus', 'chev', 'brace']);
|
||||
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 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.
|
||||
@@ -106,7 +119,16 @@ const NAV = [
|
||||
{ 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 desktop = NAV.map((item) => {
|
||||
if (!item.children) {
|
||||
@@ -188,15 +210,17 @@ function footer() {
|
||||
<span class="eyebrow">WHERE I WORK</span>
|
||||
<p class="places">Texas<i>•</i>Florida<i>•</i>New York<i>•</i>Dubai</p>
|
||||
</div>
|
||||
<small>© ${new Date().getFullYear()} Dylan Mazzei</small>
|
||||
<small>© <span>${new Date().getFullYear()}</span> Dylan Mazzei</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="foot-word" aria-hidden="true">DYLAN MAZZEI</div>
|
||||
</footer>`;
|
||||
}
|
||||
|
||||
export function page({ path, title, description, body, schema = [], head = '', scripts = '' }) {
|
||||
const canonical = SITE.url + path;
|
||||
export function page({ path, title, description, body, schema = [], head = '', scripts = '', noindex = false, ctaBands = true }, locale = 'en') {
|
||||
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 = {
|
||||
'@context': 'https://schema.org',
|
||||
'@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');
|
||||
return `<!doctype html>
|
||||
<html lang="en">
|
||||
<html lang="${locale}" dir="${dirOf(locale)}">
|
||||
<head>
|
||||
<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">
|
||||
<title>${title}</title>
|
||||
<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="color-scheme" content="light dark">
|
||||
<meta property="og:type" content="website">
|
||||
<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:description" content="${description}">
|
||||
<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:description" content="${description}">
|
||||
<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.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="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/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}
|
||||
<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}
|
||||
</head>
|
||||
<body>
|
||||
<div class="bgfx" aria-hidden="true"></div>
|
||||
<div class="progress" id="progress" aria-hidden="true"></div>
|
||||
${nav(path)}
|
||||
${nav(path, locale)}
|
||||
<main id="main">
|
||||
${body}
|
||||
${ctaBands === false ? body : withCtaBands(body)}
|
||||
</main>
|
||||
${footer()}
|
||||
${langSwitch(path, locale)}
|
||||
<script src="/assets/site.js" defer></script>
|
||||
${scripts}
|
||||
</body>
|
||||
</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>
|
||||
`;
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
path: '/contact/',
|
||||
title: 'Contact Dylan Mazzei | Business & Partnerships',
|
||||
scripts: GHL_SCRIPT,
|
||||
ctaBands: false,
|
||||
description: 'Contact Dylan Mazzei about business opportunities, strategic partnerships, consulting, media inquiries, or other conversations worth having.',
|
||||
body: `
|
||||
<section class="page-hero" aria-labelledby="ct-hero">
|
||||
@@ -28,40 +30,24 @@ export default {
|
||||
</div>
|
||||
|
||||
<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="stack-xs">
|
||||
<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>
|
||||
</div>
|
||||
<div class="form-grid">
|
||||
<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 class="ghl-wrap ghl-wrap-form">${ghlForm(SITE.ghl.form, 'Dylan – Lead Form')}</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}">
|
||||
<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>
|
||||
<a class="cal-card" href="#inquiry">
|
||||
<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>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
${officesSection()}
|
||||
<section class="section" aria-labelledby="where">
|
||||
<div class="wrap">
|
||||
<div class="showcase reveal">
|
||||
|
||||
+4
-6
@@ -3,7 +3,6 @@ import { icon, eyebrow, btn, textLink, mark } from '../layout.mjs';
|
||||
const ventures = [
|
||||
['search', 'MARKETING & AI SEARCH'],
|
||||
['chip', 'AI SOFTWARE'],
|
||||
['bottle', 'SPIRITS'],
|
||||
['edu', 'EDUCATION TECHNOLOGY'],
|
||||
['shield', 'CYBERSECURITY'],
|
||||
['bag', 'E-COMMERCE'],
|
||||
@@ -35,7 +34,7 @@ const arrowUR = '<svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stro
|
||||
export default {
|
||||
path: '/',
|
||||
title: 'Dylan Mazzei | Founder, Operator & 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">',
|
||||
scripts: '<script src="/assets/o2/three.min.js" defer></script>\n<script src="/assets/home.js" defer></script>',
|
||||
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>
|
||||
${eyebrow('DYLAN MAZZEI | FOUNDER & OPERATOR')}
|
||||
<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="icon-row" id="iconRow" aria-hidden="true"></div>
|
||||
</div>
|
||||
@@ -114,12 +113,11 @@ export default {
|
||||
<div class="vhead reveal">
|
||||
${eyebrow('CURRENT VENTURES')}
|
||||
<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>
|
||||
<ul class="sr-only">
|
||||
<li>MARKETING & AI SEARCH Leading teams through a constantly changing search landscape.</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>CYBERSECURITY Solving security challenges for growing businesses.</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="vpanel reveal" id="vpanel">
|
||||
<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 & 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>
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
@@ -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
@@ -8,18 +8,18 @@
|
||||
const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
|
||||
const lerp = (a, b, t) => a + (b - a) * t;
|
||||
const T = (s) => (window.__i18n && window.__i18n[s]) || s;
|
||||
|
||||
/* ---------- 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 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: '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: '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: '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: '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"/>' },
|
||||
{ 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: '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: '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"/>' },
|
||||
];
|
||||
const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + i.n.replace('&', '&') + '</span>';
|
||||
const tileHTML = (i) => '<div class="tile">' + svg(i.p) + '</div><span class="tl">' + T(i.n).replace('&', '&') + '</span>';
|
||||
const fl = $('#floaters'), ir = $('#iconRow');
|
||||
if (fl && ir) {
|
||||
ICONS.forEach((i) => {
|
||||
@@ -214,7 +214,6 @@
|
||||
const V = [
|
||||
{ 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: '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: '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' },
|
||||
@@ -225,7 +224,7 @@
|
||||
let vi = 0, vSwitch = () => {};
|
||||
V.forEach((v, i) => {
|
||||
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));
|
||||
vtabs.appendChild(b);
|
||||
});
|
||||
@@ -233,7 +232,7 @@
|
||||
function setV(i, first) {
|
||||
vi = (i + V.length) % V.length;
|
||||
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); }
|
||||
vSwitch(vi);
|
||||
const sel = vbtns[vi];
|
||||
@@ -242,10 +241,12 @@
|
||||
$('#vprev').addEventListener('click', () => setV(vi - 1));
|
||||
$('#vnext').addEventListener('click', () => setV(vi + 1));
|
||||
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;
|
||||
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);
|
||||
|
||||
if (typeof THREE === 'undefined') return;
|
||||
@@ -312,17 +313,6 @@
|
||||
g.rotation.x = -.35;
|
||||
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
|
||||
cap() {
|
||||
const g = new THREE.Group();
|
||||
|
||||
@@ -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
@@ -1,6 +1,10 @@
|
||||
(function () {
|
||||
window.__site = true;
|
||||
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;
|
||||
|
||||
// Theme toggle: remembers the visitor's choice; otherwise follows the system.
|
||||
@@ -9,7 +13,7 @@
|
||||
var paint = function () {
|
||||
if (!themeBtn) return;
|
||||
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));
|
||||
};
|
||||
var apply = function (t, save) {
|
||||
@@ -41,7 +45,7 @@
|
||||
var open = menu.hidden;
|
||||
menu.hidden = !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) {
|
||||
if (e.key === 'Escape' && !menu.hidden) { menu.hidden = true; menuBtn.setAttribute('aria-expanded', 'false'); menuBtn.focus(); }
|
||||
@@ -116,7 +120,7 @@
|
||||
b.addEventListener('click', function () {
|
||||
var text = b.getAttribute('data-copy');
|
||||
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) {}
|
||||
});
|
||||
});
|
||||
@@ -139,7 +143,29 @@
|
||||
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);
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -710,8 +710,9 @@ html:not(.js) .tabs { grid-template-columns: 1fr; }
|
||||
/* ---------- Contact ---------- */
|
||||
.contact-grid { display: grid; grid-template-columns: 0.95fr 1.05fr; gap: 64px; align-items: start; }
|
||||
.contact-grid > * { min-width: 0; }
|
||||
.direct { display: grid; gap: 0; border-top: 1px solid var(--mist); }
|
||||
.direct-row { display: grid; gap: 8px; padding-block: 22px; border-bottom: 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: 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 .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); }
|
||||
@@ -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); }
|
||||
: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); }
|
||||
.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 */
|
||||
.logo-img { display: none; height: 30px; width: auto; }
|
||||
:root[data-theme="dark"] .brand .logo-img { display: block; }
|
||||
:root[data-theme="dark"] .brand .logo-mark, :root[data-theme="dark"] .brand .brand-word { display: none; }
|
||||
/* The official logo in both themes: its dark outline keeps it readable on light backgrounds. */
|
||||
.brand .logo-img { display: block; }
|
||||
.brand .logo-mark, .brand .brand-word { display: none; }
|
||||
|
||||
.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); } }
|
||||
.feature-press { position: relative; }
|
||||
.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 +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 |
Reference in New Issue
Block a user