Compare commits
2 Commits
feef8cbafd
...
f6c351c31e
| Author | SHA1 | Date | |
|---|---|---|---|
| f6c351c31e | |||
| 5baacbceea |
@@ -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
|
||||||
|
|||||||
19
docs/TASKS/app-shell/002-move-public-static-pages.md
Normal file
19
docs/TASKS/app-shell/002-move-public-static-pages.md
Normal 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
|
||||||
|
```
|
||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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();
|
||||||
|
|
||||||
@@ -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'];
|
||||||
@@ -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();
|
||||||
@@ -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();
|
||||||
|
|
||||||
Reference in New Issue
Block a user