Compare commits

...

2 Commits

Author SHA1 Message Date
f6c351c31e refactor: move public static pages
Some checks failed
Backend CI/CD / build_and_deploy (push) Has been cancelled
Frontend CI/CD / build_and_deploy (push) Has been cancelled
2026-05-05 11:39:02 -04:00
5baacbceea fix: improve landing nav menus 2026-05-05 11:33:54 -04:00
22 changed files with 190 additions and 43 deletions

View File

@@ -88,7 +88,7 @@ When adding, removing, or renaming public indexed pages, update all of these:
Public page metadata helper: Public page metadata helper:
```txt ```txt
frontend/src/features/landing/publicPageMeta.js frontend/src/static/publicPageMeta.js
``` ```
## Server Routing ## Server Routing

View File

@@ -0,0 +1,19 @@
# Task: Move public static pages
## Goal
Move public marketing/static page code out of `frontend/src/features/landing` into `frontend/src/static` so it is separated from application feature modules.
## Relevant Files
- `frontend/src/router/router.js`
- `frontend/src/entry-public-ssr.js`
- `frontend/src/static/**`
- `docs/SEO.md`
## Validation
```bash
cd frontend
npm run build
```

View File

@@ -6,12 +6,12 @@ import { createI18n } from 'vue-i18n';
import { createPinia } from 'pinia'; import { createPinia } from 'pinia';
import en from '@/locales/en.json'; import en from '@/locales/en.json';
import fr from '@/locales/fr.json'; import fr from '@/locales/fr.json';
import Landing from '@/features/landing/views/Landing.vue'; import Landing from '@/static/views/Landing.vue';
import ProductPage from '@/features/landing/views/ProductPage.vue'; import ProductPage from '@/static/views/ProductPage.vue';
import ProductFeaturePage from '@/features/landing/views/ProductFeaturePage.vue'; import ProductFeaturePage from '@/static/views/ProductFeaturePage.vue';
import PricingPage from '@/features/landing/views/PricingPage.vue'; import PricingPage from '@/static/views/PricingPage.vue';
import BlogsPage from '@/features/landing/views/BlogsPage.vue'; import BlogsPage from '@/static/views/BlogsPage.vue';
import GuidesPage from '@/features/landing/views/GuidesPage.vue'; import GuidesPage from '@/static/views/GuidesPage.vue';
import './assets/main.css'; import './assets/main.css';
const publicRoutes = [ const publicRoutes = [

View File

@@ -2,12 +2,12 @@ import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
const LoginView = () => import('@/features/auth/views/LoginView.vue'); const LoginView = () => import('@/features/auth/views/LoginView.vue');
const Landing = () => import('@/features/landing/views/Landing.vue'); const Landing = () => import('@/static/views/Landing.vue');
const ProductPage = () => import('@/features/landing/views/ProductPage.vue'); const ProductPage = () => import('@/static/views/ProductPage.vue');
const ProductFeaturePage = () => import('@/features/landing/views/ProductFeaturePage.vue'); const ProductFeaturePage = () => import('@/static/views/ProductFeaturePage.vue');
const PricingPage = () => import('@/features/landing/views/PricingPage.vue'); const PricingPage = () => import('@/static/views/PricingPage.vue');
const BlogsPage = () => import('@/features/landing/views/BlogsPage.vue'); const BlogsPage = () => import('@/static/views/BlogsPage.vue');
const GuidesPage = () => import('@/features/landing/views/GuidesPage.vue'); const GuidesPage = () => import('@/static/views/GuidesPage.vue');
const RegisterView = () => import('@/features/auth/views/RegisterView.vue'); const RegisterView = () => import('@/features/auth/views/RegisterView.vue');
const ForgotPasswordView = () => import('@/features/auth/views/ForgotPasswordView.vue'); const ForgotPasswordView = () => import('@/features/auth/views/ForgotPasswordView.vue');
const ResetPasswordView = () => import('@/features/auth/views/ResetPasswordView.vue'); const ResetPasswordView = () => import('@/features/auth/views/ResetPasswordView.vue');

View File

@@ -13,14 +13,31 @@
class="site-nav" class="site-nav"
:aria-label="t('public.nav.ariaLabel')" :aria-label="t('public.nav.ariaLabel')"
> >
<div class="site-nav-group site-nav-product"> <div
<button type="button">{{ t('public.nav.product') }}</button> ref="productMenuRef"
<div class="site-product-panel"> class="site-nav-group site-nav-product"
@pointerenter="openProductMenu"
@pointerleave="scheduleProductMenuClose"
@focusin="openProductMenu"
@focusout="scheduleProductMenuClose"
>
<button
type="button"
:aria-expanded="isProductMenuOpen"
aria-haspopup="true"
>
{{ t('public.nav.product') }}
</button>
<div
class="site-product-panel"
:class="{ open: isProductMenuOpen }"
>
<router-link <router-link
v-for="feature in productFeatureItems" v-for="feature in productFeatureItems"
:key="feature.slug" :key="feature.slug"
class="site-product-link" class="site-product-link"
:to="`/product/${feature.slug}`" :to="`/product/${feature.slug}`"
@click="closeProductMenu"
> >
<span class="site-product-icon"> <span class="site-product-icon">
<svg <svg
@@ -38,11 +55,38 @@
</div> </div>
</div> </div>
<router-link to="/pricing">{{ t('public.nav.pricing') }}</router-link> <router-link to="/pricing">{{ t('public.nav.pricing') }}</router-link>
<div class="site-nav-group"> <div
<button type="button">{{ t('public.nav.resources') }}</button> ref="resourcesMenuRef"
<div class="site-nav-menu"> class="site-nav-group"
<router-link to="/blogs">{{ t('public.nav.blogs') }}</router-link> @pointerenter="openResourcesMenu"
<router-link to="/guides">{{ t('public.nav.guides') }}</router-link> @pointerleave="scheduleResourcesMenuClose"
@focusin="openResourcesMenu"
@focusout="scheduleResourcesMenuClose"
>
<button
type="button"
:aria-expanded="isResourcesMenuOpen"
aria-haspopup="true"
@click="toggleResourcesMenu"
>
{{ t('public.nav.resources') }}
</button>
<div
class="site-nav-menu"
:class="{ open: isResourcesMenuOpen }"
>
<router-link
to="/blogs"
@click="closeResourcesMenu"
>
{{ t('public.nav.blogs') }}
</router-link>
<router-link
to="/guides"
@click="closeResourcesMenu"
>
{{ t('public.nav.guides') }}
</router-link>
</div> </div>
</div> </div>
</nav> </nav>
@@ -78,15 +122,21 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useAuthStore } from '@/features/auth/stores/authStore.js'; import { useAuthStore } from '@/features/auth/stores/authStore.js';
import { productFeatureItems } from '@/features/landing/productFeatures.js'; import { productFeatureItems } from '@/static/productFeatures.js';
const allowedLocales = ['en', 'fr']; const allowedLocales = ['en', 'fr'];
const localeStorageKey = 'user-locale'; const localeStorageKey = 'user-locale';
const authStore = useAuthStore(); const authStore = useAuthStore();
const { locale, t } = useI18n(); const { locale, t } = useI18n();
const isProductMenuOpen = ref(false);
const isResourcesMenuOpen = ref(false);
const productMenuRef = ref(null);
const resourcesMenuRef = ref(null);
let productMenuCloseTimer = null;
let resourcesMenuCloseTimer = null;
const authLink = computed(() => const authLink = computed(() =>
authStore.isAuthenticated authStore.isAuthenticated
@@ -114,6 +164,77 @@
} }
} }
function clearProductMenuCloseTimer() {
if (productMenuCloseTimer === null) {
return;
}
window.clearTimeout(productMenuCloseTimer);
productMenuCloseTimer = null;
}
function clearResourcesMenuCloseTimer() {
if (resourcesMenuCloseTimer === null) {
return;
}
window.clearTimeout(resourcesMenuCloseTimer);
resourcesMenuCloseTimer = null;
}
function openProductMenu() {
clearProductMenuCloseTimer();
isProductMenuOpen.value = true;
}
function openResourcesMenu() {
clearResourcesMenuCloseTimer();
isResourcesMenuOpen.value = true;
}
function closeProductMenu() {
clearProductMenuCloseTimer();
isProductMenuOpen.value = false;
}
function closeResourcesMenu() {
clearResourcesMenuCloseTimer();
isResourcesMenuOpen.value = false;
}
function toggleResourcesMenu() {
clearResourcesMenuCloseTimer();
isResourcesMenuOpen.value = !isResourcesMenuOpen.value;
}
function scheduleProductMenuClose() {
clearProductMenuCloseTimer();
productMenuCloseTimer = window.setTimeout(() => {
const activeElement = document.activeElement;
if (productMenuRef.value?.contains(activeElement)) {
return;
}
closeProductMenu();
}, 120);
}
function scheduleResourcesMenuClose() {
clearResourcesMenuCloseTimer();
resourcesMenuCloseTimer = window.setTimeout(() => {
const activeElement = document.activeElement;
if (resourcesMenuRef.value?.contains(activeElement)) {
return;
}
closeResourcesMenu();
}, 120);
}
onMounted(() => { onMounted(() => {
const storedLocale = window.sessionStorage.getItem(localeStorageKey); const storedLocale = window.sessionStorage.getItem(localeStorageKey);
@@ -121,6 +242,11 @@
locale.value = storedLocale; locale.value = storedLocale;
} }
}); });
onBeforeUnmount(() => {
clearProductMenuCloseTimer();
clearResourcesMenuCloseTimer();
});
</script> </script>
<style scoped> <style scoped>
@@ -155,7 +281,7 @@
} }
.site-nav a { .site-nav a {
@apply rounded-full px-4 py-2 text-sm font-semibold no-underline transition-colors; @apply rounded-[0.65rem] px-4 py-2 text-sm font-semibold no-underline transition-colors;
color: #44516a; color: #44516a;
} }
@@ -170,7 +296,7 @@
} }
.site-nav-group > button { .site-nav-group > button {
@apply rounded-full px-4 py-2 text-sm font-semibold transition-colors; @apply rounded-[0.65rem] px-4 py-2 text-sm font-semibold transition-colors;
color: #44516a; color: #44516a;
} }
@@ -182,7 +308,8 @@
} }
.site-nav-group:hover .site-nav-menu, .site-nav-group:hover .site-nav-menu,
.site-nav-group:focus-within .site-nav-menu { .site-nav-group:focus-within .site-nav-menu,
.site-nav-menu.open {
@apply visible opacity-100; @apply visible opacity-100;
} }
@@ -191,14 +318,15 @@
} }
.site-product-panel { .site-product-panel {
@apply invisible absolute left-1/2 top-[calc(100%+0.5rem)] grid w-[min(56rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-3 gap-2 rounded-[1.25rem] border p-3 opacity-0 transition-opacity; @apply invisible absolute left-1/2 top-full grid w-[min(56rem,calc(100vw-2rem))] -translate-x-1/2 grid-cols-3 gap-2 rounded-[1.25rem] border p-3 opacity-0 transition-opacity;
background: rgba(255, 255, 255, 0.98); background: rgba(255, 255, 255, 0.98);
border-color: rgba(23, 32, 51, 0.08); border-color: rgba(23, 32, 51, 0.08);
box-shadow: 0 24px 60px rgba(23, 32, 51, 0.14); box-shadow: 0 24px 60px rgba(23, 32, 51, 0.14);
} }
.site-nav-product:hover .site-product-panel, .site-nav-product:hover .site-product-panel,
.site-nav-product:focus-within .site-product-panel { .site-nav-product:focus-within .site-product-panel,
.site-product-panel.open {
@apply visible opacity-100; @apply visible opacity-100;
} }
@@ -252,7 +380,7 @@
} }
.site-login { .site-login {
@apply flex h-10 items-center rounded-full px-4 text-sm font-bold no-underline transition-colors; @apply flex h-10 min-w-[7.75rem] items-center justify-center rounded-full px-4 text-sm font-bold no-underline transition-colors;
background: #172033; background: #172033;
color: #fffaf2; color: #fffaf2;
} }

View File

@@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue'; import LandingSiteMenu from '@/static/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js'; import { usePublicPageMeta } from '@/static/publicPageMeta.js';
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue'; import LandingSiteMenu from '@/static/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js'; import { usePublicPageMeta } from '@/static/publicPageMeta.js';
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue'; import LandingSiteMenu from '@/static/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js'; import { usePublicPageMeta } from '@/static/publicPageMeta.js';
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,8 +1,8 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue'; import LandingSiteMenu from '@/static/components/LandingSiteMenu.vue';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js'; import { usePublicPageMeta } from '@/static/publicPageMeta.js';
const { t } = useI18n(); const { t } = useI18n();
const tierKeys = ['free', 'freelance', 'agency', 'enterprise']; const tierKeys = ['free', 'freelance', 'agency', 'enterprise'];

View File

@@ -2,9 +2,9 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue'; import LandingSiteMenu from '@/static/components/LandingSiteMenu.vue';
import { getProductFeature, productFeatureItems } from '@/features/landing/productFeatures.js'; import { getProductFeature, productFeatureItems } from '@/static/productFeatures.js';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js'; import { usePublicPageMeta } from '@/static/publicPageMeta.js';
const route = useRoute(); const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();

View File

@@ -1,9 +1,9 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import LandingSiteMenu from '@/features/landing/components/LandingSiteMenu.vue'; import LandingSiteMenu from '@/static/components/LandingSiteMenu.vue';
import { productFeatureItems } from '@/features/landing/productFeatures.js'; import { productFeatureItems } from '@/static/productFeatures.js';
import { usePublicPageMeta } from '@/features/landing/publicPageMeta.js'; import { usePublicPageMeta } from '@/static/publicPageMeta.js';
const { t } = useI18n(); const { t } = useI18n();