diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e4531b4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,166 @@ +name: CI + +on: + pull_request: + branches: [main, preview] + +jobs: + test: + runs-on: ubuntu-latest + name: Lint + Tests + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Checkout legal content from being repo + uses: actions/checkout@v4 + with: + repository: mp2ez/being + ref: development + token: ${{ secrets.GH_PACKAGES_TOKEN }} + path: .tmp-being + sparse-checkout: | + docs/legal + sparse-checkout-cone-mode: false + + - name: Copy legal files to content directory + run: | + mkdir -p content/legal + cp -r .tmp-being/docs/legal/* content/legal/ + rm -rf .tmp-being + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Configure npm for GitHub Packages + run: | + echo "//npm.pkg.github.com/:_authToken=${{ secrets.GH_PACKAGES_TOKEN }}" >> .npmrc + + - name: Install dependencies + run: npm ci + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + - name: Test + run: npm test + + wrangler-smoke: + runs-on: ubuntu-latest + name: Wrangler runtime smoke + env: + NOTION_TOKEN: test-token-not-used + NOTION_WAITLIST_DB_ID: test-db-not-used + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Checkout legal content from being repo + uses: actions/checkout@v4 + with: + repository: mp2ez/being + ref: development + token: ${{ secrets.GH_PACKAGES_TOKEN }} + path: .tmp-being + sparse-checkout: | + docs/legal + sparse-checkout-cone-mode: false + + - name: Copy legal files to content directory + run: | + mkdir -p content/legal + cp -r .tmp-being/docs/legal/* content/legal/ + rm -rf .tmp-being + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + cache: 'npm' + + - name: Configure npm for GitHub Packages + run: | + echo "//npm.pkg.github.com/:_authToken=${{ secrets.GH_PACKAGES_TOKEN }}" >> .npmrc + + - name: Install dependencies + run: npm ci + + - name: Build worker with OpenNext + run: npx opennextjs-cloudflare build + + - name: Start wrangler dev in background + run: | + npx wrangler dev --port 8787 --local > wrangler.log 2>&1 & + echo $! > wrangler.pid + + - name: Wait for worker readiness + run: | + for i in {1..30}; do + if curl -sf -o /dev/null http://localhost:8787/; then + echo "Worker ready after ${i} attempt(s)" + exit 0 + fi + sleep 2 + done + echo "Worker did not start; logs:" + cat wrangler.log + exit 1 + + - name: Smoke test /api/waitlist + run: | + HTTP_CODE=$(curl -s -o /tmp/body -w "%{http_code}" \ + -X POST http://localhost:8787/api/waitlist \ + -H "Content-Type: application/json" \ + -d '{}') + echo "Response body:" + cat /tmp/body + echo + if [ "$HTTP_CODE" != "400" ]; then + echo "Expected HTTP 400, got $HTTP_CODE" + exit 1 + fi + if ! grep -q "Valid email required" /tmp/body; then + echo "Expected 'Valid email required' in response body" + exit 1 + fi + echo "Smoke test passed." + + - name: GET /crisis renders + security headers present + run: | + curl -s -i -o /tmp/crisis.txt http://localhost:8787/crisis + echo "--- /crisis response headers ---" + head -20 /tmp/crisis.txt + if ! head -1 /tmp/crisis.txt | grep -q " 200 "; then + echo "Expected HTTP 200 on /crisis" + exit 1 + fi + if ! grep -qi "988" /tmp/crisis.txt; then + echo "Expected '988' in /crisis body" + exit 1 + fi + if ! grep -qi "strict-transport-security" /tmp/crisis.txt; then + echo "Missing Strict-Transport-Security header" + exit 1 + fi + if ! grep -qi "content-security-policy" /tmp/crisis.txt; then + echo "Missing Content-Security-Policy header" + exit 1 + fi + if ! grep -qi "x-frame-options: DENY" /tmp/crisis.txt; then + echo "Missing X-Frame-Options: DENY header" + exit 1 + fi + echo "GET /crisis smoke + headers smoke passed." + + - name: Stop wrangler dev + if: always() + run: | + if [ -f wrangler.pid ]; then + kill "$(cat wrangler.pid)" 2>/dev/null || true + fi diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d262281..88679b3 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -49,7 +49,11 @@ jobs: - name: Build with OpenNext run: npx opennextjs-cloudflare build env: - NEXT_PUBLIC_SHOW_FULL_SITE: ${{ github.ref_name == 'preview' && 'true' || 'false' }} + # Always serve the full site (both preview and main). The splash at / + # is now unreachable in deployed envs — it stays in the codebase as + # an emergency-fallback option but the runtime redirect carries + # / → /home. /download serves as the pre-launch waitlist page. + NEXT_PUBLIC_SHOW_FULL_SITE: 'true' - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v3 diff --git a/app/accessibility/page.tsx b/app/(main)/accessibility/page.tsx similarity index 80% rename from app/accessibility/page.tsx rename to app/(main)/accessibility/page.tsx index ae23fee..63850d7 100644 --- a/app/accessibility/page.tsx +++ b/app/(main)/accessibility/page.tsx @@ -12,13 +12,13 @@ export default function AccessibilityPage() { Accessibility Statement

- Last Updated: [DATE - TO BE DETERMINED] + Last Updated: May 23, 2026

{/* Our Commitment */} -
+

Our Commitment to Accessibility

@@ -34,7 +34,7 @@ export default function AccessibilityPage() {
{/* Accessibility Features */} -
+

Accessibility Features

@@ -109,7 +109,7 @@ export default function AccessibilityPage() {
{/* Known Limitations */} -
+

Known Limitations

@@ -129,7 +129,7 @@ export default function AccessibilityPage() {
{/* Standards We Follow */} -
+

Standards We Follow

@@ -138,10 +138,6 @@ export default function AccessibilityPage() { WCAG 2.1 Level AA: Web Content Accessibility Guidelines -
  • - - Section 508: U.S. federal accessibility requirements -
  • iOS Accessibility: Apple Human Interface Guidelines @@ -153,40 +149,23 @@ export default function AccessibilityPage() {
  • - {/* Testing & Compliance */} -
    + {/* Reporting Accessibility Issues */} +

    - Testing & Compliance + Reporting Accessibility Issues

    -

    - We regularly test Being's accessibility through: +

    + We design to WCAG 2.1 Level AA standards. If you encounter an + accessibility barrier, please report it to{' '} + + accessibility@being.fyi + {' '} + and we will work to address it.

    -
      -
    • - - Automated accessibility testing with aXe and Lighthouse -
    • -
    • - - Manual testing with VoiceOver, TalkBack, and NVDA screen readers -
    • -
    • - - Keyboard-only navigation testing -
    • -
    • - - Color contrast validation -
    • -
    • - - User testing with people who rely on assistive technologies -
    • -
    {/* Feedback & Support */} -
    +

    Feedback & Support

    @@ -212,7 +191,7 @@ export default function AccessibilityPage() {
    {/* Third-Party Content */} -
    +

    Third-Party Content

    @@ -228,7 +207,7 @@ export default function AccessibilityPage() {
    {/* Continuous Improvement */} -
    +

    Our Commitment to Continuous Improvement

    diff --git a/app/(main)/cookies/page.tsx b/app/(main)/cookies/page.tsx index 93ac418..bea2864 100644 --- a/app/(main)/cookies/page.tsx +++ b/app/(main)/cookies/page.tsx @@ -1,100 +1,131 @@ /** * Cookie Policy Page - * Being's minimal cookie usage (we don't use cookies!) + * First-party functional cookies only; no third-party trackers. */ +import { GpcNotice } from '@/components/legal/GpcNotice'; + +export const metadata = { + title: 'Cookie Policy | Being', + description: 'How Being uses first-party functional cookies and honors Global Privacy Control signals.', +}; + export default function CookiesPage() { return (
    +

    Cookie Policy

    - Last Updated: [DATE - TO BE DETERMINED] + Last Updated: May 25, 2026

    - {/* The Good News */} -
    + {/* Summary */} +

    - Good News: We Don't Use Cookies + First-Party Functional Cookies Only

    - Being's website (www.being.fyi) does not use cookies, tracking - pixels, or similar technologies. We don't track you across the web, and we don't - use third-party advertising or analytics cookies. + Being’s website (www.being.fyi) uses two small first-party + cookies for site functionality. We don’t use third-party advertising or analytics + cookies, tracking pixels, or cross-site trackers.

    - This page exists to clarify what minimal data collection occurs and why. + We honor the Global Privacy Control (Sec-GPC) signal automatically — no + banner, no preference center.

    - {/* What Are Cookies? */} -
    + {/* The Two Cookies */} +

    - What Are Cookies? + Cookies We Use

    -

    - Cookies are small text files stored on your device by websites you visit. They're - commonly used to: -

    + +

    + being_ab_variant +

    +
      +
    • Purpose: Assigns you to an A or B variant so we can measure which version of the site converts better.
    • +
    • Type: First-party functional.
    • +
    • Value: Either A or B — no identifier, no behavioral data.
    • +
    • Lifetime: 30 days.
    • +
    • Shared with third parties: No. The variant label is stored alongside waitlist signups in our internal Notion database for conversion analysis; we don’t share it with advertising or analytics vendors.
    • +
    + +

    + being_gpc +

      -
    • - - Remember login information -
    • -
    • - - Track user behavior across websites -
    • -
    • - - Serve targeted advertising -
    • -
    • - - Analyze website traffic and usage -
    • +
    • Purpose: Caches the Global Privacy Control signal so the page can display an acknowledgement.
    • +
    • Type: First-party functional.
    • +
    • Value: 1 when your browser sends Sec-GPC: 1; otherwise the cookie is cleared.
    • +
    • Lifetime: 24 hours; automatically cleared on any subsequent request that doesn’t carry the header.
    • +
    • Shared with third parties: No.
    -

    - Being does none of these things on our website. -

    {/* What We Don't Use */} -
    +

    - What We Don't Use + What We Don’t Use

    • - No Advertising Cookies: We don't serve ads or use ad networks + No Advertising Cookies: We don’t serve ads or use ad networks.
    • - No Analytics Cookies: No Google Analytics, Facebook Pixel, or similar tracking + No Analytics Cookies: No Google Analytics, PostHog, Mixpanel, or similar.
    • - No Social Media Cookies: No social media widgets or share buttons with tracking + No Social Media Cookies: No social media widgets or share buttons with tracking.
    • - No Third-Party Cookies: No external services that set cookies + No Third-Party Cookies: No external services set cookies on our pages.
    • - No Session Cookies: Our website doesn't require login, so no session management + No Fingerprinting or Session Replay: No FullStory, LogRocket, Hotjar, or similar.
    + {/* Global Privacy Control */} +
    +

    + Global Privacy Control +

    +

    + If your browser sends the Sec-GPC: 1 request header (Brave, DuckDuckGo, + Firefox with an extension, etc.), we treat it as an opt-out of any sale or sharing of + personal information under CCPA, TDPSA, CPA, and CTDPA. +

    +

    + In practice, because we don’t use third-party analytics or advertising trackers, + there is no sale or sharing to opt out of — but the signal is recorded and acknowledged + with a visible notice on our privacy pages, and an X-GPC-Honored: 1 response + header confirms detection to anyone inspecting the request. +

    +

    + See the{' '} + + Multi-State Privacy Rights + {' '} + page for the underlying state law obligations. +

    +
    + {/* Mobile App */} -
    +

    Mobile App Data Storage

    @@ -126,7 +157,7 @@ export default function CookiesPage() {
    {/* Server Logs */} -
    +

    Server Logs (Minimal Data Collection)

    @@ -136,7 +167,7 @@ export default function CookiesPage() {
    • - IP Address: To prevent abuse and ensure security (automatically deleted after 30 days) + IP Address: To prevent abuse and ensure security
    • @@ -152,22 +183,24 @@ export default function CookiesPage() {

    - This data is not used for tracking, profiling, or advertising. It's retained for - security purposes and automatically deleted after 30 days. + This data is not used for tracking, profiling, or advertising. It’s retained + for a limited period for security purposes only.

    {/* Your Choices */} -
    +

    Your Choices

    - Since we don't use cookies, you don't need to accept a cookie banner or manage - cookie preferences. + You can clear either cookie at any time via your browser’s site-data settings. + Clearing being_ab_variant just reassigns a variant on your next visit; + clearing being_gpc is harmless because the cookie reflects the header, + not a stored preference.

    - If you have concerns about server logs or mobile app data storage, please see our{' '} + For questions about server logs, mobile app data storage, or anything else, see our{' '} Privacy Policy {' '} @@ -178,20 +211,20 @@ export default function CookiesPage() {

    - {/* Changes to This Policy */} -
    + {/* Changes */} +

    Changes to This Policy

    - If we ever decide to use cookies in the future (unlikely), we will update this policy - and notify you prominently on our website. We are committed to transparency and will - always respect your privacy choices. + If we add or change cookies in the future, we’ll update this page and bump the + Last Updated date above. We’re committed to transparency and to honoring your + privacy choices by default.

    {/* Contact */} -
    +

    Questions?

    diff --git a/app/crisis/page.tsx b/app/(main)/crisis/page.tsx similarity index 82% rename from app/crisis/page.tsx rename to app/(main)/crisis/page.tsx index fd7f5bb..8cfeec3 100644 --- a/app/crisis/page.tsx +++ b/app/(main)/crisis/page.tsx @@ -4,8 +4,6 @@ * Tone: Calm, supportive, informative (not alarming) */ -import BrainIcon from '@/components/shared/BrainIcon'; - export default function CrisisPage() { return (
    @@ -26,7 +24,7 @@ export default function CrisisPage() { {/* 988 Primary CTA - Prominent but Calm */}
    -
    +

    @@ -59,13 +57,13 @@ export default function CrisisPage() {
    📞 Call 988 💬 Text 988 @@ -97,7 +95,7 @@ export default function CrisisPage() {

    -
    +

    1. You'll speak with a trained counselor

    @@ -108,7 +106,7 @@ export default function CrisisPage() {

    -
    +

    2. Your call is confidential

    @@ -118,7 +116,7 @@ export default function CrisisPage() {

    -
    +

    3. You can call for yourself or someone else

    @@ -128,7 +126,7 @@ export default function CrisisPage() {

    -
    +

    4. No situation is too small

    @@ -150,7 +148,7 @@ export default function CrisisPage() {
    {/* Crisis Text Line */} -
    +

    Crisis Text Line

    @@ -160,14 +158,14 @@ export default function CrisisPage() {

    Text HOME to 741741
    {/* Veterans Crisis Line */} -
    +

    Veterans Crisis Line

    @@ -177,14 +175,14 @@ export default function CrisisPage() {

    Call 988, Press 1
    {/* The Trevor Project (LGBTQ+) */} -
    +

    The Trevor Project (LGBTQ+ Youth)

    @@ -194,14 +192,14 @@ export default function CrisisPage() {

    Call Trevor Project
    {/* NAMI HelpLine */} -
    +

    NAMI HelpLine

    @@ -211,7 +209,7 @@ export default function CrisisPage() {

    Call NAMI @@ -223,8 +221,8 @@ export default function CrisisPage() { {/* When to Call 911 */}
    -
    -

    +
    +

    When to call 911 (Emergency)

    @@ -232,23 +230,23 @@ export default function CrisisPage() {

    • - + You or someone else has taken action to harm themselves
    • - + There is an immediate medical emergency
    • - + Someone is threatening to harm themselves or others right now
    • - + There is an active life-threatening situation
    -
    +

    Note: 988 is for crisis support and suicide prevention. 911 is for immediate medical emergencies and life-threatening situations. @@ -279,20 +277,6 @@ export default function CrisisPage() {

    - {/* Being Disclaimer */} -
    -
    - -

    - About Being -

    -

    - Being is a wellness and mindfulness tool, not a substitute for professional medical - care or emergency services. If you're in crisis, please use the resources above. - Our app includes 988 access from every screen because your safety is paramount. -

    -
    -
    ); } diff --git a/app/(main)/download/page.tsx b/app/(main)/download/page.tsx index ba4ff90..2f62885 100644 --- a/app/(main)/download/page.tsx +++ b/app/(main)/download/page.tsx @@ -1,97 +1,47 @@ /** - * Download Page - App store links and pricing - * Professional SaaS Design - * NOTE: App store badges and QR codes need graphic assets - * NOTE: Pricing needs verification from product team + * Download Page — pre-launch state + * + * The app isn't in the stores yet. Every existing CTA across the site + * routes here, so this page serves as the waitlist signup until launch. + * When real app-store badges and downloads are available, the hero + + * waitlist form get replaced with badges + QR codes; the supporting + * content (pricing, system requirements, FAQ) stays as-is. */ import BrainIcon from '@/components/shared/BrainIcon'; -import Button from '@/components/shared/Button'; +import { WaitlistSignupForm } from '@/components/shared/WaitlistSignupForm'; + +export const metadata = { + title: 'Get Early Access | Being', + description: 'Join the waitlist to be the first to know when Being launches on iOS and Android.', +}; export default function DownloadPage() { return (
    - {/* Hero Section */} -
    -
    + {/* Hero + waitlist */} +
    +

    - Download Being + Coming soon to iOS and Android

    -

    - Start your Stoic Mindfulness practice today. 28-day free trial included. - No credit card required. +

    + Get early access + a free month trial when we launch. We’ll email you the + moment Being is in the App Store and Google Play.

    +
    - {/* App Store Badges */} + {/* What to expect when we launch — Pricing */}
    -
    -

    - Available on iOS and Android -

    - -
    - {/* iOS App Store Badge - PLACEHOLDER */} -
    -
    -
    🍎
    -

    iOS App Store Badge

    -

    (graphic needed)

    -
    -
    - - {/* Google Play Badge - PLACEHOLDER */} -
    -
    -
    🤖
    -

    Google Play Badge

    -

    (graphic needed)

    -
    -
    -
    - - {/* QR Codes - PLACEHOLDER */} -
    -

    - Scan to download on your phone -

    -
    -
    -
    -
    -
    📱
    -

    iOS QR Code

    -

    (graphic needed)

    -
    -
    -

    iPhone & iPad

    -
    - -
    -
    -
    -
    📱
    -

    Android QR Code

    -

    (graphic needed)

    -
    -
    -

    Android Devices

    -
    -
    -
    -
    -
    - - {/* Pricing */} -

    - Simple, Transparent Pricing + What to expect when we launch

    No hidden fees. No data selling. Just mindfulness practice with deeper meaning. @@ -100,11 +50,11 @@ export default function DownloadPage() {

    {/* Free Trial */} -
    +
    🎁

    Free Trial

    -
    28 days
    +
    1 month

    Full access, no credit card

      @@ -114,7 +64,7 @@ export default function DownloadPage() {
    • - Daily check-ins & assessments + Daily check-ins & assessments
    • @@ -128,7 +78,7 @@ export default function DownloadPage() {
    {/* Subscription Pricing */} -
    +
    📅

    Subscription

    @@ -170,7 +120,7 @@ export default function DownloadPage() {
    {/* System Requirements */} -
    +

    System Requirements @@ -181,7 +131,7 @@ export default function DownloadPage() {

    🍎 - iOS & iPadOS + iOS & iPadOS

    • @@ -241,19 +191,29 @@ export default function DownloadPage() {

    {/* FAQ */} -
    +

    - Download FAQ + Frequently asked questions

    +
    +

    + When will Being launch? +

    +

    + We’re finalizing app-store review now. Join the waitlist above and we’ll + email you as soon as Being is available to download. +

    +
    +

    Do I need to create an account?

    - No account required to start. Being works completely offline with local storage. + No account required to start. Being will work completely offline with local storage. You can optionally create an account later for cloud backup across devices.

    @@ -263,7 +223,7 @@ export default function DownloadPage() { What happens after my free trial ends?

    - Nothing automatically. We won't charge you unless you explicitly subscribe. You'll + Nothing automatically. We won’t charge you unless you explicitly subscribe. You’ll keep access to your data, but check-ins and assessments will be limited to view-only.

    @@ -297,33 +257,13 @@ export default function DownloadPage() { Can I cancel my subscription?

    - Yes, anytime. Cancel through your App Store or Google Play account settings. You'll + Yes, anytime. Cancel through your App Store or Google Play account settings. You’ll keep access until the end of your billing period, and you can always export your data.

    - - {/* Final CTA */} -
    -
    -

    - Ready to begin? -

    -

    - Join thousands practicing Stoic Mindfulness. Start your free trial today. -

    -
    - - -
    -
    -
    ); } diff --git a/app/(main)/features/page.tsx b/app/(main)/features/page.tsx index 78f424c..8b8c4b7 100644 --- a/app/(main)/features/page.tsx +++ b/app/(main)/features/page.tsx @@ -1,7 +1,7 @@ /** * Features Page - Being app capabilities * Professional SaaS Design - * Showcases daily check-ins, clinical tools, crisis support, privacy + * Showcases daily check-ins, wellness self-assessments, crisis support, privacy */ import BrainIcon from '@/components/shared/BrainIcon'; @@ -21,7 +21,7 @@ export default function FeaturesPage() {

    Being combines ancient Stoic wisdom with modern mental health science. Daily check-ins, - clinical assessments, and crisis support—all with HIPAA-level privacy. + wellness self-assessments, and crisis support, with privacy-first design and AES-256 encryption.

    @@ -41,7 +41,7 @@ export default function FeaturesPage() {
    {/* Morning */} -
    +
    🌅

    Morning

    @@ -69,7 +69,7 @@ export default function FeaturesPage() {

    {/* Midday */} -
    +
    ☀️

    Midday

    @@ -97,7 +97,7 @@ export default function FeaturesPage() {

    {/* Evening */} -
    +
    🌙

    Evening

    @@ -142,7 +142,7 @@ export default function FeaturesPage() {

    {/* PHQ-9 */} -
    +

    PHQ-9 (Depression)

    The Patient Health Questionnaire-9 is a widely-used self-assessment questionnaire @@ -169,7 +169,7 @@ export default function FeaturesPage() {

    {/* GAD-7 */} -
    +

    GAD-7 (Anxiety)

    The Generalized Anxiety Disorder-7 scale helps you monitor anxiety symptoms over time. @@ -210,7 +210,7 @@ export default function FeaturesPage() {

    -
    +

    988 Suicide & Crisis Lifeline

    One tap to connect with trained crisis counselors. Available 24/7/365. @@ -242,7 +242,7 @@ export default function FeaturesPage() { Privacy & Security

    - Your mental health data is yours. We protect it with HIPAA-level encryption + Your mental health data is yours. We protect it with AES-256 encryption and local-first storage.

    @@ -291,7 +291,7 @@ export default function FeaturesPage() {

    Experience Stoic Mindfulness with evidence-based self-monitoring tools. - Start your 28-day free trial today. + Start your 1 month free trial today.

    @@ -90,7 +90,7 @@ export default function Home() { className="text-accent-600 hover:text-accent-700 font-medium transition-colors duration-150 inline-flex items-center gap-1" > Learn about Stoic Mindfulness - +
    @@ -104,7 +104,7 @@ export default function Home() { Science

    - Clinical tools (PHQ-9, GAD-7) track your mental health. Immediate crisis support via 988. Mindfulness meets evidence-based care. + Wellness self-monitoring tools (PHQ-9, GAD-7) help you track your mental health. Immediate crisis support via 988. Mindfulness grounded in evidence-based practice.

    Explore app features - +
    @@ -126,7 +126,7 @@ export default function Home() { Privacy

    - HIPAA-level encryption. All data stored locally on your device. We never sell your information. Your mental health data belongs to you. + AES-256 encryption. All data stored locally on your device. We never sell your information. Your mental health data belongs to you.

    Read privacy policy - +
    @@ -153,7 +153,7 @@ export default function Home() {

    - Join thousands practicing Stoic Mindfulness. Start your 28-day free trial today. + Join thousands practicing Stoic Mindfulness. Start your 1 month free trial today.

    diff --git a/app/(main)/layout.tsx b/app/(main)/layout.tsx index 0cc6050..8c5027c 100644 --- a/app/(main)/layout.tsx +++ b/app/(main)/layout.tsx @@ -15,6 +15,14 @@ export default function MainLayout({ }) { return ( <> + {/* Skip to main content (WCAG 2.4.1 Bypass Blocks) */} + + Skip to main content + + {/* Desktop Navigation (hidden on mobile) */} @@ -22,7 +30,7 @@ export default function MainLayout({ {/* Main Content */} -
    +
    {children}
    diff --git a/app/(main)/philosophy/page.tsx b/app/(main)/philosophy/page.tsx index 3e5f4a3..d743e89 100644 --- a/app/(main)/philosophy/page.tsx +++ b/app/(main)/philosophy/page.tsx @@ -3,21 +3,17 @@ * Professional SaaS Design * Updated structure: Five Core Principles as primary framework * NOTE: Content validated by philosopher agent + * + * Server component. Only the "Deepen Your Practice" accordion section is + * a client subcomponent (PracticeAccordions) — everything else is static + * content rendered on the server. */ -'use client'; - -import { useState } from 'react'; import BrainIcon from '@/components/shared/BrainIcon'; import Button from '@/components/shared/Button'; +import PracticeAccordions from '@/components/philosophy/PracticeAccordions'; export default function PhilosophyPage() { - const [expandedSection, setExpandedSection] = useState(null); - - const toggleSection = (section: string) => { - setExpandedSection(expandedSection === section ? null : section); - }; - return (
    {/* Hero Section */} @@ -31,11 +27,11 @@ export default function PhilosophyPage() {

    - Stoic Mindfulness is not just Stoicism plus mindfulness—it's a unique integrated system + Stoic Mindfulness is not just Stoicism plus mindfulness—it’s a unique integrated system that combines ancient philosophical wisdom with modern contemplative practice and neuroscience.

    - It's the practice of aware wisdom: bringing full presence to each moment + It’s the practice of aware wisdom: bringing full presence to each moment while guided by principles that help you discern what truly matters and respond with virtue.

    @@ -100,7 +96,7 @@ export default function PhilosophyPage() {
    {/* The Synthesis */} -
    +

    The Synthesis

    Aware Wisdom = Metacognitive Awareness × Philosophical Discernment @@ -131,7 +127,7 @@ export default function PhilosophyPage() { {/* Principle 1: Aware Presence */}

    -
    🧘
    +

    1. Aware Presence

    @@ -139,7 +135,7 @@ export default function PhilosophyPage() { swept away by them.

    - "Am I present and aware right now?" + “Am I present and aware right now?”

    @@ -148,15 +144,15 @@ export default function PhilosophyPage() { {/* Principle 2: Radical Acceptance */}
    -
    🌊
    +

    2. Radical Acceptance

    - This is what's happening. Acceptance doesn't mean approval—it means acknowledging + This is what’s happening. Acceptance doesn’t mean approval—it means acknowledging reality as the foundation for wise action.

    - "Can I accept this moment as it is?" + “Can I accept this moment as it is?”

    @@ -165,7 +161,7 @@ export default function PhilosophyPage() { {/* Principle 3: Sphere Sovereignty */}
    -
    🎯
    +

    3. Sphere Sovereignty

    @@ -173,7 +169,7 @@ export default function PhilosophyPage() { attachment to outcomes. Govern only your domain.

    - "What's actually within my control here?" + “What’s actually within my control here?”

    @@ -182,7 +178,7 @@ export default function PhilosophyPage() { {/* Principle 4: Virtuous Response */}
    -
    ⚖️
    +

    4. Virtuous Response

    @@ -190,7 +186,7 @@ export default function PhilosophyPage() { or temperance requires. Choose the response that aligns with your values.

    - "What does virtue require of me now?" + “What does virtue require of me now?”

    @@ -199,7 +195,7 @@ export default function PhilosophyPage() { {/* Principle 5: Interconnected Living */}
    -
    🌍
    +

    5. Interconnected Living

    @@ -207,14 +203,14 @@ export default function PhilosophyPage() { shares a common nature and common good.

    - "How can I serve the common good?" + “How can I serve the common good?”

    -
    +

    These five principles work together. Aware Presence grounds you in the moment. Radical Acceptance meets reality honestly. Sphere Sovereignty focuses your energy. Virtuous Response guides your @@ -233,7 +229,7 @@ export default function PhilosophyPage() {

    {/* vs Traditional Mindfulness */} -
    +

    vs. Traditional Mindfulness

    @@ -252,7 +248,7 @@ export default function PhilosophyPage() {
    • ✓ Awareness + philosophy
    • ✓ Wise discernment
    • -
    • ✓ Meaning & flourishing
    • +
    • ✓ Meaning & flourishing
    • ✓ Integrated with ethics
    @@ -263,7 +259,7 @@ export default function PhilosophyPage() {
    {/* vs Classical Stoicism */} -
    +

    vs. Classical Stoicism

    @@ -274,7 +270,7 @@ export default function PhilosophyPage() {
  • • Philosophical study
  • • Intellectual reasoning
  • • Retrospective analysis
  • -
  • • Reading & reflection
  • +
  • • Reading & reflection
  • @@ -295,242 +291,8 @@ export default function PhilosophyPage() {
    - {/* Deepen Your Practice */} -
    -
    -

    - Deepen Your Practice -

    - - {/* The Four Cardinal Virtues */} -
    - - - {expandedSection === 'virtues' && ( -
    -
    - {/* Wisdom */} -
    -
    🦉
    -

    Wisdom

    -

    - Discerning what is truly good, bad, or indifferent. Understanding what is - within your control and what is not. -

    -
    - "You have power over your mind—not outside events." -
    — Marcus Aurelius, Meditations 6.8
    -
    -
    - - {/* Courage */} -
    -
    🦁
    -

    Courage

    -

    - Not the absence of fear, but action in spite of it. Facing difficulties - with resilience and maintaining your principles. -

    -
    - "It is because we do not dare that things are difficult." -
    — Seneca, Letters to Lucilius 104.26
    -
    -
    - - {/* Justice */} -
    -
    ⚖️
    -

    Justice

    -

    - Treating others with fairness, kindness, and respect. Recognizing our - responsibilities to the larger community. -

    -
    - "What brings no benefit to the hive brings none to the bee." -
    — Marcus Aurelius, Meditations 6.54
    -
    -
    - - {/* Temperance */} -
    -
    🌊
    -

    Temperance

    -

    - Self-control and moderation. Finding balance, avoiding excess, and - cultivating discipline in thought and action. -

    -
    - "It is in our power not to want what we don't have." -
    — Seneca, Letters to Lucilius 123.3 (adapted)
    -
    -
    -
    -
    - )} -
    - - {/* Entry Level */} -
    - - - {expandedSection === 'entry' && ( -
    -
    -

    Morning & Evening Check-ins

    -

    - Morning: Set your intention. What principle will you practice today?
    - Evening: Review your day. Where did awareness and virtue meet? Where can you grow? -

    -
    - -
    -

    Aware Presence Practice

    -

    - Notice your breath. Observe sensations in your body. Watch thoughts arise and pass - without getting swept away. This is the foundation of all practice. -

    -
    - -
    -

    Sphere Sovereignty Sorting

    -

    - When worry arises, ask: "Is this within my control?" If yes, plan action. If no, - practice acceptance. This is the cornerstone of Stoic practice. -

    -
    -
    - )} -
    - - {/* Intermediate Level */} -
    - - - {expandedSection === 'intermediate' && ( -
    -
    -

    Premeditatio Malorum (Negative Visualization)

    -

    - Rational contemplation, not catastrophizing: Briefly imagine losing - what you value to cultivate gratitude and prepare for uncertainty. Time-limited (2-3 minutes), - followed by appreciation for the present. This builds resilience without anxiety. -

    -
    - -
    -

    The View from Above

    -

    - Zoom out: see yourself, your city, your country, the Earth from space. This cosmic - perspective helps you see daily worries in proper proportion. -

    -
    - -
    -

    Voluntary Discomfort

    -

    - Occasionally practice small discomforts: a cold shower, fasting, saying no to pleasures. - This builds resilience and reminds you that comfort is not required for well-being. -

    -
    -
    - )} -
    - - {/* Advanced Level */} -
    - - - {expandedSection === 'advanced' && ( -
    -
    -

    Amor Fati (Love of Fate)

    -

    - Beyond acceptance: loving what happens. Not passive resignation, but active embrace - of reality as the raw material for growth. Every obstacle becomes fuel for virtue. -

    -
    - -
    -

    Memento Mori (Remember Death)

    -

    - Your time is limited—this isn't morbid, it's liberating. When you remember mortality, - trivial concerns fade and what truly matters becomes clear. Every moment is precious. -

    -
    - -
    -

    Sympatheia (Interconnection)

    -

    - Deep recognition that you are part of a larger whole. Your actions ripple outward. - Justice isn't optional—it's recognition that humanity shares a common nature and - common good. Bring presence to this truth. -

    -
    -
    - )} -
    -
    -
    + {/* Deepen Your Practice — interactive accordions (client subcomponent) */} + {/* Download CTA */}
    diff --git a/app/privacy/california/page.tsx b/app/(main)/privacy/california/page.tsx similarity index 75% rename from app/privacy/california/page.tsx rename to app/(main)/privacy/california/page.tsx index f67e8aa..556a9b2 100644 --- a/app/privacy/california/page.tsx +++ b/app/(main)/privacy/california/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/california-privacy.md'; export const dynamic = 'force-static'; @@ -9,5 +10,5 @@ export const metadata = { }; export default function CaliforniaPrivacyPage() { - return ; + return } />; } diff --git a/app/(main)/privacy/multi-state/page.tsx b/app/(main)/privacy/multi-state/page.tsx new file mode 100644 index 0000000..008b19b --- /dev/null +++ b/app/(main)/privacy/multi-state/page.tsx @@ -0,0 +1,15 @@ +import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; +import content from '@/content/legal/multi-state-privacy.md'; + +export const dynamic = 'force-static'; + +export const metadata = { + title: 'Multi-State Privacy Rights | Being', + description: + 'Your privacy rights under Texas, Colorado, Connecticut, and Virginia state privacy laws, with a California summary.', +}; + +export default function MultiStatePrivacyPage() { + return } />; +} diff --git a/app/(main)/privacy/page.tsx b/app/(main)/privacy/page.tsx index 61bde1b..2145975 100644 --- a/app/(main)/privacy/page.tsx +++ b/app/(main)/privacy/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/privacy-policy.md'; export const dynamic = 'force-static'; @@ -9,5 +10,5 @@ export const metadata = { }; export default function PrivacyPolicyPage() { - return ; + return } />; } diff --git a/app/support/page.tsx b/app/(main)/support/page.tsx similarity index 72% rename from app/support/page.tsx rename to app/(main)/support/page.tsx index ccfdeba..57a6ae1 100644 --- a/app/support/page.tsx +++ b/app/(main)/support/page.tsx @@ -1,4 +1,5 @@ import { LegalPage } from '@/components/legal/LegalPage'; +import { GpcNotice } from '@/components/legal/GpcNotice'; import content from '@/content/legal/support.md'; export const dynamic = 'force-static'; @@ -9,5 +10,5 @@ export const metadata = { }; export default function SupportPage() { - return ; + return } />; } diff --git a/app/(standalone)/layout.tsx b/app/(standalone)/layout.tsx index 000de4c..6d65e36 100644 --- a/app/(standalone)/layout.tsx +++ b/app/(standalone)/layout.tsx @@ -1,8 +1,15 @@ /** * Standalone Layout - No navigation * For pages like coming-soon that should not have header/footer + * + * Redirect gating lives here (server component) so the navigation happens + * before the client splash bundle ships. NEXT_PUBLIC_SHOW_FULL_SITE is + * still inlined at build time; switching to a runtime env var is a + * follow-up if needed. */ +import { redirect } from 'next/navigation'; + export const metadata = { title: 'Being - Launching Soon', description: 'Ancient Stoic wisdom meets modern mental health practice. Join the waitlist.', @@ -13,5 +20,8 @@ export default function StandaloneLayout({ }: { children: React.ReactNode; }) { + if (process.env.NEXT_PUBLIC_SHOW_FULL_SITE === 'true') { + redirect('/home'); + } return <>{children}; } diff --git a/app/(standalone)/page.tsx b/app/(standalone)/page.tsx index 8a42825..921fae2 100644 --- a/app/(standalone)/page.tsx +++ b/app/(standalone)/page.tsx @@ -1,49 +1,13 @@ /** * Coming Soon Page - Being * Single-focus email capture interstitial - * Launch until app store availability + * Shown at / when NEXT_PUBLIC_SHOW_FULL_SITE !== 'true' (see layout) */ -'use client'; - -import { useState, FormEvent } from 'react'; -import { redirect } from 'next/navigation'; import BrainIcon from '@/components/shared/BrainIcon'; - -// Redirect to full site when NEXT_PUBLIC_SHOW_FULL_SITE is enabled -if (process.env.NEXT_PUBLIC_SHOW_FULL_SITE === 'true') { - redirect('/home'); -} +import { WaitlistSignupForm } from '@/components/shared/WaitlistSignupForm'; export default function ComingSoon() { - const [email, setEmail] = useState(''); - const [status, setStatus] = useState<'idle' | 'submitting' | 'success' | 'error'>('idle'); - const [errorMessage, setErrorMessage] = useState(''); - - const handleSubmit = async (e: FormEvent) => { - e.preventDefault(); - setStatus('submitting'); - setErrorMessage(''); - - try { - const response = await fetch('/api/waitlist', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email }), - }); - - if (!response.ok) { - throw new Error('Failed to join waitlist'); - } - - setStatus('success'); - setEmail(''); - } catch (error) { - setStatus('error'); - setErrorMessage('Something went wrong. Please try again.'); - } - }; - return (
    @@ -64,86 +28,18 @@ export default function ComingSoon() {
    -

    +

    Ancient Stoic wisdom meets modern mental health practice.

    -

    +

    Be the first to know when we launch.

    {/* Email Capture Form */} - {status === 'success' ? ( -
    -

    - You're on the list -

    -

    - We'll notify you as soon as Being launches on the App Store and Google Play. -

    -
    - ) : ( -
    -
    - setEmail(e.target.value)} - placeholder="Enter your email" - required - disabled={status === 'submitting'} - className=" - flex-1 px-4 py-3 - rounded-lg border-2 border-gray-300 - text-base text-gray-900 placeholder:text-gray-400 - focus:outline-none focus:ring-2 focus:ring-accent-500 focus:border-accent-500 - disabled:opacity-50 disabled:cursor-not-allowed - transition-all duration-150 - min-h-[48px] - " - aria-label="Email address" - /> - -
    - - {status === 'error' && ( -

    - {errorMessage} -

    - )} - -

    - We'll send you one email when Being launches. - No spam, ever. Unsubscribe anytime. -

    -
    - )} + {/* Trust Signals */}
    @@ -152,7 +48,7 @@ export default function ComingSoon() { - 28-day free trial + 1 month free trial diff --git a/app/api/waitlist/route.test.ts b/app/api/waitlist/route.test.ts new file mode 100644 index 0000000..635fcb5 --- /dev/null +++ b/app/api/waitlist/route.test.ts @@ -0,0 +1,137 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { NextRequest } from 'next/server'; + +vi.mock('@/lib/ab-testing', () => ({ + getVariant: vi.fn(), + trackConversion: vi.fn(), +})); + +function buildRequest(body: unknown): NextRequest { + return new NextRequest('http://localhost/api/waitlist', { + method: 'POST', + body: typeof body === 'string' ? body : JSON.stringify(body), + }); +} + +type RouteModule = typeof import('./route'); +type AbModule = typeof import('@/lib/ab-testing'); + +async function importRouteFresh(): Promise<{ route: RouteModule; ab: AbModule }> { + vi.resetModules(); + const ab = (await import('@/lib/ab-testing')) as AbModule; + const route = (await import('./route')) as RouteModule; + return { route, ab }; +} + +describe('POST /api/waitlist', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('NOTION_TOKEN', 'test-token'); + vi.stubEnv('NOTION_WAITLIST_DB_ID', 'test-db'); + vi.spyOn(console, 'error').mockImplementation(() => {}); + vi.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('returns 400 when email is missing', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + + const res = await route.POST(buildRequest({})); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Valid email required' }); + }); + + it('returns 400 when email lacks @', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + + const res = await route.POST(buildRequest({ email: 'invalid' })); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Valid email required' }); + }); + + it('returns 500 when NOTION_TOKEN is missing', async () => { + vi.stubEnv('NOTION_TOKEN', ''); + const { route } = await importRouteFresh(); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Server configuration error' }); + }); + + it('returns 500 when NOTION_WAITLIST_DB_ID is missing', async () => { + vi.stubEnv('NOTION_WAITLIST_DB_ID', ''); + const { route } = await importRouteFresh(); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Server configuration error' }); + }); + + it('posts to Notion without Variant when no A/B variant assigned', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true }); + expect(fetchSpy).toHaveBeenCalledOnce(); + + const [url, init] = fetchSpy.mock.calls[0]!; + expect(url).toBe('https://api.notion.com/v1/pages'); + const sent = JSON.parse((init as RequestInit).body as string); + expect(sent.properties.Email.title[0].text.content).toBe('a@b.com'); + expect(sent.properties.Variant).toBeUndefined(); + expect(sent.properties['Signed Up'].date.start).toMatch( + /^\d{4}-\d{2}-\d{2}T/, + ); + expect(ab.trackConversion).not.toHaveBeenCalled(); + }); + + it('posts to Notion with Variant and tracks conversion when variant assigned', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue('A'); + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockResolvedValue(new Response(JSON.stringify({}), { status: 200 })); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + + expect(res.status).toBe(200); + const sent = JSON.parse( + (fetchSpy.mock.calls[0]![1] as RequestInit).body as string, + ); + expect(sent.properties.Variant).toEqual({ select: { name: 'A' } }); + expect(ab.trackConversion).toHaveBeenCalledWith('A', 'waitlist_signup'); + }); + + it('returns 500 when Notion API responds non-ok', async () => { + const { route, ab } = await importRouteFresh(); + (ab.getVariant as ReturnType).mockResolvedValue(null); + vi.spyOn(global, 'fetch').mockResolvedValue( + new Response(JSON.stringify({ error: 'invalid' }), { status: 500 }), + ); + + const res = await route.POST(buildRequest({ email: 'a@b.com' })); + + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: 'Failed to join waitlist' }); + expect(ab.trackConversion).not.toHaveBeenCalled(); + }); + + it("returns 400 'Invalid JSON' when request body is malformed", async () => { + const { route } = await importRouteFresh(); + const res = await route.POST(buildRequest('not-json')); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: 'Invalid JSON' }); + }); +}); diff --git a/app/api/waitlist/route.ts b/app/api/waitlist/route.ts index 6a8eddb..9993e12 100644 --- a/app/api/waitlist/route.ts +++ b/app/api/waitlist/route.ts @@ -7,22 +7,35 @@ */ import { NextRequest, NextResponse } from 'next/server'; -import { getVariant, trackConversion, type Variant } from '@/lib/ab-testing'; +import { z } from 'zod'; +import { getVariant, trackConversion } from '@/lib/ab-testing'; const NOTION_TOKEN = process.env.NOTION_TOKEN; const NOTION_DATABASE_ID = process.env.NOTION_WAITLIST_DB_ID; +const BodySchema = z.object({ + email: z.string().email(), +}); + export async function POST(request: NextRequest) { + // Parse body separately so malformed JSON is a 400 (client error), + // not a 500 conflated with upstream/Notion failures. + let body: unknown; try { - const { email } = await request.json(); + body = await request.json(); + } catch { + return NextResponse.json({ error: 'Invalid JSON' }, { status: 400 }); + } - // Validate email - if (!email || !email.includes('@')) { + try { + const parsed = BodySchema.safeParse(body); + if (!parsed.success) { return NextResponse.json( { error: 'Valid email required' }, { status: 400 } ); } + const { email } = parsed.data; // Validate environment variables if (!NOTION_TOKEN || !NOTION_DATABASE_ID) { diff --git a/app/disclaimers/page.tsx b/app/disclaimers/page.tsx deleted file mode 100644 index b4b146b..0000000 --- a/app/disclaimers/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LegalPage } from '@/components/legal/LegalPage'; -import content from '@/content/legal/medical-disclaimer.md'; - -export const dynamic = 'force-static'; - -export const metadata = { - title: 'Medical Disclaimer | Being', - description: 'Important medical and wellness disclaimers for the Being app.', -}; - -export default function DisclaimersPage() { - return ; -} diff --git a/app/do-not-sell/page.tsx b/app/do-not-sell/page.tsx deleted file mode 100644 index c9287d9..0000000 --- a/app/do-not-sell/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LegalPage } from '@/components/legal/LegalPage'; -import content from '@/content/legal/do-not-sell.md'; - -export const dynamic = 'force-static'; - -export const metadata = { - title: 'Do Not Sell My Personal Information | Being', - description: 'Exercise your right to opt out of the sale or sharing of your personal information.', -}; - -export default function DoNotSellPage() { - return ; -} diff --git a/app/globals.css b/app/globals.css index fe377d2..ce3fb6a 100644 --- a/app/globals.css +++ b/app/globals.css @@ -14,7 +14,26 @@ @theme inline { /* === ALIASES === */ - /* Map Tailwind conventions to design system naming */ + /* Map Tailwind conventions to design system naming. + * + * Tailwind v4 only generates utility classes for tokens registered in this + * @theme block. Tokens that only live in :root (from the design system) are + * usable via raw var(...) but won't produce bg-* / text-* / border-* utilities. + * Every design-system token used as a utility class must be re-declared here. + */ + + /* Brand colors — design system uses camelCase internals, Tailwind needs kebab */ + --color-brand-midnight: var(--color-brand-midnight); + --color-brand-sage: var(--color-brand-sage); + --color-brand-blue-gray: var(--color-brand-blueGray); + --color-brand-off-white: var(--color-brand-offWhite); + + /* Accent scale (used directly + via sage-* aliases below) */ + --color-accent-50: var(--color-accent-50); + --color-accent-100: var(--color-accent-100); + --color-accent-500: var(--color-accent-500); + --color-accent-600: var(--color-accent-600); + --color-accent-700: var(--color-accent-700); /* Sage → Accent (backward compat for existing code) */ --color-sage-50: var(--color-accent-50); @@ -28,9 +47,19 @@ --color-warning: var(--color-status-warning); --color-error: var(--color-status-error); --color-info: var(--color-status-info); + --color-critical: var(--color-status-critical); - /* Crisis shorthand */ + /* Status backgrounds */ + --color-success-bg: var(--color-status-successBackground); + --color-warning-bg: var(--color-status-warningBackground); + --color-error-bg: var(--color-status-errorBackground); + --color-info-bg: var(--color-status-infoBackground); + --color-critical-bg: var(--color-status-criticalBackground); + + /* Crisis colors (shorthand for design system tokens) */ --color-crisis-bg: var(--color-crisis-background); + --color-crisis-text: var(--color-crisis-text); + --color-crisis-border: var(--color-crisis-border); /* Semantic */ --color-background: var(--background); @@ -98,42 +127,48 @@ body { animation: fadeInUp 0.3s ease-out; } -/* === LEGAL CONTENT === */ +/* === LEGAL CONTENT === + * Typography uses DS 1.8 tokens (--text-* / --spacing-* / --radius-*). + * Two intentional overrides on top of the DS scale: + * - h2 weight stays 700 (DS headline4 is 600) for stronger section breaks + * - p line-height stays 1.75 (DS bodyRegular is 1.5) for airier legal reading + */ .legal-content h1 { - font-size: 2.25rem; - font-weight: 700; + font-size: var(--text-headline1-size); + font-weight: var(--text-headline1-weight); + letter-spacing: var(--text-headline1-tracking); color: var(--color-brand-midnight); - margin-bottom: 1rem; + margin-bottom: var(--spacing-16); text-align: center; } .legal-content h2 { - font-size: 1.5rem; - font-weight: 700; + font-size: var(--text-headline4-size); + font-weight: 700; /* DS headline4 is 600; legal pages want stronger separation */ color: var(--color-brand-midnight); - margin-top: 3rem; - margin-bottom: 1rem; + margin-top: var(--spacing-48); + margin-bottom: var(--spacing-16); } .legal-content h3 { - font-size: 1.25rem; - font-weight: 600; + font-size: var(--text-title-size); + font-weight: var(--text-title-weight); color: var(--color-brand-midnight); - margin-top: 2rem; - margin-bottom: 0.75rem; + margin-top: var(--spacing-32); + margin-bottom: var(--spacing-12); } .legal-content p { color: var(--color-gray-700); - line-height: 1.75; - margin-bottom: 1rem; + line-height: 1.75; /* DS bodyRegular is 1.5; legal pages want extra airiness */ + margin-bottom: var(--spacing-16); } .legal-content ul, .legal-content ol { color: var(--color-gray-700); - margin-bottom: 1rem; - margin-left: 1.5rem; + margin-bottom: var(--spacing-16); + margin-left: var(--spacing-24); } .legal-content ul { @@ -145,7 +180,7 @@ body { } .legal-content li { - margin-bottom: 0.5rem; + margin-bottom: var(--spacing-8); } .legal-content a { @@ -165,37 +200,37 @@ body { .legal-content blockquote { border-left: 4px solid var(--color-brand-sage); background-color: white; - padding: 1rem; - margin: 1.5rem 0; - border-radius: 0 0.5rem 0.5rem 0; + padding: var(--spacing-16); + margin: var(--spacing-24) 0; + border-radius: 0 var(--radius-medium) var(--radius-medium) 0; } .legal-content hr { - margin: 2rem 0; + margin: var(--spacing-32) 0; border-color: var(--color-gray-300); } .legal-content table { width: 100%; border-collapse: collapse; - margin-bottom: 1.5rem; + margin-bottom: var(--spacing-24); border: 1px solid var(--color-gray-300); - border-radius: 0.5rem; + border-radius: var(--radius-medium); overflow: hidden; } .legal-content th { background-color: var(--color-gray-50); - padding: 0.75rem 1rem; + padding: var(--spacing-12) var(--spacing-16); text-align: left; - font-size: 0.875rem; + font-size: var(--text-bodySmall-size); font-weight: 600; color: var(--color-brand-midnight); } .legal-content td { - padding: 0.75rem 1rem; - font-size: 0.875rem; + padding: var(--spacing-12) var(--spacing-16); + font-size: var(--text-bodySmall-size); color: var(--color-gray-700); border-top: 1px solid var(--color-gray-200); } diff --git a/app/hipaa/page.tsx b/app/hipaa/page.tsx deleted file mode 100644 index 927de70..0000000 --- a/app/hipaa/page.tsx +++ /dev/null @@ -1,303 +0,0 @@ -/** - * HIPAA Notice Page - * NOTE: REQUIRES ATTORNEY REVIEW BEFORE LAUNCH - * Explains HIPAA-level privacy practices (Being is not a covered entity) - */ - -export default function HIPAAPage() { - return ( -
    -
    -
    -

    - HIPAA Notice -

    -

    - ⚠️ Draft document - Attorney review required -

    -
    - -
    - {/* Important Distinction */} -
    -

    - Important: Being is NOT a HIPAA Covered Entity -

    -

    - Being is a wellness application, not a healthcare provider. We are not a - healthcare provider, health plan, or healthcare clearinghouse. Therefore, Being is - not legally subject to HIPAA regulations. -

    -

    - HIPAA applies to "covered entities" (healthcare providers, health plans, healthcare clearinghouses) - and their "business associates." Being does not fall into either category because we provide - wellness tools and self-monitoring resources, not medical services. -

    -

    - We voluntarily choose to adopt HIPAA-level security and privacy standards because - we deeply care about protecting your mental health data. This is our commitment to you, not a - legal obligation. We believe wellness data deserves the same protection as medical data. -

    -
    - - {/* Our Commitment */} -
    -

    - Our Voluntary Commitment to HIPAA-Level Privacy -

    -

    - We choose to adopt HIPAA-equivalent practices because we care about your privacy. -

    -

    - Even though HIPAA does not legally apply to Being (we're a wellness app, not a healthcare - provider), we voluntarily implement the following HIPAA-inspired safeguards to protect your - mental health information: -

    - -

    - Administrative Safeguards -

    -
      -
    • - - Privacy and security policies reviewed regularly -
    • -
    • - - Employee training on data privacy and security -
    • -
    • - - Limited access to user data (least privilege principle) -
    • -
    • - - Incident response plan for data breaches -
    • -
    - -

    - Physical Safeguards -

    -
      -
    • - - Secure cloud infrastructure with restricted physical access -
    • -
    • - - Data center security controls and monitoring -
    • -
    • - - Secure disposal of electronic media -
    • -
    - -

    - Technical Safeguards -

    -
      -
    • - - Encryption: AES-256 at rest, TLS 1.3 in transit -
    • -
    • - - Access Controls: Multi-factor authentication, role-based access -
    • -
    • - - Audit Logging: Comprehensive logging of data access -
    • -
    • - - Automatic Logoff: Session timeouts for inactive users -
    • -
    • - - Data Integrity: Validation and verification of data accuracy -
    • -
    -
    - - {/* Your Protected Health Information */} -
    -

    - What Information We Protect -

    -

    - We voluntarily protect the following wellness information with HIPAA-level security - (even though we're not legally required to do so): -

    -
      -
    • - - Wellness assessments (PHQ-9, GAD-7 scores and responses for self-monitoring) -
    • -
    • - - Daily check-in data (mood, emotions, thoughts) -
    • -
    • - - Journal entries and personal reflections -
    • -
    • - - Progress tracking and historical data -
    • -
    • - - Any personally identifiable information (email, name) -
    • -
    -
    - - {/* When We May Disclose Information */} -
    -

    - When We May Disclose Your Information -

    -

    - Following HIPAA-inspired principles (by choice, not legal requirement), we limit disclosure - of your information to: -

    -
      -
    • - 1. - With Your Authorization: When you explicitly request to share - data with healthcare providers -
    • -
    • - 2. - For Treatment, Payment, Operations: To provide our services, - process payments, and improve the app (de-identified data only) -
    • -
    • - 3. - As Required by Law: When compelled by valid legal process - (subpoena, court order) -
    • -
    • - 4. - Public Health & Safety: To prevent serious threat to health - or safety (rare, emergency situations only) -
    • -
    -
    - - {/* Your Rights */} -
    -

    - Your Privacy Rights -

    -

    - Inspired by HIPAA principles (which we voluntarily adopt), you have the right to: -

    -
      -
    • - - Access: Request and receive a copy of your health information -
    • -
    • - - Amendment: Request corrections to inaccurate information -
    • -
    • - - Accounting: Request a list of disclosures we've made -
    • -
    • - - Restriction: Request limits on how we use your information -
    • -
    • - - Confidential Communication: Request communication via specific methods -
    • -
    • - - Portability: Export your data in portable format -
    • -
    • - - Deletion: Request deletion of your account and all data -
    • -
    -
    - - {/* Breach Notification */} -
    -

    - Breach Notification -

    -

    - In the unlikely event of a data breach affecting your protected information, we will - notify you within 60 days via email and/or in-app notification, following HIPAA breach - notification standards (even though we're not legally required to comply with HIPAA). - The notification will include: -

    -
      -
    • - - Description of what happened -
    • -
    • - - Types of information involved -
    • -
    • - - Steps you should take to protect yourself -
    • -
    • - - Steps we're taking to investigate and prevent future breaches -
    • -
    -
    - - {/* Contact */} -
    -

    - Questions or Concerns? -

    -

    - If you have questions about our voluntary HIPAA-level privacy practices: -

    -
    -

    - Privacy Officer:{' '} - - privacy@being.fyi - -

    -

    - Mailing Address: [TO BE DETERMINED] -

    -
    -
    - - {/* Effective Date */} -
    -

    - Effective Date: [TO BE DETERMINED]
    - Last Reviewed: [TO BE DETERMINED] -

    -
    -
    - - {/* Attorney Review Notice */} -
    -

    - ⚠️ DRAFT DOCUMENT: This HIPAA Notice requires attorney review before - publication. Note: Being is NOT legally subject to HIPAA (we're a wellness app, not a - healthcare provider), but we voluntarily adopt HIPAA-level practices. Attorney should - review to ensure accurate representation of this voluntary commitment. -

    -
    -
    -
    - ); -} diff --git a/app/privacy-practices/page.tsx b/app/privacy-practices/page.tsx deleted file mode 100644 index 0af1cda..0000000 --- a/app/privacy-practices/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { LegalPage } from '@/components/legal/LegalPage'; -import content from '@/content/legal/notice-of-privacy-practices.md'; - -export const dynamic = 'force-static'; - -export const metadata = { - title: 'Notice of Privacy Practices | Being', - description: 'How Being handles your mental health and wellness information.', -}; - -export default function PrivacyPracticesPage() { - return ; -} diff --git a/components/legal/GpcNotice.tsx b/components/legal/GpcNotice.tsx new file mode 100644 index 0000000..b08b729 --- /dev/null +++ b/components/legal/GpcNotice.tsx @@ -0,0 +1,62 @@ +'use client'; + +/** + * Global Privacy Control acknowledgement notice. + * + * Renders when either signal is detected client-side: + * - `being_gpc=1` cookie set by middleware (set when Sec-GPC: 1 is received) + * - `navigator.globalPrivacyControl === true` (JS API exposed by some browsers) + * + * Either channel alone triggers display — different browsers/extensions expose + * different combinations. + * + * Uses useSyncExternalStore so the SSR snapshot is `false` (no flash) and the + * client snapshot is computed after hydration. + * + * @see INFRA-151 + */ + +import { useSyncExternalStore } from 'react'; + +function subscribe(): () => void { + return () => {}; +} + +function getClientSnapshot(): boolean { + if (typeof document !== 'undefined') { + const hasCookie = document.cookie.split(';').some((c) => c.trim() === 'being_gpc=1'); + if (hasCookie) return true; + } + if (typeof navigator !== 'undefined') { + return (navigator as Navigator & { globalPrivacyControl?: boolean }).globalPrivacyControl === true; + } + return false; +} + +function getServerSnapshot(): boolean { + return false; +} + +export function GpcNotice() { + const show = useSyncExternalStore(subscribe, getClientSnapshot, getServerSnapshot); + + if (!show) return null; + + return ( + + ); +} diff --git a/components/legal/LegalPage.tsx b/components/legal/LegalPage.tsx index b89dc73..ede7ca2 100644 --- a/components/legal/LegalPage.tsx +++ b/components/legal/LegalPage.tsx @@ -1,19 +1,46 @@ import { marked } from 'marked'; +import type { ReactNode } from 'react'; interface LegalPageProps { content: string; + banner?: ReactNode; +} + +// Per-isolate cache for parsed markdown. Content strings are module-level +// imports — identity-stable across all requests — so a Map keyed on the +// string instance is a safe cache. First request on a fresh Worker isolate +// pays the parse cost (~50ms for the larger docs); subsequent requests on +// the same isolate hit the cache (<1ms). +// +// Why this exists: OpenNext on Cloudflare Workers re-executes server +// components per request even for `force-static` pages, so without this +// cache `marked()` runs on every request. That pushed CPU P99 to ~500ms +// and caused intermittent Worker 1102 (exceededResources) errors. +const htmlCache = new Map(); + +function renderMarkdown(content: string): string { + let html = htmlCache.get(content); + if (html === undefined) { + html = marked(content) as string; + htmlCache.set(content, html); + } + return html; } /** * Renders a legal document from markdown with consistent styling. * Uses design system tokens for colors and spacing. * Server component using marked for edge runtime compatibility. + * + * `banner` slot renders above the article — used for cross-cutting + * notices like GpcNotice that need the page's background container. */ -export function LegalPage({ content }: LegalPageProps) { - const html = marked(content); +export function LegalPage({ content, banner }: LegalPageProps) { + const html = renderMarkdown(content); return (
    + {banner}
    - Download + Get Early Access
    {/* 988 Crisis Button (calm styling, not alarm) */} 988 Crisis Support diff --git a/components/navigation/Footer.tsx b/components/navigation/Footer.tsx index a538be8..0b44147 100644 --- a/components/navigation/Footer.tsx +++ b/components/navigation/Footer.tsx @@ -12,7 +12,7 @@ export default function Footer() {