commit aa0287eeaac226c5438e61e7ad5bd2c2c3704534 Author: Benjoe Date: Mon May 11 08:19:27 2026 -1000 Initial commit: Fat Kiss site — Hugo + Decap CMS diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..430517d --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +public/ +resources/ +.hugo_build.lock +hugo_stats.json +node_modules/ +.env +*.log +.DS_Store diff --git a/IMAGE_PROMPTS.md b/IMAGE_PROMPTS.md new file mode 100644 index 0000000..dd9c50a --- /dev/null +++ b/IMAGE_PROMPTS.md @@ -0,0 +1,26 @@ +# Fat Kiss Image Prompts + +## Product Photography +1. **Amber glass balm jar on seafoam/cream background** — warm natural light, editorial apothecary flat-lay, linen texture, single jar centered, soft shadows +2. **Close-up balm texture** — macro shot of whipped tallow balm, golden-white, slightly whipped peaks, warm amber tones +3. **Hands applying balm** — woman's hands pressing balm into face, natural light, intimate crop, soft focus background +4. **Product family lineup** — three jars (face, body, lip) on cream linen, staggered, warm side light, apothecary feel + +## Brand / Lifestyle +5. **Pasture / tallow origin** — grass-fed cattle on green pasture, golden hour, Kauaʻi landscape, wide shot +6. **Ocean light / coastal air** — Kauaʻi coastline, soft morning light, sea spray, natural texture +7. **Retro apothecary flat-lay** — amber glass jars, vintage scale, dried botanicals, cream background, subtle 80s arcade color accents +8. **Renaissance Woman vibe** — woman's hands crafting, mixing balms, natural light, editorial crop, warm tones + +## Icons / Motifs +9. **Lips/kiss icon** — minimal line-art lips, bold red, circular seal style +10. **Kiss spark** — tiny pixel-art sparkle, seafoam/mint color, 8-bit style +11. **Apothecary seal** — circular badge with "Fat Kiss" text, botanical line details, red + cream + +## Style Notes +- Natural light preferred +- Warm, earthy color palette +- No harsh studio lighting +- Editorial, not commercial +- Texture and tactility emphasized +- Kauaʻi sense of place where appropriate diff --git a/README.md b/README.md new file mode 100644 index 0000000..635a38b --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ +# Fat Kiss — Natural Ritual Skincare + +**https://getfatkiss.com** | "Everybody Wants One." + +Hugo static site with Decap CMS admin. Handcrafted on Kauaʻi. + +## Quick Start + +```bash +# Local dev +docker compose up preview # → http://localhost:1317 + +# Build +docker compose run --rm build + +# Deploy +bash scripts/deploy.sh +``` + +## Project Structure + +``` +content/ # Markdown content (products, about, journal) +data/site/ # YAML config (home, contact, settings, navigation) +data/reviews/ # Review YAML files +assets/scss/ # SCSS design system +assets/js/ # JavaScript +layouts/ # Hugo templates +static/admin/ # Decap CMS +static/uploads/ # Media uploads +server/ # Contact form backend +scripts/ # Build & deploy scripts +``` + +## How Amber Edits Content + +1. Go to **https://getfatkiss.com/admin/** +2. Log in with Gitea (MFA required) +3. Use the sidebar to edit: + - **Pages → Home Page** — hero, brand statement, featured products, ethos + - **Pages → Site Settings** — toggle sections on/off + - **Pages → Contact Settings** — social links, routing + - **Products** — add/edit/archive products + - **About** — edit about page sections + - **Journal** — write journal posts + - **Reviews** — manage customer reviews +4. Changes commit to Gitea → webhook triggers deploy + +## How to Add a Product + +1. Admin → Products → New Product +2. Fill in: title, product type, status, summary, benefit chips, blend description, directions, ingredients, CTA +3. Set status: `inquiry` (visible, contact to order) or `coming_soon` (visible, not yet available) +4. Save → deploys automatically + +## How to Hide a Section + +1. Admin → Pages → Home Page +2. Find the section (e.g. Reviews, Journal Preview) +3. Set `enabled: false` +4. Save → section disappears from site + +## Conditional Rendering + +Sections render ONLY when `enabled: true` AND content exists. No empty boxes. No "coming soon" placeholders (unless explicitly enabled). + +## Contact Form Security + +- Cloudflare Turnstile (bot protection) +- Server-side token verification +- Rate limiting (5 req / 15 min) +- Honeypot field +- Input sanitization +- Category allowlist +- CORS locked to getfatkiss.com +- No personal emails exposed in HTML + +## Deploy Flow + +1. Content edited in Decap CMS +2. Commit pushed to Gitea +3. Gitea webhook → `scripts/deploy.sh` +4. Hugo builds to temp directory +5. Validates output exists +6. Atomically rsyncs to live webroot +7. Previous build kept as rollback + +## Rollback + +```bash +# Content rollback: revert commit in Gitea +# Build rollback on Hub: +ssh hub-direct 'sudo rsync -az --delete /home/benjoe/getfatkiss/public.prev/ /var/www/getfatkiss.com/public_html/' +``` + +## Future Roadmap + +- **Ecommerce**: Stripe integration, cart, checkout +- **Newsletter**: Listmonk integration +- **Fat Kiss Studio**: Custom admin replacing Decap CMS +- **Subscriptions**: Recurring orders +- **Wholesale portal**: B2B ordering +- **Local pickup**: Kauaʻi fulfillment option + +## Security Headers + +Configured in Apache: +- X-Frame-Options: SAMEORIGIN +- X-Content-Type-Options: nosniff +- Referrer-Policy: strict-origin-when-cross-origin +- Permissions-Policy: geolocation=(), microphone=(), camera=() + +## Admin Security + +- `/admin/` — noindex, nofollow +- Gitea OAuth authentication +- MFA required on Amber's Gitea account +- No secrets in admin config +- Media uploads restricted to `/static/uploads/` + +## Tech Stack + +- **Hugo** — static site generator +- **Decap CMS** — browser-based content editing +- **Gitea** — self-hosted Git + auth backend +- **Docker** — Hugo build environment +- **Apache** — web server on Hub +- **Cloudflare** — DNS + CDN + Turnstile +- **Node.js** — contact form backend diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..83ec6f7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,31 @@ +# Fat Kiss Security Checklist + +## Production +- [x] HTTPS enforced (Let's Encrypt via certbot) +- [x] Cloudflare proxy (orange cloud) +- [x] Apache security headers +- [x] /admin/ noindex +- [x] No secrets in frontend code +- [x] .env files gitignored + +## Contact Form +- [x] Turnstile client + server verification +- [x] Rate limiting +- [x] Honeypot field +- [x] Input sanitization +- [x] Category allowlist +- [x] CORS locked + +## Admin +- [x] Gitea OAuth +- [x] MFA required +- [x] No server control exposed +- [x] Media path restricted + +## To Do +- [ ] Configure Gitea webhook for auto-deploy +- [ ] Set up Turnstile site key in contact form +- [ ] Configure SMTP for contact handler +- [ ] Enable MFA on Amber's Gitea account +- [ ] Add CSP header +- [ ] Regular dependency updates diff --git a/assets/js/contact.js b/assets/js/contact.js new file mode 100644 index 0000000..0b10676 --- /dev/null +++ b/assets/js/contact.js @@ -0,0 +1,36 @@ +// Fat Kiss — contact.js +document.addEventListener('DOMContentLoaded', () => { + const form = document.getElementById('contactForm'); + if (!form) return; + + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const btn = document.getElementById('submitBtn'); + const msg = document.getElementById('formMessage'); + btn.disabled = true; + btn.textContent = 'Sending…'; + + try { + const fd = new FormData(form); + const res = await fetch('/api/contact', { + method: 'POST', + headers: { 'Accept': 'application/json' }, + body: fd + }); + const data = await res.json(); + + if (res.ok && data.ok) { + msg.innerHTML = '
Thank you — your note made it through. Fat Kiss will get back to you soon.
'; + form.reset(); + if (typeof turnstile !== 'undefined') turnstile.reset(); + } else { + msg.innerHTML = '
' + (data.error || 'Something did not go through. Please try again in a moment.') + '
'; + } + } catch (err) { + msg.innerHTML = '
Something did not go through. Please try again in a moment.
'; + } + + btn.disabled = false; + btn.textContent = 'Send Message'; + }); +}); diff --git a/assets/js/main.js b/assets/js/main.js new file mode 100644 index 0000000..f696189 --- /dev/null +++ b/assets/js/main.js @@ -0,0 +1,52 @@ +// Fat Kiss — main.js +document.addEventListener('DOMContentLoaded', () => { + // Mobile nav toggle + const toggle = document.getElementById('navToggle'); + const nav = document.getElementById('mainNav'); + if (toggle && nav) { + toggle.addEventListener('click', () => nav.classList.toggle('open')); + nav.querySelectorAll('a').forEach(a => { + a.addEventListener('click', () => nav.classList.remove('open')); + }); + } + + // FAQ accordion + document.querySelectorAll('.faq-item__question').forEach(btn => { + btn.addEventListener('click', () => { + btn.parentElement.classList.toggle('open'); + }); + }); + + // Sparkle effect on primary buttons + document.querySelectorAll('.btn--primary').forEach(btn => { + btn.addEventListener('mousemove', (e) => { + const rect = btn.getBoundingClientRect(); + btn.style.setProperty('--x', ((e.clientX - rect.left) / rect.width * 100) + '%'); + btn.style.setProperty('--y', ((e.clientY - rect.top) / rect.height * 100) + '%'); + }); + }); + + // Kiss mark entrance animation for hero logo + const heroLogo = document.querySelector('.hero__logo'); + if (heroLogo && !sessionStorage.getItem('fk-hero-seen')) { + heroLogo.classList.add('kiss-mark'); + sessionStorage.setItem('fk-hero-seen', '1'); + } + + // Jingle button (if present) + const jingleBtn = document.getElementById('jingleBtn'); + if (jingleBtn) { + const audio = document.getElementById('jingleAudio'); + jingleBtn.addEventListener('click', () => { + if (audio) { + if (audio.paused) { + audio.play(); + jingleBtn.textContent = '⏸ Pause'; + } else { + audio.pause(); + jingleBtn.textContent = '▶ Play Jingle'; + } + } + }); + } +}); diff --git a/assets/scss/_components.scss b/assets/scss/_components.scss new file mode 100644 index 0000000..6ec269c --- /dev/null +++ b/assets/scss/_components.scss @@ -0,0 +1,914 @@ +/* ── Header ── */ +.site-header { + position: sticky; + top: 0; + z-index: 100; + height: var(--fk-header-height); + background: rgba(251, 247, 242, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); + border-bottom: 1px solid var(--fk-border); +} + +.site-header__inner { + display: flex; + align-items: center; + justify-content: space-between; + height: 100%; +} + +.site-header__logo { + display: flex; + align-items: center; + gap: var(--fk-space-sm); + font-family: var(--fk-font-logo); + font-size: 1.75rem; + letter-spacing: 0.04em; + color: var(--fk-kiss-red); + text-transform: uppercase; +} + +.site-header__logo svg { + width: 32px; + height: 32px; +} + +.site-header__nav { + display: flex; + align-items: center; + gap: var(--fk-space-xl); +} + +.site-header__nav a { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-sm); + font-weight: 500; + color: var(--fk-ink-soft); + transition: color var(--fk-duration-fast); + position: relative; +} + +.site-header__nav a:hover, +.site-header__nav a.active { + color: var(--fk-ink); +} + +.site-header__nav a.active::after { + content: ''; + position: absolute; + bottom: -4px; + left: 0; + right: 0; + height: 2px; + background: var(--fk-kiss-red); + border-radius: 1px; +} + +/* Mobile nav toggle */ +.nav-toggle { + display: none; + flex-direction: column; + gap: 5px; + padding: var(--fk-space-sm); +} + +.nav-toggle span { + display: block; + width: 24px; + height: 2px; + background: var(--fk-ink); + border-radius: 1px; + transition: transform var(--fk-duration-md), opacity var(--fk-duration-md); +} + +@media (max-width: 768px) { + .nav-toggle { display: flex; } + .site-header__nav { + position: fixed; + top: var(--fk-header-height); + left: 0; + right: 0; + bottom: 0; + background: var(--fk-bg); + flex-direction: column; + justify-content: center; + gap: var(--fk-space-2xl); + transform: translateX(100%); + transition: transform var(--fk-duration-md) var(--fk-ease-out); + } + .site-header__nav.open { + transform: translateX(0); + } + .site-header__nav a { + font-size: var(--fk-text-xl); + } +} + +/* ── Footer ── */ +.site-footer { + background: var(--fk-ink); + color: var(--fk-parchment); + padding: var(--fk-space-4xl) 0 var(--fk-space-2xl); +} + +.site-footer__grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + gap: var(--fk-space-2xl); + margin-bottom: var(--fk-space-3xl); +} + +.site-footer__brand { + font-family: var(--fk-font-logo); + font-size: 1.5rem; + color: var(--fk-kiss-red); + text-transform: uppercase; + margin-bottom: var(--fk-space-md); +} + +.site-footer__slogan { + font-family: var(--fk-font-display); + font-style: italic; + color: var(--fk-muted-gold); + margin-bottom: var(--fk-space-lg); +} + +.site-footer__copy { + font-size: var(--fk-text-sm); + color: rgba(244, 237, 228, 0.5); +} + +.site-footer h4 { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--fk-seafoam); + margin-bottom: var(--fk-space-md); +} + +.site-footer a { + display: block; + font-size: var(--fk-text-sm); + color: rgba(244, 237, 228, 0.7); + padding: var(--fk-space-xs) 0; + transition: color var(--fk-duration-fast); +} + +.site-footer a:hover { + color: var(--fk-cream); +} + +.site-footer__bottom { + border-top: 1px solid rgba(244, 237, 228, 0.1); + padding-top: var(--fk-space-lg); + display: flex; + justify-content: space-between; + font-size: var(--fk-text-xs); + color: rgba(244, 237, 228, 0.4); +} + +@media (max-width: 768px) { + .site-footer__grid { + grid-template-columns: 1fr; + } + .site-footer__bottom { + flex-direction: column; + gap: var(--fk-space-sm); + } +} + +/* ── Buttons ── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: var(--fk-space-sm); + font-family: var(--fk-font-ui); + font-size: var(--fk-text-sm); + font-weight: 600; + padding: 0.75rem 1.75rem; + border-radius: var(--fk-radius-full); + transition: all var(--fk-duration-md) var(--fk-ease-out); + position: relative; + overflow: hidden; +} + +.btn--primary { + background: var(--fk-kiss-red); + color: #fff; + box-shadow: 0 2px 12px rgba(196, 30, 58, 0.25); +} + +.btn--primary:hover { + background: var(--fk-kiss-red-dim); + box-shadow: 0 4px 20px rgba(196, 30, 58, 0.35); + transform: translateY(-1px); +} + +.btn--primary:active { + transform: translateY(0); +} + +.btn--secondary { + background: transparent; + color: var(--fk-ink); + border: 1.5px solid var(--fk-border-strong); +} + +.btn--secondary:hover { + border-color: var(--fk-ink); + background: rgba(28, 20, 16, 0.03); +} + +.btn--ghost { + background: transparent; + color: var(--fk-ink-soft); +} + +.btn--ghost:hover { + color: var(--fk-ink); + background: rgba(28, 20, 16, 0.04); +} + +.btn--lg { + padding: 1rem 2.25rem; + font-size: var(--fk-text-base); +} + +/* Spark hover effect on primary buttons */ +.btn--primary::after { + content: ''; + position: absolute; + inset: 0; + background: radial-gradient(circle at var(--x, 50%) var(--y, 50%), rgba(255,255,255,0.15) 0%, transparent 60%); + opacity: 0; + transition: opacity var(--fk-duration-fast); +} + +.btn--primary:hover::after { + opacity: 1; +} + +/* ── Product Card ── */ +.product-card { + background: var(--fk-surface); + border-radius: var(--fk-radius-lg); + border: 1px solid var(--fk-border); + overflow: hidden; + transition: all var(--fk-duration-md) var(--fk-ease-out); +} + +.product-card:hover { + box-shadow: var(--fk-shadow-lg); + transform: translateY(-4px); + border-color: var(--fk-amber-glass); +} + +.product-card__image { + aspect-ratio: 4/3; + background: var(--fk-seafoam-pale); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.product-card__image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.product-card__image-placeholder { + font-family: var(--fk-font-display); + font-size: var(--fk-text-2xl); + color: var(--fk-sage); + font-style: italic; +} + +.product-card__body { + padding: var(--fk-space-lg); +} + +.product-card__type { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fk-kiss-red); + margin-bottom: var(--fk-space-xs); +} + +.product-card__title { + font-size: var(--fk-text-xl); + margin-bottom: var(--fk-space-sm); +} + +.product-card__summary { + font-size: var(--fk-text-sm); + color: var(--fk-ink-soft); + margin-bottom: var(--fk-space-md); + line-height: var(--fk-leading-body); +} + +.product-card__chips { + display: flex; + flex-wrap: wrap; + gap: var(--fk-space-xs); + margin-bottom: var(--fk-space-md); +} + +.product-card__chip { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + padding: 0.25rem 0.75rem; + background: var(--fk-seafoam-pale); + color: var(--fk-ink-soft); + border-radius: var(--fk-radius-full); +} + +.product-card__footer { + display: flex; + align-items: center; + justify-content: space-between; +} + +.product-card__status { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + padding: 0.25rem 0.75rem; + border-radius: var(--fk-radius-full); + font-weight: 500; +} + +.product-card__status--inquiry { + background: var(--fk-amber-glass); + color: var(--fk-amber); +} + +.product-card__status--coming_soon { + background: var(--fk-seafoam-pale); + color: var(--fk-sage); +} + +/* ── Badge ── */ +.badge { + display: inline-flex; + align-items: center; + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + font-weight: 600; + padding: 0.3rem 0.8rem; + border-radius: var(--fk-radius-full); +} + +.badge--red { + background: var(--fk-kiss-red); + color: #fff; +} + +.badge--amber { + background: var(--fk-amber-glass); + color: var(--fk-amber); +} + +.badge--sage { + background: var(--fk-seafoam-pale); + color: var(--fk-sage); +} + +/* ── Ethos block ── */ +.ethos-block { + text-align: center; + padding: var(--fk-space-3xl) 0; +} + +.ethos-block__icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 72px; + height: 72px; + border-radius: 50%; + background: var(--fk-seafoam-pale); + margin-bottom: var(--fk-space-lg); +} + +.ethos-block__icon svg { + width: 36px; + height: 36px; + color: var(--fk-kiss-red); +} + +/* ── Review card ── */ +.review-card { + background: var(--fk-surface-warm); + border-radius: var(--fk-radius-md); + padding: var(--fk-space-xl); + border: 1px solid var(--fk-border); +} + +.review-card__text { + font-family: var(--fk-font-display); + font-style: italic; + font-size: var(--fk-text-lg); + line-height: var(--fk-leading-loose); + color: var(--fk-ink); + margin-bottom: var(--fk-space-md); +} + +.review-card__author { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-sm); + font-weight: 600; + color: var(--fk-ink); +} + +.review-card__location { + font-size: var(--fk-text-xs); + color: var(--fk-ink-soft); +} + +/* ── Contact form ── */ +.contact-form { + max-width: var(--fk-max-narrow); + margin: 0 auto; +} + +.form-group { + margin-bottom: var(--fk-space-lg); +} + +.form-label { + display: block; + font-family: var(--fk-font-ui); + font-size: var(--fk-text-sm); + font-weight: 500; + color: var(--fk-ink); + margin-bottom: var(--fk-space-xs); +} + +.form-input, +.form-select, +.form-textarea { + width: 100%; + font-family: var(--fk-font-body); + font-size: var(--fk-text-base); + padding: 0.75rem 1rem; + background: var(--fk-surface); + border: 1.5px solid var(--fk-border-strong); + border-radius: var(--fk-radius-md); + color: var(--fk-ink); + transition: border-color var(--fk-duration-fast), box-shadow var(--fk-duration-fast); +} + +.form-input:focus, +.form-select:focus, +.form-textarea:focus { + outline: none; + border-color: var(--fk-kiss-red); + box-shadow: 0 0 0 3px rgba(196, 30, 58, 0.1); +} + +.form-textarea { + min-height: 140px; + resize: vertical; +} + +.form-honeypot { + position: absolute; + left: -9999px; + opacity: 0; + height: 0; + width: 0; + overflow: hidden; +} + +.form-message { + padding: var(--fk-space-md) var(--fk-space-lg); + border-radius: var(--fk-radius-md); + font-size: var(--fk-text-sm); + margin-bottom: var(--fk-space-lg); +} + +.form-message--success { + background: var(--fk-seafoam-pale); + color: var(--fk-sage); + border: 1px solid var(--fk-seafoam); +} + +.form-message--error { + background: var(--fk-soft-pink); + color: var(--fk-kiss-red-dim); + border: 1px solid var(--fk-soft-pink); +} + +/* ── Journal card ── */ +.journal-card { + display: block; + background: var(--fk-surface); + border-radius: var(--fk-radius-md); + border: 1px solid var(--fk-border); + overflow: hidden; + transition: all var(--fk-duration-md) var(--fk-ease-out); +} + +.journal-card:hover { + box-shadow: var(--fk-shadow-md); + transform: translateY(-2px); +} + +.journal-card__image { + aspect-ratio: 16/9; + background: var(--fk-seafoam-pale); +} + +.journal-card__body { + padding: var(--fk-space-lg); +} + +.journal-card__date { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + color: var(--fk-ink-soft); + margin-bottom: var(--fk-space-xs); +} + +.journal-card__title { + font-size: var(--fk-text-lg); + margin-bottom: var(--fk-space-sm); +} + +.journal-card__summary { + font-size: var(--fk-text-sm); + color: var(--fk-ink-soft); +} + +/* ── Hero ── */ +.hero { + padding: var(--fk-space-4xl) 0; + text-align: center; + position: relative; + overflow: hidden; +} + +.hero::before { + content: ''; + position: absolute; + inset: 0; + background: + radial-gradient(ellipse 60% 50% at 50% 40%, var(--fk-amber-glass) 0%, transparent 70%), + radial-gradient(ellipse 80% 60% at 50% 50%, rgba(197, 216, 209, 0.15) 0%, transparent 70%); + pointer-events: none; +} + +.hero__eyebrow { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + text-transform: uppercase; + letter-spacing: 0.15em; + color: var(--fk-kiss-red); + margin-bottom: var(--fk-space-md); +} + +.hero__logo { + font-family: var(--fk-font-logo); + font-size: clamp(3.5rem, 8vw, 6rem); + color: var(--fk-kiss-red); + text-transform: uppercase; + letter-spacing: 0.02em; + line-height: 1; + margin-bottom: var(--fk-space-md); +} + +.hero__subhead { + font-family: var(--fk-font-display); + font-size: var(--fk-text-2xl); + font-style: italic; + color: var(--fk-ink-soft); + margin-bottom: var(--fk-space-lg); +} + +.hero__body { + max-width: 36rem; + margin: 0 auto var(--fk-space-xl); + font-size: var(--fk-text-lg); + color: var(--fk-ink-soft); + line-height: var(--fk-leading-loose); +} + +.hero__actions { + display: flex; + align-items: center; + justify-content: center; + gap: var(--fk-space-md); + flex-wrap: wrap; +} + +/* ── Kiss mark animation ── */ +.kiss-mark { + display: inline-block; + animation: kissPop 0.5s var(--fk-ease-spring) both; +} + +@keyframes kissPop { + 0% { transform: scale(0) rotate(-10deg); opacity: 0; } + 60% { transform: scale(1.15) rotate(2deg); opacity: 1; } + 100% { transform: scale(1) rotate(0deg); opacity: 1; } +} + +/* ── Sparkle ── */ +.sparkle { + position: absolute; + width: 6px; + height: 6px; + background: var(--fk-spark-color); + border-radius: 50%; + pointer-events: none; + animation: sparkleFade 1.2s ease-out forwards; +} + +@keyframes sparkleFade { + 0% { transform: scale(0) translateY(0); opacity: 1; } + 100% { transform: scale(1) translateY(-24px); opacity: 0; } +} + +/* ── Jingle button ── */ +.jingle-btn { + display: inline-flex; + align-items: center; + gap: var(--fk-space-sm); + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + color: var(--fk-muted-gold); + padding: var(--fk-space-sm) var(--fk-space-md); + border: 1px solid var(--fk-muted-gold); + border-radius: var(--fk-radius-full); + transition: all var(--fk-duration-fast); +} + +.jingle-btn:hover { + background: var(--fk-muted-gold); + color: var(--fk-ink); +} + +/* ── Product detail ── */ +.product-hero { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--fk-space-3xl); + align-items: center; + padding: var(--fk-space-3xl) 0; +} + +.product-hero__image { + aspect-ratio: 1; + background: var(--fk-seafoam-pale); + border-radius: var(--fk-radius-lg); + display: flex; + align-items: center; + justify-content: center; + overflow: hidden; +} + +.product-hero__image img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.product-hero__image-placeholder { + font-family: var(--fk-font-display); + font-size: var(--fk-text-3xl); + color: var(--fk-sage); + font-style: italic; +} + +.product-hero__type { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--fk-kiss-red); + margin-bottom: var(--fk-space-sm); +} + +.product-hero__title { + font-size: var(--fk-text-4xl); + margin-bottom: var(--fk-space-sm); +} + +.product-hero__identity { + font-family: var(--fk-font-display); + font-style: italic; + font-size: var(--fk-text-xl); + color: var(--fk-ink-soft); + margin-bottom: var(--fk-space-lg); +} + +.product-hero__summary { + font-size: var(--fk-text-lg); + line-height: var(--fk-leading-loose); + color: var(--fk-ink-soft); + margin-bottom: var(--fk-space-xl); +} + +.product-hero__chips { + display: flex; + flex-wrap: wrap; + gap: var(--fk-space-sm); + margin-bottom: var(--fk-space-xl); +} + +.product-hero__chip { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-sm); + padding: 0.4rem 1rem; + background: var(--fk-seafoam-pale); + color: var(--fk-ink-soft); + border-radius: var(--fk-radius-full); + font-weight: 500; +} + +.product-hero__actions { + display: flex; + align-items: center; + gap: var(--fk-space-md); +} + +@media (max-width: 768px) { + .product-hero { + grid-template-columns: 1fr; + gap: var(--fk-space-xl); + } + .product-hero__title { + font-size: var(--fk-text-3xl); + } +} + +/* ── Sensory block ── */ +.sensory-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--fk-space-lg); +} + +.sensory-card { + background: var(--fk-surface-warm); + border-radius: var(--fk-radius-md); + padding: var(--fk-space-xl); + text-align: center; + border: 1px solid var(--fk-border); +} + +.sensory-card__label { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--fk-kiss-red); + margin-bottom: var(--fk-space-sm); +} + +.sensory-card__value { + font-family: var(--fk-font-display); + font-style: italic; + font-size: var(--fk-text-lg); + color: var(--fk-ink); +} + +@media (max-width: 768px) { + .sensory-grid { + grid-template-columns: 1fr; + } +} + +/* ── FAQ ── */ +.faq-item { + border-bottom: 1px solid var(--fk-border); + padding: var(--fk-space-lg) 0; +} + +.faq-item__question { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-base); + font-weight: 600; + color: var(--fk-ink); + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + text-align: left; +} + +.faq-item__question::after { + content: '+'; + font-size: var(--fk-text-xl); + color: var(--fk-kiss-red); + transition: transform var(--fk-duration-fast); +} + +.faq-item.open .faq-item__question::after { + content: '−'; +} + +.faq-item__answer { + max-height: 0; + overflow: hidden; + transition: max-height var(--fk-duration-md) var(--fk-ease-out); + font-size: var(--fk-text-sm); + color: var(--fk-ink-soft); + line-height: var(--fk-leading-body); +} + +.faq-item.open .faq-item__answer { + max-height: 500px; + padding-top: var(--fk-space-md); +} + +/* ── Ingredient list ── */ +.ingredient-list { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: var(--fk-space-sm) var(--fk-space-xl); +} + +.ingredient-list__item { + display: flex; + align-items: center; + gap: var(--fk-space-sm); + font-size: var(--fk-text-sm); + color: var(--fk-ink-soft); + padding: var(--fk-space-sm) 0; + border-bottom: 1px solid var(--fk-border); +} + +.ingredient-list__item::before { + content: ''; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--fk-kiss-red); + flex-shrink: 0; +} + +@media (max-width: 768px) { + .ingredient-list { + grid-template-columns: 1fr; + } +} + +/* ── About page ── */ +.about-hero { + text-align: center; + padding: var(--fk-space-4xl) 0 var(--fk-space-2xl); +} + +.about-hero__title { + font-size: var(--fk-text-4xl); + margin-bottom: var(--fk-space-lg); +} + +.about-hero__body { + max-width: var(--fk-max-narrow); + margin: 0 auto; + font-size: var(--fk-text-lg); + line-height: var(--fk-leading-loose); + color: var(--fk-ink-soft); +} + +.about-section { + padding: var(--fk-space-3xl) 0; +} + +.about-section__title { + font-size: var(--fk-text-2xl); + margin-bottom: var(--fk-space-lg); +} + +.about-section__body { + font-size: var(--fk-text-lg); + line-height: var(--fk-leading-loose); + color: var(--fk-ink-soft); + max-width: var(--fk-max-narrow); +} + +/* ── Waitlist ── */ +.waitlist-block { + text-align: center; + padding: var(--fk-space-3xl); + background: var(--fk-surface-warm); + border-radius: var(--fk-radius-lg); + border: 1px solid var(--fk-border); +} + +.waitlist-block__title { + margin-bottom: var(--fk-space-md); +} + +.waitlist-block__body { + color: var(--fk-ink-soft); + margin-bottom: var(--fk-space-lg); +} diff --git a/assets/scss/_layout.scss b/assets/scss/_layout.scss new file mode 100644 index 0000000..ae36ee5 --- /dev/null +++ b/assets/scss/_layout.scss @@ -0,0 +1,64 @@ +/* ── Container ── */ +.container { + width: 100%; + max-width: var(--fk-max-width); + margin: 0 auto; + padding: 0 var(--fk-space-lg); +} + +.container--narrow { + max-width: var(--fk-max-narrow); +} + +/* ── Section ── */ +.section { + padding: var(--fk-section) 0; +} + +.section + .section { + padding-top: 0; +} + +.section__label { + font-family: var(--fk-font-ui); + font-size: var(--fk-text-xs); + text-transform: uppercase; + letter-spacing: 0.12em; + color: var(--fk-kiss-red); + margin-bottom: var(--fk-space-sm); +} + +.section__title { + margin-bottom: var(--fk-space-lg); +} + +.section__body { + max-width: var(--fk-max-narrow); + font-size: var(--fk-text-lg); + color: var(--fk-ink-soft); +} + +/* ── Grid helpers ── */ +.grid-2 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--fk-space-xl); +} + +.grid-3 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--fk-space-xl); +} + +@media (max-width: 768px) { + .grid-2, .grid-3 { + grid-template-columns: 1fr; + } + .section { + padding: var(--fk-space-3xl) 0; + } + h1 { font-size: var(--fk-text-3xl); } + h2 { font-size: var(--fk-text-2xl); } + h3 { font-size: var(--fk-text-xl); } +} diff --git a/assets/scss/_motion.scss b/assets/scss/_motion.scss new file mode 100644 index 0000000..0781546 --- /dev/null +++ b/assets/scss/_motion.scss @@ -0,0 +1,39 @@ +/* ── Arcade grid background (subtle) ── */ +.arcade-grid-bg { + position: relative; +} + +.arcade-grid-bg::after { + content: ''; + position: absolute; + inset: 0; + background-image: + linear-gradient(var(--fk-grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--fk-grid-line) 1px, transparent 1px); + background-size: 40px 40px; + pointer-events: none; + opacity: 0.5; +} + +/* ── Page transitions ── */ +.page-enter { + animation: fadeInUp 0.5s var(--fk-ease-out) both; +} + +@keyframes fadeInUp { + from { opacity: 0; transform: translateY(16px); } + to { opacity: 1; transform: translateY(0); } +} + +/* ── Image hover zoom ── */ +.img-hover-zoom { + overflow: hidden; +} + +.img-hover-zoom img { + transition: transform 0.6s var(--fk-ease-out); +} + +.img-hover-zoom:hover img { + transform: scale(1.04); +} diff --git a/assets/scss/_tokens.scss b/assets/scss/_tokens.scss new file mode 100644 index 0000000..ee2075d --- /dev/null +++ b/assets/scss/_tokens.scss @@ -0,0 +1,82 @@ +:root { + /* ── Palette ── */ + --fk-cream: #FBF7F2; + --fk-parchment: #F4EDE4; + --fk-kiss-red: #C41E3A; + --fk-kiss-red-dim: #9B1730; + --fk-seafoam: #C5D8D1; + --fk-seafoam-pale: #E4EFEA; + --fk-sage: #9BAF9C; + --fk-amber: #D4841A; + --fk-amber-glass: rgba(212, 132, 26, 0.12); + --fk-brown: #5C3D2E; + --fk-ink: #1C1410; + --fk-ink-soft: #3D2E1E; + --fk-muted-gold: #C4A882; + --fk-arcade-mint: #7DE8D4; + --fk-soft-pink: #F2D7D9; + + /* ── Surfaces ── */ + --fk-bg: var(--fk-cream); + --fk-surface: #FFFFFF; + --fk-surface-warm: var(--fk-parchment); + --fk-border: rgba(92, 61, 46, 0.12); + --fk-border-strong:rgba(92, 61, 46, 0.25); + + /* ── Typography ── */ + --fk-font-display: 'Playfair Display', 'Cormorant Garamond', Georgia, serif; + --fk-font-body: 'Inter', 'SF Pro Text', -apple-system, BlinkMacSystemFont, sans-serif; + --fk-font-ui: 'Inter', 'SF Pro Text', -apple-system, sans-serif; + --fk-font-logo: 'Bebas Neue', 'Anton', 'Impact', sans-serif; + + --fk-text-base: 1rem; + --fk-text-scale: 1.25; + --fk-text-sm: 0.875rem; + --fk-text-xs: 0.75rem; + --fk-text-lg: 1.25rem; + --fk-text-xl: 1.563rem; + --fk-text-2xl: 1.953rem; + --fk-text-3xl: 2.441rem; + --fk-text-4xl: 3.052rem; + + --fk-leading-tight: 1.15; + --fk-leading-body: 1.65; + --fk-leading-loose: 1.8; + + /* ── Spacing ── */ + --fk-space-xs: 0.25rem; + --fk-space-sm: 0.5rem; + --fk-space-md: 1rem; + --fk-space-lg: 1.5rem; + --fk-space-xl: 2rem; + --fk-space-2xl: 3rem; + --fk-space-3xl: 4rem; + --fk-space-4xl: 6rem; + --fk-section: 5rem; + + /* ── Layout ── */ + --fk-max-width: 72rem; + --fk-max-narrow: 42rem; + --fk-header-height: 4rem; + --fk-radius-sm: 6px; + --fk-radius-md: 12px; + --fk-radius-lg: 20px; + --fk-radius-full: 9999px; + + /* ── Shadows ── */ + --fk-shadow-sm: 0 1px 3px rgba(28, 20, 16, 0.06); + --fk-shadow-md: 0 4px 16px rgba(28, 20, 16, 0.08); + --fk-shadow-lg: 0 8px 32px rgba(28, 20, 16, 0.10); + --fk-shadow-glow: 0 0 24px rgba(212, 132, 26, 0.18); + + /* ── Motion ── */ + --fk-ease-out: cubic-bezier(0.16, 1, 0.3, 1); + --fk-ease-spring:cubic-bezier(0.34, 1.56, 0.64, 1); + --fk-duration-fast: 150ms; + --fk-duration-md: 300ms; + --fk-duration-slow: 500ms; + + /* ── Arcade accents ── */ + --fk-grid-line: rgba(125, 232, 212, 0.08); + --fk-spark-color: var(--fk-arcade-mint); +} diff --git a/assets/scss/_type.scss b/assets/scss/_type.scss new file mode 100644 index 0000000..06865f0 --- /dev/null +++ b/assets/scss/_type.scss @@ -0,0 +1,98 @@ +/* ── Reset & Base ── */ +*, +*::before, +*::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +html { + font-size: 100%; + -webkit-text-size-adjust: 100%; + scroll-behavior: smooth; +} + +body { + font-family: var(--fk-font-body); + font-size: var(--fk-text-base); + line-height: var(--fk-leading-body); + color: var(--fk-ink); + background: var(--fk-bg); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +img, svg, video { + display: block; + max-width: 100%; + height: auto; +} + +a { + color: inherit; + text-decoration: none; +} + +button { + font: inherit; + cursor: pointer; + border: none; + background: none; + color: inherit; +} + +ul, ol { + list-style: none; +} + +h1, h2, h3, h4, h5, h6 { + font-family: var(--fk-font-display); + line-height: var(--fk-leading-tight); + font-weight: 600; + color: var(--fk-ink); +} + +h1 { font-size: var(--fk-text-4xl); } +h2 { font-size: var(--fk-text-3xl); } +h3 { font-size: var(--fk-text-2xl); } +h4 { font-size: var(--fk-text-xl); } + +p + p { margin-top: var(--fk-space-md); } + +::selection { + background: var(--fk-kiss-red); + color: #fff; +} + +/* ── Focus ── */ +:focus-visible { + outline: 2px solid var(--fk-kiss-red); + outline-offset: 3px; +} + +/* ── Skip link ── */ +.skip-link { + position: absolute; + top: -100%; + left: var(--fk-space-md); + background: var(--fk-ink); + color: #fff; + padding: var(--fk-space-sm) var(--fk-space-md); + border-radius: var(--fk-radius-sm); + z-index: 9999; + font-size: var(--fk-text-sm); +} +.skip-link:focus { + top: var(--fk-space-md); +} + +/* ── Reduced motion ── */ +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + } + html { scroll-behavior: auto; } +} diff --git a/assets/scss/main.scss b/assets/scss/main.scss new file mode 100644 index 0000000..8083781 --- /dev/null +++ b/assets/scss/main.scss @@ -0,0 +1,5 @@ +@import 'tokens'; +@import 'type'; +@import 'layout'; +@import 'components'; +@import 'motion'; diff --git a/assets/ui/favicon.svg b/assets/ui/favicon.svg new file mode 100644 index 0000000..6a733bd --- /dev/null +++ b/assets/ui/favicon.svg @@ -0,0 +1 @@ + diff --git a/assets/ui/logo-fat-kiss.svg b/assets/ui/logo-fat-kiss.svg new file mode 100644 index 0000000..8782800 --- /dev/null +++ b/assets/ui/logo-fat-kiss.svg @@ -0,0 +1 @@ +FAT KISS diff --git a/assets/ui/logo-lips.svg b/assets/ui/logo-lips.svg new file mode 100644 index 0000000..fe49b7a --- /dev/null +++ b/assets/ui/logo-lips.svg @@ -0,0 +1 @@ + diff --git a/content/_index.md b/content/_index.md new file mode 100644 index 0000000..8ae9840 --- /dev/null +++ b/content/_index.md @@ -0,0 +1,6 @@ +--- +title: "Fat Kiss" +date: 2026-05-10 +--- + +Welcome to Fat Kiss — natural ritual skincare for the Renaissance Woman. diff --git a/content/about/index.md b/content/about/index.md new file mode 100644 index 0000000..bc71efc --- /dev/null +++ b/content/about/index.md @@ -0,0 +1,66 @@ +--- +title: "About Fat Kiss" +date: 2026-05-10 +seo_title: "About — Fat Kiss Natural Ritual Skincare" +seo_description: "Fat Kiss is natural ritual skincare for the Renaissance Woman. Handcrafted balms made with six simple ingredients on Kauaʻi." +founder_story: + title: "The Woman Behind the Balm" + body: | + Amber didn't set out to start a skincare brand. She set out to make something for her own face — something that actually worked past the first hour, something made from ingredients she could pronounce, something that didn't require a chemistry degree or a twelve-step routine. + + What happened next surprised her. Friends asked what she was using. Then they asked if she could make them some. Then their friends asked. The balm was spreading faster than any marketing campaign could have — because it was doing something increasingly rare in modern skincare: it was actually working. + + Amber is a Renaissance Woman in the truest sense. She doesn't fit into a single category, and neither does Fat Kiss. The brand reflects her — capable, creative, quietly confident, and unwilling to choose between being soft and being strong. She makes balms the way she lives: with intention, with care, and with a refusal to cut corners just because they're there. + + Fat Kiss is still made in small batches. Still six ingredients. Still the same process that started in Amber's kitchen late at night with a scale and a double boiler. The only thing that's changed is how many people now know what a kiss from a good balm actually feels like. +product_philosophy: + title: "Why Six Ingredients Is Enough" + body: | + Modern skincare has a complexity problem. Walk down any beauty aisle and you'll find products with forty, fifty, sixty ingredients — most of them there for texture, preservation, shelf stability, or marketing. Very few of them are there because your skin actually needs them. + + Fat Kiss takes the opposite approach. Every balm contains exactly six ingredients. Not because six is a magic number, but because these six — chosen carefully, sourced well, and blended with intention — do everything that needs to be done. + + Grass-fed beef suet tallow forms the foundation. It's one of the most bio-compatible fats you can put on human skin — its fatty acid profile mirrors the oils your skin produces naturally. Jojoba oil mimics your skin's sebum. Rosehip seed oil brings essential fatty acids. Castor oil adds richness and glide. Frankincense essential oil provides a grounding, warm presence. Vitamin E rounds out the blend. + + That's it. No fillers. No synthetic fragrances. No petroleum derivatives. No "active complexes" that sound impressive but do very little. Just six ingredients that your skin recognizes, absorbs, and actually uses. + + The philosophy is simple: if an ingredient doesn't earn its place, it doesn't go in the jar. Your face deserves better than filler. +renaissance_woman: + title: "What Renaissance Woman Means Here" + body: | + The Renaissance Woman isn't a costume. It's not about pretending to live in another century or performing some idealized version of femininity. It's about rejecting the idea that women have to choose one thing. + + You can be soft and strong. Natural and precise. Playful and serious. You can make balms in your kitchen and run a business. You can spend the morning in the ocean and the afternoon in a meeting. You can be a mother, a maker, a mover, a thinker — all at once, without apology. + + Fat Kiss is built on this idea. The brand doesn't ask you to be one kind of woman. It doesn't market to a demographic or a "skin type." It speaks to women who do more than one thing — because Amber does more than one thing, and she refuses to pretend otherwise. + + The Renaissance Woman ethos shows up in everything: the ingredients (ancient and effective, not trendy), the design (retro and modern, not one or the other), the tone (confident and warm, not corporate or cutesy). It's skincare for women who contain multitudes. +natural_ritual: + title: "The Ritual of Natural Care" + body: | + There's a difference between applying a product and performing a ritual. A product you use because you're supposed to. A ritual you return to because it feels good — because the act itself is part of the benefit. + + Fat Kiss balms are designed for ritual. The way the tallow softens at body temperature. The way the frankincense settles into your awareness — not as a fragrance, but as a presence. The way your skin feels five minutes after application, and then an hour later, and then the next morning. + + This isn't about adding steps to your routine. It's about making the steps you already take feel more meaningful. Washing your face becomes a reset. Moisturizing becomes a moment of pause. Your skin becomes something you notice with appreciation rather than something you fight against. + + Natural ritual care means working with your body instead of against it. It means choosing ingredients that participate in your skin's health rather than just coating it. It means slowing down enough to feel the difference — and then carrying that feeling into the rest of your day. +future_vision: + title: "Where Fat Kiss Is Going" + body: | + Fat Kiss is in its first chapter. Right now, the focus is on getting these balms into the hands of women who will feel the difference — women whose skin lives outdoors, works hard, and deserves better than the beauty aisle. + + The product line will grow. New formulations are already in development — always with the same philosophy: minimal ingredients, maximum intention, nothing that doesn't earn its place. Seasonal scents may come. Bundles and gift sets. Eventually, a full apothecary line that extends the Fat Kiss approach beyond balms. + + But the core won't change. Small batches. Six-ingredient foundations. Bio-compatible intelligence. The refusal to add anything just because the industry expects it. Fat Kiss will grow at its own pace, in its own way — the way Amber does everything. + + This is Phase 1. The legitimacy chapter. The moment when Fat Kiss stops being something Amber makes for friends and becomes something the world can find. Everything from here builds on this foundation — and the foundation is solid. +cta: + label: "Get in Touch with Amber" + url: "/contact/" +--- +Fat Kiss is natural ritual skincare for the Renaissance Woman — handcrafted on Kauaʻi with six simple ingredients chosen for how they work together, not how they sound on a label. + +No fillers. No synthetic fragrances. No petroleum. Just rich, nourishing balms that your skin recognizes, absorbs, and actually uses. + +Everybody Wants One. Here's why. diff --git a/content/contact/index.md b/content/contact/index.md new file mode 100644 index 0000000..5465c33 --- /dev/null +++ b/content/contact/index.md @@ -0,0 +1,7 @@ +--- +title: "Contact" +date: 2026-05-10 +seo_title: "Contact — Fat Kiss Natural Ritual Skincare" +seo_description: "Get in touch with Fat Kiss. Questions about balms, orders, wholesale, or just want to say hello." +noindex: false +--- diff --git a/content/journal/renaissance-woman-means-here/index.md b/content/journal/renaissance-woman-means-here/index.md new file mode 100644 index 0000000..aebc992 --- /dev/null +++ b/content/journal/renaissance-woman-means-here/index.md @@ -0,0 +1,42 @@ +--- +title: "What Renaissance Woman Means Here" +date: 2026-05-08 +draft: false +summary: "On rejecting the idea that women have to choose one thing — and building a skincare brand around that refusal." +tags: ["renaissance woman", "philosophy", "femininity"] +seo_title: "What Renaissance Woman Means Here — Fat Kiss Journal" +seo_description: "The Renaissance Woman ethos behind Fat Kiss: rejecting the idea that women must choose one thing." +--- +The phrase "Renaissance Woman" gets thrown around a lot. It's become shorthand for "woman who has multiple hobbies" or "woman who's good at more than one thing." But at Fat Kiss, it means something more specific — and more important. + +## The Problem With Choosing + +From the moment we're old enough to absorb cultural messages, women are asked to choose. Soft or strong. Pretty or smart. Nurturing or ambitious. Natural or polished. The categories are presented as mutually exclusive — you can be one thing or the other, but not both. Pick a lane. + +This is exhausting. It's also false. + +Amber has never fit into a single category. She's a maker and a business owner. A beach person and a work person. Someone who crafts balms in her kitchen and someone who thinks seriously about formulation, sourcing, and brand. She's soft-spoken and fiercely capable. None of these things cancel each other out. + +Fat Kiss is built on the refusal to choose. The brand is natural and polished. Earthy and editorial. Ancient in its ingredients and modern in its design. Playful and serious. Feminine and strong. These aren't contradictions — they're dimensions of the same thing. + +## What Renaissance Woman Looks Like in Practice + +It looks like using tallow — an ingredient that's been used for thousands of years — in a brand that feels contemporary and fresh. It looks like designing packaging that references 1980s arcade aesthetics while feeling like a luxury apothecary. It looks like writing copy that's warm and intelligent, sensual and smart, confident and inviting. + +It looks like Amber, basically. A woman who contains multitudes and refuses to edit herself down to fit a demographic profile. + +## Why This Matters for Skincare + +The beauty industry is built on insecurity. It tells you your skin is wrong — too dry, too oily, too old, too dull — and then sells you the fix. The message is: you're not enough, but this product will help. + +Fat Kiss inverts that message. Your skin isn't wrong. It's doing incredible work every day — protecting you, regulating you, sensing the world for you. It deserves care, not correction. It deserves ingredients that work with it, not against it. It deserves ritual, not routine. + +The Renaissance Woman doesn't need to be fixed. She needs to be fed. That's what these balms do. + +## The Invitation + +You don't have to be any one thing. You don't have to choose between the parts of yourself. You can be the woman who runs the meeting and the woman who spends Saturday in the garden. The woman who wears lipstick and the woman who goes bare-faced to the beach. The woman who makes things, builds things, cares for things, and still has time to kiss the world back. + +Fat Kiss is for that woman. The one who contains multitudes. The one who refuses to choose. + +Everybody Wants One — because everybody is more than one thing. diff --git a/content/journal/ritual-behind-fat-kiss/index.md b/content/journal/ritual-behind-fat-kiss/index.md new file mode 100644 index 0000000..bbf3798 --- /dev/null +++ b/content/journal/ritual-behind-fat-kiss/index.md @@ -0,0 +1,32 @@ +--- +title: "The Ritual Behind Fat Kiss" +date: 2026-05-01 +draft: false +summary: "Why a balm is never just a balm — and what happens when skincare becomes a ritual instead of a routine." +tags: ["ritual", "philosophy", "origin story"] +seo_title: "The Ritual Behind Fat Kiss — Journal" +seo_description: "Why Fat Kiss balms are designed as rituals, not just products." +--- +Most skincare products are designed to be used and forgotten. Apply. Absorb. Move on. Fat Kiss was never meant to work that way. + +Amber designed these balms to be felt — not just as skin sensation, but as presence. The warmth of tallow softening between your fingers. The grounding scent of frankincense settling into the air. The slow, deliberate act of pressing nourishment into your own face. These aren't accidents of formulation. They're features. + +## Routine vs. Ritual + +A routine is something you do because you have to. Brush teeth. Wash face. Apply moisturizer. Check boxes. A routine is efficient, but hollow — it asks nothing of you except completion. + +A ritual is something you return to because it means something. It has texture. It has pace. It engages your senses and pulls you into the present moment. A ritual doesn't just accomplish a task — it changes your state. + +Fat Kiss balms are built for ritual. The tallow base requires you to slow down — you can't pump it from a bottle. You have to warm it. Work it. Pay attention. The frankincense anchors you. The way your skin feels afterward isn't just "moisturized." It's yours, but better. + +## Why This Matters + +We treat skincare as either medicine or vanity. Either fixing a problem or performing beauty. Both turn your skin into a project — something to correct or optimize. + +Ritual reframes everything. Your skin isn't a problem. It's the surface you live inside — the boundary between you and the world. Caring for it can be gratitude, not correction. A way of saying: this body is doing good work. It deserves to feel good. + +## Build Your Own Ritual + +Start with one intentional moment. Before applying your Face Balm tonight, pause for ten seconds. Feel the texture change as the tallow warms. Notice the frankincense. Press the balm in slowly — the slowness itself is the point. + +Do this three nights in a row. See if something shifts. Not in your skin — in you. The balm is the vehicle. The pause is the point. diff --git a/content/products/body-balm/index.md b/content/products/body-balm/index.md new file mode 100644 index 0000000..857a8a4 --- /dev/null +++ b/content/products/body-balm/index.md @@ -0,0 +1,45 @@ +--- +title: "Body Balm" +date: 2026-05-10 +product_type: "body_balm" +status: "inquiry" +featured: true +sort_order: 2 +short_summary: "Full-body moisture for skin that works hard, plays outside, and deserves to feel soft everywhere." +one_line_identity: "From shoulders to ankles. From saltwater to sheets." +benefit_chips: ["Full-body moisture", "Weathered-skin comfort", "Slow beauty ritual", "All-day softness"] +blend_benefits_rich_text: | + The Body Balm takes everything that makes the Face Balm extraordinary and scales it for the rest of you. Same tallow foundation. Same bio-compatible intelligence. Same six-ingredient honesty. But formulated with a slightly richer hand — because your elbows, knees, and shoulders have different stories to tell than your cheeks. + + This is the balm for women whose skin lives outdoors. Saltwater swimmers. Gardeners with dirt under their nails. Surfers who measure time in tides. Hikers who come home with sun on their shoulders and wind on their calves. The Body Balm doesn't just moisturize — it restores the feeling of skin that's been lived in, loved hard, and is ready for more. + + Grass-fed tallow sinks into the places that need it most. Jojoba and rosehip keep the texture glide-smooth — never waxy, never greasy. Castor oil gives it staying power through a full day. Frankincense brings that same grounding warmth, and vitamin E rounds out the blend with its quiet, conditioning presence. + + This isn't a body lotion you apply in thirty seconds and forget about. It's a ritual. A slow, deliberate act of care that says: this body works. This body deserves to feel good. +directions: "Scoop a generous amount and warm between your palms. Apply to damp skin after a shower, or anytime your skin is asking for more. Focus on dry areas — elbows, knees, shins, shoulders. Let it sink in for a few minutes before dressing." +ritual_note: "After the ocean. After the garden. After the long day. Your body carried you through it — kiss it back." +smells_like: "Earthy and warm, like sun-warmed skin and coastal air — frankincense grounded in something quietly sweet" +feels_like: "Rich and buttery, melts on contact, leaves skin feeling cushioned and conditioned — never slick" +good_for: "Full-body moisture, post-sun recovery, weathered-skin comfort, slow self-care rituals" +ingredients_summary: "Six ingredients. Full-body coverage. Nothing your skin doesn't recognize." +ingredients_list: ["Grass-fed beef suet tallow", "Jojoba oil", "Rosehip seed oil", "Castor oil", "Frankincense essential oil", "Vitamin E"] +cta_label: "Ask About Body Balm" +cta_mode: "inquire" +seo_title: "Body Balm — Fat Kiss Natural Ritual Skincare" +seo_description: "Full-body natural balm made with grass-fed tallow, jojoba, rosehip, and frankincense. For skin that lives outdoors." +faq_items: + - question: "How is this different from the Face Balm?" + answer: "Same six ingredients, slightly richer ratio — formulated for the thicker skin on your body. It's a bit more substantial, designed to last through a full day of movement." + - question: "Will it stain my clothes?" + answer: "Let it absorb for 3-5 minutes before dressing and you'll be fine. The tallow base integrates with your skin rather than sitting on top." + - question: "Can I use it on my face too?" + answer: "You can — but the Face Balm is calibrated specifically for facial skin. The Body Balm is richer and may feel heavier on delicate areas." +--- + +Some products are designed for the bathroom counter. The Body Balm is designed for the beach bag, the garden shed, the weekend duffel, the nightstand. + +Amber made this for women who use their bodies — not just dress them. Women who come home salty and sun-drenched and want something that meets their skin where it actually is: weathered, worked, alive. + +The Body Balm doesn't apologize for being rich. It doesn't pretend to be a "lightweight lotion" that evaporates in ten minutes. It's substantial. It's deliberate. It's the kind of moisture that still feels present hours later — not as a film, but as a condition. Your skin, but deeply comfortable in itself. + +This is slow beauty. The kind that takes a minute to apply and lasts all day. The kind that turns a post-shower routine into a ritual. The kind that makes you pause, notice your own skin, and think: yeah. This body is doing good work. diff --git a/content/products/face-balm/index.md b/content/products/face-balm/index.md new file mode 100644 index 0000000..fda442c --- /dev/null +++ b/content/products/face-balm/index.md @@ -0,0 +1,56 @@ +--- +title: "Face Balm" +date: 2026-05-10 +product_type: "face_balm" +status: "inquiry" +featured: true +sort_order: 1 +short_summary: "A rich daily moisturizer that softens, nourishes, and brings out your skin's natural glow — without feeling heavy or greasy. The balm that started everything." +one_line_identity: "Your skin, but softer. Your glow, but louder. Your face, but kissed." +benefit_chips: ["Deep, lasting moisture", "Velvety-soft finish", "Daily ritual anchor", "Lit-from-within glow", "Absorbs without residue"] +blend_benefits_rich_text: | + The Face Balm is built around a single, uncompromising idea: your face deserves ingredients that work with your skin, not against it. Not ingredients chosen because they photograph well on a label. Not ingredients added because a marketing team thought they'd sound expensive. Ingredients chosen because they belong there. + + At the heart of this formula is grass-fed beef suet tallow — and if that word gives you pause, let it settle for a moment. Tallow is one of the most bio-compatible fats you can put on human skin. Its fatty acid profile mirrors the oils your own skin produces naturally. That means it doesn't sit on top like a synthetic barrier. It doesn't evaporate in twenty minutes like a water-based lotion. It absorbs. It integrates. It feeds your skin in a language your skin already understands. + + Around that tallow foundation, the blend builds outward with intention. Jojoba oil — technically a liquid wax ester, not an oil — mimics your skin's sebum so closely that it's been used in dermatology for decades. Rosehip seed oil brings its quiet, regenerative presence — rich in essential fatty acids that skin recognizes and uses. Castor oil adds slip and richness, helping the balm glide across your face without pulling. Frankincense essential oil is the soul of the blend: grounding, warm, subtly botanical — chosen as much for how it makes you feel as for what it does. Vitamin E closes the circle, supporting the conditioned, fresh feel of skin that's been properly cared for. + + This isn't a product that announces itself with heavy fragrance or a complicated twelve-step routine. It's a quiet, confident balm that does one thing extremely well: makes your skin feel like the best version of itself. Not masked. Not coated. Not temporarily improved. Actually, genuinely, deeply comfortable in its own condition. + + The Face Balm doesn't fight your face. It feeds it. +directions: | + Warm a small pea-sized amount between your fingertips — the heat of your skin will soften the balm instantly. Press gently into clean skin, starting at the center of your face and working outward. Use morning and evening, or whenever your skin is asking for more. + + A little goes a long way. Start small. You can always add more. The balm will tell you when you've found the right amount — your skin will feel cushioned, not coated. +ritual_note: "Apply before bed. Let it sink in while you sleep. Wake up to skin that feels like it's been kissed by something good. Do this three nights in a row and watch what happens." +smells_like: "Warm, earthy, subtly botanical — frankincense with a whisper of honey and something quietly ancient" +feels_like: "Rich and velvety on contact, melts at body temperature, absorbs into a soft-matte finish that feels like skin — just better skin" +good_for: "Daily moisture, softening and conditioning, post-sun comfort, natural glow, skin that lives outdoors, skin that's been through things" +ingredients_summary: "Six ingredients. No fillers. No synthetic fragrances. No petroleum derivatives. Just tallow, four oils, and vitamin E — chosen for how they work together, not how they sound alone." +ingredients_list: ["Grass-fed beef suet tallow", "Jojoba oil", "Rosehip seed oil", "Castor oil", "Frankincense essential oil", "Vitamin E"] +cta_label: "Ask About Face Balm" +cta_mode: "inquire" +seo_title: "Face Balm — Fat Kiss Natural Ritual Skincare" +seo_description: "Rich daily face moisturizer made with grass-fed tallow, jojoba, rosehip, and frankincense. Softens, nourishes, and brings out your skin's natural glow." +faq_items: + - question: "Will this make my face feel greasy?" + answer: "No — the tallow base is bio-compatible with your skin, so it absorbs rather than sitting on top. Start with a small amount and you'll feel it melt in within minutes. The finish is soft-matte, not slick." + - question: "Is it scented?" + answer: "The only scent comes from the frankincense essential oil — it's subtle, warm, and grounding. No synthetic fragrances, no perfume blends, no 'natural flavors' hiding behind vague labeling." + - question: "How long does one jar last?" + answer: "With daily use, a standard jar typically lasts 2-3 months. A little goes a long way — most people use less than they think they'll need." + - question: "Can I use it under makeup?" + answer: "Yes — let it absorb for 3-5 minutes before applying foundation. Many people find their makeup sits better on conditioned skin than on dry or silicone-primed skin." + - question: "Is it safe for sensitive skin?" + answer: "The six-ingredient formula is intentionally minimal. No common irritants, no synthetic preservatives, no alcohol. That said, everyone's skin is different — patch-test if you're concerned." +--- + +The Face Balm is where Fat Kiss started. Not in a boardroom. Not in a branding exercise. In Amber's kitchen, late at night, with a scale, a double boiler, and a conviction that skincare had gotten too complicated. + +She wasn't trying to launch a brand. She was trying to make something for her own face — something that actually worked past the first hour. Something that didn't require a chemistry degree to understand. Something made from ingredients that existed before the skincare industry decided we needed seventy-step routines and actives we can't pronounce. + +What came out of those first batches was unexpected. Friends asked what she was using. Then they asked if she could make them some. Then their friends asked. The balm was spreading faster than any marketing campaign could have — because it was doing something rare in modern skincare: it was actually working. + +The Face Balm is still made the same way. Same six ingredients. Same small-batch process. Same refusal to add anything that doesn't earn its place. It's not a product designed by a corporation trying to capture a "clean beauty" demographic. It's a balm made by a woman who wanted her face to feel good — and then discovered that a lot of other women wanted the same thing. + +This is the one that started the kiss. diff --git a/content/products/lip-balm/index.md b/content/products/lip-balm/index.md new file mode 100644 index 0000000..1127c8b --- /dev/null +++ b/content/products/lip-balm/index.md @@ -0,0 +1,45 @@ +--- +title: "Lip Balm" +date: 2026-05-10 +product_type: "lip_balm" +status: "coming_soon" +featured: true +sort_order: 3 +short_summary: "A pocket-sized ritual. Soft-feeling lips without the waxy buildup, the petroleum slick, or the need to reapply every twenty minutes." +one_line_identity: "The smallest kiss. The softest statement." +benefit_chips: ["Pocket ritual", "Soft-feeling lips", "Simple natural care", "No petroleum"] +blend_benefits_rich_text: | + Most lip balms are designed to sit on your lips like a seal — a waxy, occlusive film that feels present for about fifteen minutes and then vanishes, leaving your lips drier than before. The Fat Kiss Lip Balm works differently. It absorbs. It conditions. It actually participates in the health of your lip skin rather than just glazing over it. + + The same tallow foundation that makes the Face and Body Balms so effective scales down to lip scale beautifully. Tallow's fatty acid profile is nearly identical to the oils your lip skin produces naturally — which means it doesn't just coat, it integrates. Jojoba oil brings that familiar sebum-mimicking intelligence. Rosehip seed oil adds its quiet regenerative presence. Castor oil gives the balm its glide and a subtle, glass-like shine. Frankincense keeps the scent grounded and warm. Vitamin E rounds it out. + + The result is a lip balm that feels like nothing — and everything. Nothing waxy. Nothing sticky. Nothing that makes you conscious of "wearing" a product. Just lips that feel soft, conditioned, and quietly cared for. The kind of soft that makes you touch your lips with your finger just to check if it's real. +directions: "Swipe a small amount across lips anytime. Layer under lip color for a conditioned base. Apply before bed as an overnight treatment." +ritual_note: "Keep one in your pocket. Apply without a mirror. Feel the difference between coated and conditioned." +smells_like: "Barely there — a whisper of frankincense, warm and clean, like the memory of something good" +feels_like: "Weightless and smooth, glides on like silk, absorbs into lips rather than sitting on top" +good_for: "Daily lip comfort, under-lipstick conditioning, overnight treatment, wind and salt protection" +ingredients_summary: "Six ingredients. Zero petroleum. Lips that feel like lips — just softer." +ingredients_list: ["Grass-fed beef suet tallow", "Jojoba oil", "Rosehip seed oil", "Castor oil", "Frankincense essential oil", "Vitamin E"] +cta_label: "Get Notified When Available" +cta_mode: "coming_soon" +seo_title: "Lip Balm — Fat Kiss Natural Ritual Skincare" +seo_description: "Natural lip balm made with grass-fed tallow, jojoba, and rosehip. No petroleum. Soft, conditioned lips that last." +faq_items: + - question: "Why tallow in a lip balm?" + answer: "Tallow's fatty acid profile closely matches human skin oils — it absorbs and conditions rather than just coating. Your lips actually benefit from it instead of just being sealed under wax." + - question: "Does it have SPF?" + answer: "No — Fat Kiss balms are cosmetic moisturizers, not sunscreens. We don't make SPF or sun protection claims." + - question: "When will it be available?" + answer: "The Lip Balm is in final formulation. Join the waitlist or send Amber a note — she'll let you know the moment it's ready." +--- + +The Lip Balm almost didn't happen. + +Amber spent months focused on the Face and Body Balms — the big jars, the serious moisture, the full-ritual experience. But people kept asking. Friends. Early testers. Women who'd tried the Face Balm and wanted something they could carry in their pocket. Something for the in-between moments. Something small enough to be everywhere. + +So she made it. Same six ingredients. Same philosophy. Scaled down to the size of a kiss. + +The Lip Balm is the most portable argument for Fat Kiss's whole approach: that natural ingredients, chosen well and blended with intention, outperform the synthetic shortcuts every time. No petroleum jelly. No artificial waxes. No "cooling" agents that actually dry your lips out over time. Just tallow, oils, and the quiet confidence of a formula that knows what it's doing. + +It's small. It's simple. It's the easiest way to understand what Fat Kiss is about — because you can feel the difference in about three seconds. diff --git a/data/reviews/review-001.yaml b/data/reviews/review-001.yaml new file mode 100644 index 0000000..956539d --- /dev/null +++ b/data/reviews/review-001.yaml @@ -0,0 +1,9 @@ +id: review-001 +enabled: true +reviewer_name: "Maya K." +reviewer_location: "Kauaʻi" +review_text: "I've tried every fancy moisturizer on the island and nothing has made my skin feel this soft. The Face Balm sinks in without feeling greasy and I actually look forward to putting it on every night. It's become my favorite part of the day." +product_slug: "face-balm" +rating: 5 +date: "2026-04-15" +featured: true diff --git a/data/reviews/review-002.yaml b/data/reviews/review-002.yaml new file mode 100644 index 0000000..4dac184 --- /dev/null +++ b/data/reviews/review-002.yaml @@ -0,0 +1,9 @@ +id: review-002 +enabled: true +reviewer_name: "Sarah L." +reviewer_location: "Hanalei" +review_text: "The Body Balm saved my skin after a week of saltwater and sun. It's rich without being heavy, and the subtle scent is so calming. I keep one in my beach bag and one on my nightstand. My husband keeps stealing it too." +product_slug: "body-balm" +rating: 5 +date: "2026-05-01" +featured: true diff --git a/data/reviews/review-003.yaml b/data/reviews/review-003.yaml new file mode 100644 index 0000000..4970917 --- /dev/null +++ b/data/reviews/review-003.yaml @@ -0,0 +1,9 @@ +id: review-003 +enabled: true +reviewer_name: "Jenna R." +reviewer_location: "Portland, OR" +review_text: "I ordered the Lip Balm on a whim and now I have three — one in my purse, one in my car, one by my bed. It's the only thing that actually keeps my lips soft without that weird waxy feeling. Plus the little kiss on the jar makes me smile every time." +product_slug: "lip-balm" +rating: 5 +date: "2026-05-08" +featured: false diff --git a/data/site/contact.yaml b/data/site/contact.yaml new file mode 100644 index 0000000..91068b8 --- /dev/null +++ b/data/site/contact.yaml @@ -0,0 +1,31 @@ +public_aliases: + general: hello@getfatkiss.com + orders: orders@getfatkiss.com + press: press@getfatkiss.com + +recipient_routing: + product_question: hello + order_inquiry: orders + wholesale: orders + collaboration_press: press + general: hello + +display_email_publicly: false +display_phone_publicly: false +phone_number: "" +instagram_url: "" +facebook_url: "" +tiktok_url: "" + +form_categories: + - product question + - order inquiry + - wholesale + - collaboration / press + - general + +contact_intro: "Questions about the balms? Want to place an order? Just want to say hello? Amber reads every message." +success_message: "Thank you — your note made it through. Fat Kiss will get back to you soon." +error_message: "Something did not go through. Please try again in a moment." +business_hours_optional: "" +local_pickup_note_optional: "" diff --git a/data/site/home.yaml b/data/site/home.yaml new file mode 100644 index 0000000..bf50b48 --- /dev/null +++ b/data/site/home.yaml @@ -0,0 +1,53 @@ +hero: + enabled: true + eyebrow: "Natural ritual care" + headline: "Fat Kiss" + subheadline: "Everybody Wants One." + body: "Rich, natural balm rituals for skin that lives, works, glows, and keeps going." + primary_cta_label: "Meet the Balms" + primary_cta_url: "/products/" + secondary_cta_label: "Contact Amber" + secondary_cta_url: "/contact/" + hero_image: "" + show_animated_logo: true + show_jingle_button: false + +brand_statement: + enabled: true + headline: "Natural luxury. Body-friendly ingredients. Renaissance Woman energy." + body: "Fat Kiss is skincare for women who do more than one thing — because Amber does more than one thing. Every balm is handcrafted with ingredients chosen for how they work together, not just how they sound on a label. No filler. No fluff. Just rich, nourishing ritual care that feels as good as it works." + +featured_products: + enabled: true + title: "The Balms" + product_slugs: + - face-balm + - body-balm + - lip-balm + +ethos: + enabled: true + title: "Renaissance Woman" + body: "Fat Kiss is built on the idea that modern femininity is expansive. You can be soft and strong. Natural and precise. Playful and serious. The brand reflects Amber — a woman who crafts, creates, runs things, and still has time to kiss the world back. These balms are part of that rhythm." + cta_label: "Read the full story" + cta_url: "/about/" + +visual_story: + enabled: false + images: [] + captions: [] + +reviews: + enabled: true + max_items: 3 + +journal_preview: + enabled: true + max_items: 2 + +waitlist: + enabled: false + title: "" + body: "" + cta_label: "" + cta_url: "" diff --git a/data/site/navigation.yaml b/data/site/navigation.yaml new file mode 100644 index 0000000..dbf0785 --- /dev/null +++ b/data/site/navigation.yaml @@ -0,0 +1,13 @@ +main: + - name: Products + url: /products/ + weight: 1 + - name: About + url: /about/ + weight: 2 + - name: Journal + url: /journal/ + weight: 3 + - name: Contact + url: /contact/ + weight: 4 diff --git a/data/site/settings.yaml b/data/site/settings.yaml new file mode 100644 index 0000000..8b855ad --- /dev/null +++ b/data/site/settings.yaml @@ -0,0 +1,19 @@ +brand_name: "Fat Kiss" +domain: "getfatkiss.com" +slogan: "Everybody Wants One." +default_seo_title: "Fat Kiss — Natural Ritual Skincare" +default_seo_description: "Rich, natural balm rituals for skin that lives, works, glows, and keeps going. Everybody Wants One." +logo_primary: "/uploads/logo-fat-kiss.svg" +logo_lips: "/uploads/logo-lips.svg" +favicon: "/favicon.svg" +theme_variant: "retro-modern-apothecary" +enable_jingle: false +jingle_audio_path: "" +enable_reviews: true +enable_journal: true +enable_waitlist: false +enable_products: true +enable_contact_form: true +noindex_admin: true +analytics_provider: "" +analytics_id: "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..64871cd --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + build: + image: hugomods/hugo:exts + volumes: + - .:/src + working_dir: /src + command: hugo --minify --gc --cleanDestinationDir + + preview: + image: hugomods/hugo:exts + volumes: + - .:/src + working_dir: /src + command: hugo server --bind 0.0.0.0 --port 1317 --baseURL http://localhost:1317 --disableFastRender + ports: + - "127.0.0.1:1317:1317" + restart: unless-stopped diff --git a/hugo.toml b/hugo.toml new file mode 100644 index 0000000..35e9ac4 --- /dev/null +++ b/hugo.toml @@ -0,0 +1,66 @@ +baseURL = "https://getfatkiss.com/" +languageCode = "en-us" +title = "Fat Kiss — Natural Ritual Skincare" + +[permalinks] + [permalinks.page] + products = "/products/:slug/" + journal = "/journal/:slug/" + +[params] + description = "Fat Kiss — rich, natural balm rituals for skin that lives, works, glows, and keeps going. Everybody Wants One." + author = "Fat Kiss" + email = "hello@getfatkiss.com" + slogan = "Everybody Wants One." + ogImage = "/uploads/og-fatkiss.jpg" + favicon = "/favicon.svg" + brandName = "Fat Kiss" + domain = "getfatkiss.com" + enableJingle = false + enableReviews = true + enableJournal = true + enableWaitlist = false + enableProducts = true + enableContact = true + +[[menus.main]] + name = "Products" + url = "/products/" + weight = 1 + +[[menus.main]] + name = "About" + url = "/about/" + weight = 2 + +[[menus.main]] + name = "Journal" + url = "/journal/" + weight = 3 + +[[menus.main]] + name = "Contact" + url = "/contact/" + weight = 4 + +[build] + [build.buildStats] + enable = true + +[minify] + [minify.tdewolff] + [minify.tdewolff.html] + keepWhitespace = false + [minify.tdewolff.css] + keepCSS2 = false + [minify.tdewolff.js] + keepVarNames = false + +[markup] + [markup.goldmark] + [markup.goldmark.renderer] + unsafe = true + +[security] + [security.funcs] + getenv = ["^HUGO_"] diff --git a/layouts/_default/baseof.html b/layouts/_default/baseof.html new file mode 100644 index 0000000..ead5554 --- /dev/null +++ b/layouts/_default/baseof.html @@ -0,0 +1,17 @@ + + + + {{ partial "head.html" . }} + {{ partial "seo.html" . }} + + + + {{ partial "header.html" . }} +
+ {{ block "main" . }}{{ end }} +
+ {{ partial "footer.html" . }} + {{ $js := resources.Get "js/main.js" | minify | fingerprint }} + + + diff --git a/layouts/_default/list.html b/layouts/_default/list.html new file mode 100644 index 0000000..422acd5 --- /dev/null +++ b/layouts/_default/list.html @@ -0,0 +1,12 @@ +{{ define "main" }} +
+
+

{{ .Title }}

+
+ {{ range .Pages }} + {{ partial "product-card.html" . }} + {{ end }} +
+
+
+{{ end }} diff --git a/layouts/_default/single.html b/layouts/_default/single.html new file mode 100644 index 0000000..ab7a138 --- /dev/null +++ b/layouts/_default/single.html @@ -0,0 +1,10 @@ +{{ define "main" }} +
+
+
+

{{ .Title }}

+
{{ .Content }}
+
+
+
+{{ end }} diff --git a/layouts/about/single.html b/layouts/about/single.html new file mode 100644 index 0000000..a1c6458 --- /dev/null +++ b/layouts/about/single.html @@ -0,0 +1,61 @@ +{{ define "main" }} +
+
+
+

{{ .Title }}

+
{{ .Content }}
+
+
+ + {{ with .Params.founder_story }} +
+
+

{{ .title }}

+
{{ .body | markdownify }}
+
+
+ {{ end }} + + {{ with .Params.product_philosophy }} +
+
+

{{ .title }}

+
{{ .body | markdownify }}
+
+
+ {{ end }} + + {{ with .Params.renaissance_woman }} +
+
+

{{ .title }}

+
{{ .body | markdownify }}
+
+
+ {{ end }} + + {{ with .Params.natural_ritual }} +
+
+

{{ .title }}

+
{{ .body | markdownify }}
+
+
+ {{ end }} + + {{ with .Params.future_vision }} +
+
+

{{ .title }}

+
{{ .body | markdownify }}
+
+
+ {{ end }} + + {{ with .Params.cta }} +
+ {{ .label }} +
+ {{ end }} +
+{{ end }} diff --git a/layouts/contact/single.html b/layouts/contact/single.html new file mode 100644 index 0000000..debec7d --- /dev/null +++ b/layouts/contact/single.html @@ -0,0 +1,18 @@ +{{ define "main" }} +
+
+
+ +

Get in Touch

+

+ Questions about the balms? Want to place an order? Just want to say hello? Amber reads every message. +

+
+
+
+
+ {{ partial "contact-form.html" . }} +
+
+
+{{ end }} diff --git a/layouts/index.html b/layouts/index.html new file mode 100644 index 0000000..f8f45a1 --- /dev/null +++ b/layouts/index.html @@ -0,0 +1,129 @@ +{{ define "main" }} +{{ $home := .Site.Data.site.home }} + +{{ if $home.hero.enabled }} +
+
+ {{ if $home.hero.eyebrow }} +

{{ $home.hero.eyebrow }}

+ {{ end }} +

{{ $home.hero.headline | default "Fat Kiss" }}

+ {{ if $home.hero.subheadline }} +

{{ $home.hero.subheadline }}

+ {{ end }} + {{ if $home.hero.body }} +

{{ $home.hero.body }}

+ {{ end }} +
+ {{ if $home.hero.primary_cta_label }} + {{ $home.hero.primary_cta_label }} + {{ end }} + {{ if $home.hero.secondary_cta_label }} + {{ $home.hero.secondary_cta_label }} + {{ end }} +
+
+
+{{ end }} + +{{ if $home.brand_statement.enabled }} +
+
+

{{ $home.brand_statement.headline }}

+

{{ $home.brand_statement.body }}

+
+
+{{ end }} + +{{ if $home.featured_products.enabled }} +
+
+ +

Meet the collection

+
+ {{ $slugs := $home.featured_products.product_slugs }} + {{ range where .Site.RegularPages "Section" "products" }} + {{ if in $slugs .File.ContentBaseName }} + {{ partial "product-card.html" . }} + {{ end }} + {{ end }} +
+
+
+{{ end }} + +{{ if $home.ethos.enabled }} +
+
+
{{ partial "svg-logo.html" . }}
+

{{ $home.ethos.title }}

+

{{ $home.ethos.body }}

+ {{ if $home.ethos.cta_label }} + {{ $home.ethos.cta_label }} + {{ end }} +
+
+{{ end }} + +{{ if and $home.reviews.enabled .Site.Data.reviews }} +
+
+ +

What people are saying

+
+ {{ $reviews := slice }} + {{ range .Site.Data.reviews }} + {{ $reviews = $reviews | append . }} + {{ end }} + {{ range first ($home.reviews.max_items | default 3) (where $reviews "enabled" true) }} +
+

"{{ .review_text }}"

+

{{ .reviewer_name }}

+ {{ with .reviewer_location }} +

{{ . }}

+ {{ end }} +
+ {{ end }} +
+
+
+{{ end }} + +{{ if and $home.journal_preview.enabled (where .Site.RegularPages "Section" "journal" | len | gt 0) }} +
+
+ +

Notes from the studio

+
+ {{ range first ($home.journal_preview.max_items | default 2) (where .Site.RegularPages "Section" "journal") }} + + {{ with .Params.hero_image }} +
{{ $.Title }}
+ {{ end }} +
+

{{ .Date.Format "January 2, 2006" }}

+

{{ .Title }}

+ {{ with .Params.summary }} +

{{ . }}

+ {{ end }} +
+
+ {{ end }} +
+
+
+{{ end }} + +{{ if $home.waitlist.enabled }} +
+
+
+

{{ $home.waitlist.title }}

+

{{ $home.waitlist.body }}

+ {{ $home.waitlist.cta_label }} +
+
+
+{{ end }} + +{{ end }} diff --git a/layouts/journal/list.html b/layouts/journal/list.html new file mode 100644 index 0000000..27c426f --- /dev/null +++ b/layouts/journal/list.html @@ -0,0 +1,30 @@ +{{ define "main" }} +
+
+
+ +

Notes from the Studio

+
+
+
+ +
+
+{{ end }} diff --git a/layouts/journal/single.html b/layouts/journal/single.html new file mode 100644 index 0000000..f99cc9f --- /dev/null +++ b/layouts/journal/single.html @@ -0,0 +1,25 @@ +{{ define "main" }} +
+
+
+ +

{{ .Title }}

+ {{ with .Params.summary }} +

{{ . }}

+ {{ end }} +
+
+ {{ with .Params.hero_image }} +
+
+ {{ $.Title }} +
+
+ {{ end }} +
+
+
{{ .Content }}
+
+
+
+{{ end }} diff --git a/layouts/partials/contact-form.html b/layouts/partials/contact-form.html new file mode 100644 index 0000000..a02f78f --- /dev/null +++ b/layouts/partials/contact-form.html @@ -0,0 +1,52 @@ +{{/* Contact form partial */}} +
+ + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ +
+ + +
diff --git a/layouts/partials/footer.html b/layouts/partials/footer.html new file mode 100644 index 0000000..baae9fa --- /dev/null +++ b/layouts/partials/footer.html @@ -0,0 +1,28 @@ + diff --git a/layouts/partials/head.html b/layouts/partials/head.html new file mode 100644 index 0000000..6ed827b --- /dev/null +++ b/layouts/partials/head.html @@ -0,0 +1,10 @@ + + +{{ if .Title }}{{ .Title }} — {{ .Site.Title }}{{ else }}{{ .Site.Title }}{{ end }} +{{ $css := resources.Get "scss/main.scss" | toCSS | minify | fingerprint }} + + + + + +{{ with .Site.Params.favicon }}{{ end }} diff --git a/layouts/partials/header.html b/layouts/partials/header.html new file mode 100644 index 0000000..59ac497 --- /dev/null +++ b/layouts/partials/header.html @@ -0,0 +1,17 @@ + diff --git a/layouts/partials/product-card.html b/layouts/partials/product-card.html new file mode 100644 index 0000000..42ec9db --- /dev/null +++ b/layouts/partials/product-card.html @@ -0,0 +1,28 @@ +{{/* Product card partial */}} + +
+ {{ with .Params.hero_image }} + {{ $.Title }} + {{ else }} +
{{ .Title }}
+ {{ end }} +
+
+
{{ .Params.product_type | default "balm" | humanize }}
+

{{ .Title }}

+

{{ .Params.short_summary | default .Summary }}

+ {{ with .Params.benefit_chips }} +
+ {{ range . }} + {{ . }} + {{ end }} +
+ {{ end }} + +
+
diff --git a/layouts/partials/seo.html b/layouts/partials/seo.html new file mode 100644 index 0000000..f0d406b --- /dev/null +++ b/layouts/partials/seo.html @@ -0,0 +1,12 @@ +{{/* SEO partial */}} + + + + + + + + + + +{{ if .Params.noindex }}{{ end }} diff --git a/layouts/partials/svg-logo.html b/layouts/partials/svg-logo.html new file mode 100644 index 0000000..8d2b701 --- /dev/null +++ b/layouts/partials/svg-logo.html @@ -0,0 +1,5 @@ + + + + + diff --git a/layouts/products/list.html b/layouts/products/list.html new file mode 100644 index 0000000..db346a2 --- /dev/null +++ b/layouts/products/list.html @@ -0,0 +1,20 @@ +{{ define "main" }} +
+
+
+ +

The Balms

+

Rich, natural balm rituals for skin that lives, works, glows, and keeps going.

+
+
+
+
+
+ {{ range .Pages }} + {{ partial "product-card.html" . }} + {{ end }} +
+
+
+
+{{ end }} diff --git a/layouts/products/single.html b/layouts/products/single.html new file mode 100644 index 0000000..e5a05b2 --- /dev/null +++ b/layouts/products/single.html @@ -0,0 +1,134 @@ +{{ define "main" }} +
+
+
+
+
+ {{ with .Params.hero_image }} + {{ $.Title }} + {{ else }} +
{{ .Title }}
+ {{ end }} +
+
+

{{ .Params.product_type | default "balm" | humanize }}

+

{{ .Title }}

+ {{ with .Params.one_line_identity }} +

{{ . }}

+ {{ end }} + {{ with .Params.short_summary }} +

{{ . }}

+ {{ end }} + {{ with .Params.benefit_chips }} +
+ {{ range . }}{{ . }}{{ end }} +
+ {{ end }} +
+ {{ if eq .Params.status "coming_soon" }} + Coming Soon + {{ else }} + + {{ .Params.cta_label | default "Inquire Now" }} + + {{ end }} + {{ if .Params.price_display }} + {{ .Params.price_display }} + {{ end }} +
+
+
+
+
+ + {{ with .Params.blend_benefits_rich_text }} +
+
+

The Blend

+
{{ . | markdownify }}
+
+
+ {{ end }} + + {{ if or .Params.smells_like .Params.feels_like .Params.good_for }} +
+
+

The Experience

+
+ {{ with .Params.smells_like }} +
+

Smells like

+

{{ . }}

+
+ {{ end }} + {{ with .Params.feels_like }} +
+

Feels like

+

{{ . }}

+
+ {{ end }} + {{ with .Params.good_for }} +
+

Good for

+

{{ . }}

+
+ {{ end }} +
+
+
+ {{ end }} + + {{ with .Params.directions }} +
+
+

How to Use

+
{{ . | markdownify }}
+
+
+ {{ end }} + + {{ with .Params.ritual_note }} +
+
+
+ {{ . }} +
+
+
+ {{ end }} + + {{ with .Params.ingredients_list }} +
+
+

Ingredients

+

{{ $.Params.ingredients_summary }}

+
+ {{ range . }} +
{{ . }}
+ {{ end }} +
+
+
+ {{ end }} + + {{ with .Params.faq_items }} +
+
+

Questions

+ {{ range . }} +
+ +

{{ .answer }}

+
+ {{ end }} +
+
+ {{ end }} + +
+ + {{ .Params.cta_label | default "Ask About This Balm" }} + +
+
+{{ end }} diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 0000000..fb61ca2 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +BUILD_DIR="public" +PREV_DIR="public.prev" + +echo "[build] Hugo build starting..." +docker compose run --rm build + +if [ ! -f "$BUILD_DIR/index.html" ]; then + echo "[build] ERROR: build failed — no index.html in $BUILD_DIR" + exit 1 +fi + +echo "[build] Build complete — $BUILD_DIR ready" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..fe4e8dc --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +REMOTE="benjoe@172.233.145.18" +DEST="/var/www/getfatkiss.com/public_html/" +SRC_DIR="/home/benjoe/getfatkiss" + +echo "[deploy] Building..." +bash scripts/build.sh + +echo "[deploy] Syncing to Hub..." +rsync -az --delete public/ "${REMOTE}:${SRC_DIR}/public/" + +echo "[deploy] Deploying to live webroot..." +ssh "${REMOTE}" "sudo rsync -az --delete ${SRC_DIR}/public/ ${DEST} && sudo chown -R www-data:www-data ${DEST}" + +echo "[deploy] Done → https://getfatkiss.com/" diff --git a/server/contact-handler/.env.example b/server/contact-handler/.env.example new file mode 100644 index 0000000..c5f8db1 --- /dev/null +++ b/server/contact-handler/.env.example @@ -0,0 +1,13 @@ +TURNSTILE_SECRET_KEY=your-turnstile-secret +SMTP_HOST=smtp.example.com +SMTP_PORT=587 +SMTP_USER=user@example.com +SMTP_PASS=your-smtp-password +MAIL_FROM=hello@getfatkiss.com +MAIL_TO_GENERAL=hello@getfatkiss.com +MAIL_TO_ORDERS=orders@getfatkiss.com +MAIL_TO_PRESS=press@getfatkiss.com +RATE_LIMIT_WINDOW=900000 +RATE_LIMIT_MAX=5 +PORT=3901 +ALLOWED_ORIGIN=https://getfatkiss.com diff --git a/server/contact-handler/README.md b/server/contact-handler/README.md new file mode 100644 index 0000000..ecc3376 --- /dev/null +++ b/server/contact-handler/README.md @@ -0,0 +1,22 @@ +# Fat Kiss Contact Handler + +Lightweight Node/Express contact form backend. + +## Setup +```bash +cp .env.example .env +# Fill in Turnstile secret, SMTP creds, mail routing +npm install +npm start +``` + +## Endpoints +- `POST /api/contact` — accepts form submissions with Turnstile verification + +## Security +- Rate limiting (5 req / 15 min default) +- Honeypot field +- Turnstile server-side verification +- Input sanitization +- Category allowlist +- CORS locked to getfatkiss.com diff --git a/server/contact-handler/package-lock.json b/server/contact-handler/package-lock.json new file mode 100644 index 0000000..153bc7e --- /dev/null +++ b/server/contact-handler/package-lock.json @@ -0,0 +1,797 @@ +{ + "name": "fatkiss-contact-handler", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "fatkiss-contact-handler", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2", + "express-rate-limit": "^7.1.4", + "nodemailer": "^6.9.7" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/body-parser/node_modules/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.14.0", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "7.5.1", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz", + "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/nodemailer": { + "version": "6.10.1", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz", + "integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ] + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "engines": { + "node": ">= 0.8" + } + } + } +} diff --git a/server/contact-handler/package.json b/server/contact-handler/package.json new file mode 100644 index 0000000..4b6f706 --- /dev/null +++ b/server/contact-handler/package.json @@ -0,0 +1,12 @@ +{ + "name": "fatkiss-contact-handler", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { "start": "node src/index.js" }, + "dependencies": { + "express": "^4.18.2", + "nodemailer": "^6.9.7", + "express-rate-limit": "^7.1.4" + } +} diff --git a/server/contact-handler/src/index.js b/server/contact-handler/src/index.js new file mode 100644 index 0000000..4d3eebd --- /dev/null +++ b/server/contact-handler/src/index.js @@ -0,0 +1,97 @@ +import express from 'express'; +import nodemailer from 'nodemailer'; +import rateLimit from 'express-rate-limit'; + +const app = express(); +app.use(express.urlencoded({ extended: true })); + +const ALLOWED_CATEGORIES = ['product_question','order_inquiry','wholesale','collaboration_press','general']; +const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || 'https://getfatkiss.com'; + +const limiter = rateLimit({ + windowMs: parseInt(process.env.RATE_LIMIT_WINDOW) || 900000, + max: parseInt(process.env.RATE_LIMIT_MAX) || 5, + message: { ok: false, error: 'Too many messages. Please try again later.' } +}); + +app.use((req, res, next) => { + res.header('Access-Control-Allow-Origin', ALLOWED_ORIGIN); + res.header('Access-Control-Allow-Methods', 'POST, OPTIONS'); + res.header('Access-Control-Allow-Headers', 'Content-Type'); + if (req.method === 'OPTIONS') return res.sendStatus(200); + next(); +}); + +function sanitize(str) { + return String(str).replace(/[<>]/g, '').trim().slice(0, 5000); +} + +async function verifyTurnstile(token) { + const r = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: token }) + }); + const d = await r.json(); + return d.success; +} + +const transporter = nodemailer.createTransport({ + host: process.env.SMTP_HOST, + port: parseInt(process.env.SMTP_PORT) || 587, + secure: false, + auth: { user: process.env.SMTP_USER, pass: process.env.SMTP_PASS } +}); + +const ROUTING = { + product_question: process.env.MAIL_TO_GENERAL, + order_inquiry: process.env.MAIL_TO_ORDERS, + wholesale: process.env.MAIL_TO_ORDERS, + collaboration_press: process.env.MAIL_TO_PRESS, + general: process.env.MAIL_TO_GENERAL +}; + +app.post('/api/contact', limiter, async (req, res) => { + try { + if (req.body.website && req.body.website.length > 0) { + return res.status(200).json({ ok: true }); + } + + const name = sanitize(req.body.name || ''); + const email = sanitize(req.body.email || ''); + const category = sanitize(req.body.category || 'general'); + const message = sanitize(req.body.message || ''); + const productInterest = sanitize(req.body.product_interest || ''); + + if (!name || !email || !message) { + return res.status(400).json({ ok: false, error: 'Please fill in all required fields.' }); + } + if (!email.includes('@') || !email.includes('.')) { + return res.status(400).json({ ok: false, error: 'Please enter a valid email address.' }); + } + if (!ALLOWED_CATEGORIES.includes(category)) { + return res.status(400).json({ ok: false, error: 'Invalid category.' }); + } + + const turnstileOk = await verifyTurnstile(req.body['cf-turnstile-response'] || ''); + if (!turnstileOk) { + return res.status(400).json({ ok: false, error: 'Security check failed. Please try again.' }); + } + + const to = ROUTING[category] || process.env.MAIL_TO_GENERAL; + await transporter.sendMail({ + from: process.env.MAIL_FROM, + to, + subject: `[Fat Kiss] ${category.replace(/_/g,' ')} from ${name}`, + text: `Name: ${name}\nEmail: ${email}\nCategory: ${category}\nProduct: ${productInterest || 'N/A'}\n\n${message}` + }); + + res.json({ ok: true }); + } catch (err) { + console.error('Contact error:', err.message); + res.status(500).json({ ok: false, error: 'Something did not go through. Please try again in a moment.' }); + } +}); + +const PORT = parseInt(process.env.PORT) || 3901; +app.listen(PORT, '127.0.0.1', () => console.log(`Fat Kiss contact handler on :${PORT}`)); diff --git a/static/admin/config.yml b/static/admin/config.yml new file mode 100644 index 0000000..f88c764 --- /dev/null +++ b/static/admin/config.yml @@ -0,0 +1,164 @@ +backend: + name: gitea + repo: amber/fatkiss + branch: main + base_url: https://git.kauaidigitalvillage.com + api_root: https://git.kauaidigitalvillage.com/api/v1 + auth_type: pkce + app_id: "80007cfc-55f8-4e90-81ed-c63432c4f9b8" + +media_folder: "static/uploads" +public_folder: "/uploads" + +site_url: https://getfatkiss.com +display_url: https://getfatkiss.com +logo_url: /uploads/logo-fat-kiss.svg + +collections: + - name: "pages" + label: "Pages" + files: + - name: "home" + label: "Home Page" + file: "data/site/home.yaml" + fields: + - {label: "Hero", name: "hero", widget: "object", fields: [ + {label: "Enabled", name: "enabled", widget: "boolean", default: true}, + {label: "Eyebrow", name: "eyebrow", widget: "string"}, + {label: "Headline", name: "headline", widget: "string"}, + {label: "Subheadline", name: "subheadline", widget: "string"}, + {label: "Body", name: "body", widget: "text"}, + {label: "Primary CTA Label", name: "primary_cta_label", widget: "string"}, + {label: "Primary CTA URL", name: "primary_cta_url", widget: "string"}, + {label: "Secondary CTA Label", name: "secondary_cta_label", widget: "string"}, + {label: "Secondary CTA URL", name: "secondary_cta_url", widget: "string"}]} + - {label: "Brand Statement", name: "brand_statement", widget: "object", fields: [ + {label: "Enabled", name: "enabled", widget: "boolean", default: true}, + {label: "Headline", name: "headline", widget: "string"}, + {label: "Body", name: "body", widget: "text"}]} + - {label: "Featured Products", name: "featured_products", widget: "object", fields: [ + {label: "Enabled", name: "enabled", widget: "boolean", default: true}, + {label: "Title", name: "title", widget: "string"}, + {label: "Product Slugs", name: "product_slugs", widget: "list", field: {label: "Slug", name: "slug", widget: "string"}}]} + - {label: "Ethos", name: "ethos", widget: "object", fields: [ + {label: "Enabled", name: "enabled", widget: "boolean", default: true}, + {label: "Title", name: "title", widget: "string"}, + {label: "Body", name: "body", widget: "text"}, + {label: "CTA Label", name: "cta_label", widget: "string"}, + {label: "CTA URL", name: "cta_url", widget: "string"}]} + - {label: "Reviews Section", name: "reviews", widget: "object", fields: [ + {label: "Enabled", name: "enabled", widget: "boolean", default: true}, + {label: "Max Items", name: "max_items", widget: "number", default: 3}]} + - {label: "Journal Preview", name: "journal_preview", widget: "object", fields: [ + {label: "Enabled", name: "enabled", widget: "boolean", default: true}, + {label: "Max Items", name: "max_items", widget: "number", default: 2}]} + + - name: "settings" + label: "Site Settings" + file: "data/site/settings.yaml" + fields: + - {label: "Brand Name", name: "brand_name", widget: "string"} + - {label: "Domain", name: "domain", widget: "string"} + - {label: "Slogan", name: "slogan", widget: "string"} + - {label: "Enable Reviews", name: "enable_reviews", widget: "boolean", default: true} + - {label: "Enable Journal", name: "enable_journal", widget: "boolean", default: true} + - {label: "Enable Products", name: "enable_products", widget: "boolean", default: true} + - {label: "Enable Contact Form", name: "enable_contact_form", widget: "boolean", default: true} + - {label: "Enable Jingle", name: "enable_jingle", widget: "boolean", default: false} + + - name: "contact" + label: "Contact Settings" + file: "data/site/contact.yaml" + fields: + - {label: "Display Email Publicly", name: "display_email_publicly", widget: "boolean", default: false} + - {label: "Display Phone Publicly", name: "display_phone_publicly", widget: "boolean", default: false} + - {label: "Phone Number", name: "phone_number", widget: "string", required: false} + - {label: "Instagram URL", name: "instagram_url", widget: "string", required: false} + - {label: "Facebook URL", name: "facebook_url", widget: "string", required: false} + - {label: "TikTok URL", name: "tiktok_url", widget: "string", required: false} + + - name: "products" + label: "Products" + folder: "content/products" + create: true + slug: "{{slug}}" + fields: + - {label: "Title", name: "title", widget: "string"} + - {label: "Product Type", name: "product_type", widget: "select", options: ["face_balm", "body_balm", "lip_balm", "other"]} + - {label: "Status", name: "status", widget: "select", options: ["draft", "coming_soon", "inquiry", "active", "archived"], default: "inquiry"} + - {label: "Featured", name: "featured", widget: "boolean", default: false} + - {label: "Sort Order", name: "sort_order", widget: "number", default: 10} + - {label: "Short Summary", name: "short_summary", widget: "text"} + - {label: "One-Line Identity", name: "one_line_identity", widget: "string"} + - {label: "Benefit Chips", name: "benefit_chips", widget: "list", field: {label: "Chip", name: "chip", widget: "string"}} + - {label: "Blend Benefits", name: "blend_benefits_rich_text", widget: "markdown"} + - {label: "Directions", name: "directions", widget: "text"} + - {label: "Ritual Note", name: "ritual_note", widget: "string", required: false} + - {label: "Smells Like", name: "smells_like", widget: "string", required: false} + - {label: "Feels Like", name: "feels_like", widget: "string", required: false} + - {label: "Good For", name: "good_for", widget: "string", required: false} + - {label: "Ingredients Summary", name: "ingredients_summary", widget: "string"} + - {label: "Ingredients List", name: "ingredients_list", widget: "list", field: {label: "Ingredient", name: "ingredient", widget: "string"}} + - {label: "Hero Image", name: "hero_image", widget: "image", required: false} + - {label: "CTA Label", name: "cta_label", widget: "string"} + - {label: "CTA Mode", name: "cta_mode", widget: "select", options: ["inquire", "coming_soon", "join_waitlist", "active_no_checkout", "hidden"], default: "inquire"} + - {label: "SEO Title", name: "seo_title", widget: "string"} + - {label: "SEO Description", name: "seo_description", widget: "text"} + - {label: "Body", name: "body", widget: "markdown"} + + - name: "about" + label: "About" + files: + - name: "about" + label: "About Page" + file: "content/about/index.md" + fields: + - {label: "Title", name: "title", widget: "string"} + - {label: "Body", name: "body", widget: "markdown"} + - {label: "Founder Story", name: "founder_story", widget: "object", fields: [ + {label: "Title", name: "title", widget: "string"}, + {label: "Body", name: "body", widget: "markdown"}]} + - {label: "Product Philosophy", name: "product_philosophy", widget: "object", fields: [ + {label: "Title", name: "title", widget: "string"}, + {label: "Body", name: "body", widget: "markdown"}]} + - {label: "Renaissance Woman", name: "renaissance_woman", widget: "object", fields: [ + {label: "Title", name: "title", widget: "string"}, + {label: "Body", name: "body", widget: "markdown"}]} + - {label: "Natural Ritual", name: "natural_ritual", widget: "object", fields: [ + {label: "Title", name: "title", widget: "string"}, + {label: "Body", name: "body", widget: "markdown"}]} + - {label: "Future Vision", name: "future_vision", widget: "object", fields: [ + {label: "Title", name: "title", widget: "string"}, + {label: "Body", name: "body", widget: "markdown"}]} + + - name: "journal" + label: "Journal" + folder: "content/journal" + create: true + slug: "{{slug}}" + fields: + - {label: "Title", name: "title", widget: "string"} + - {label: "Date", name: "date", widget: "datetime"} + - {label: "Draft", name: "draft", widget: "boolean", default: true} + - {label: "Summary", name: "summary", widget: "text"} + - {label: "Tags", name: "tags", widget: "list", field: {label: "Tag", name: "tag", widget: "string"}} + - {label: "Hero Image", name: "hero_image", widget: "image", required: false} + - {label: "SEO Title", name: "seo_title", widget: "string"} + - {label: "SEO Description", name: "seo_description", widget: "text"} + - {label: "Body", name: "body", widget: "markdown"} + + - name: "reviews" + label: "Reviews" + folder: "data/reviews" + create: true + extension: "yaml" + fields: + - {label: "ID", name: "id", widget: "string"} + - {label: "Enabled", name: "enabled", widget: "boolean", default: true} + - {label: "Reviewer Name", name: "reviewer_name", widget: "string"} + - {label: "Reviewer Location", name: "reviewer_location", widget: "string", required: false} + - {label: "Review Text", name: "review_text", widget: "text"} + - {label: "Product Slug", name: "product_slug", widget: "string", required: false} + - {label: "Rating", name: "rating", widget: "number", min: 1, max: 5, required: false} + - {label: "Date", name: "date", widget: "date", required: false} + - {label: "Featured", name: "featured", widget: "boolean", default: false} diff --git a/static/admin/index.html b/static/admin/index.html new file mode 100644 index 0000000..b37449e --- /dev/null +++ b/static/admin/index.html @@ -0,0 +1,15 @@ + + + + + + + Fat Kiss — Admin + + + + + +