feat(legal): add legal pages and global footer
Introduce Privacy Policy, Terms of Service, and Disclaimers views Add site footer with copyright, legal links, and attribution notices Update system wordings with comprehensive legal content in EN/ZH
This commit is contained in:
@@ -30,6 +30,7 @@ const emit = defineEmits<{
|
||||
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
const copyrightYear = new Date().getFullYear();
|
||||
const languageMenu = ref<HTMLElement | null>(null);
|
||||
const languageMenuButton = ref<HTMLButtonElement | null>(null);
|
||||
const languageMenuOpen = ref(false);
|
||||
@@ -210,5 +211,19 @@ onBeforeUnmount(() => {
|
||||
<main class="page">
|
||||
<slot></slot>
|
||||
</main>
|
||||
|
||||
<footer class="site-footer">
|
||||
<div class="site-footer__inner">
|
||||
<p class="site-footer__copyright">
|
||||
{{ t('legal.footer.copyright', { year: copyrightYear }) }}
|
||||
</p>
|
||||
<nav class="site-footer__links" :aria-label="t('legal.footer.linksLabel')">
|
||||
<RouterLink to="/privacy-policy" @click="closeSidebar">{{ t('legal.footer.privacy') }}</RouterLink>
|
||||
<RouterLink to="/terms-of-service" @click="closeSidebar">{{ t('legal.footer.terms') }}</RouterLink>
|
||||
<RouterLink to="/disclaimers" @click="closeSidebar">{{ t('legal.footer.disclaimers') }}</RouterLink>
|
||||
</nav>
|
||||
<p class="site-footer__notice">{{ t('legal.footer.notice') }}</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -10,6 +10,7 @@ import RecipeList from '../views/RecipeList.vue';
|
||||
import RecipeDetail from '../views/RecipeDetail.vue';
|
||||
import DailyChecklistView from '../views/DailyChecklistView.vue';
|
||||
import LifeView from '../views/LifeView.vue';
|
||||
import LegalView from '../views/LegalView.vue';
|
||||
import ComingSoonView from '../views/ComingSoonView.vue';
|
||||
import AdminView from '../views/AdminView.vue';
|
||||
import ForgotPasswordView from '../views/ForgotPasswordView.vue';
|
||||
@@ -179,6 +180,24 @@ export const router = createRouter({
|
||||
},
|
||||
{ path: '/checklist', component: DailyChecklistView, meta: { seo: seo({ titleKey: 'pages.checklist.title', descriptionKey: 'pages.checklist.subtitle' }) } },
|
||||
{ path: '/life', component: LifeView, meta: { seo: seo({ titleKey: 'pages.life.title', descriptionKey: 'pages.life.subtitle' }) } },
|
||||
{
|
||||
path: '/privacy-policy',
|
||||
component: LegalView,
|
||||
props: { page: 'privacy' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.privacy.title', descriptionKey: 'pages.legal.privacy.subtitle', canonicalPath: '/privacy-policy' }) }
|
||||
},
|
||||
{
|
||||
path: '/terms-of-service',
|
||||
component: LegalView,
|
||||
props: { page: 'terms' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.terms.title', descriptionKey: 'pages.legal.terms.subtitle', canonicalPath: '/terms-of-service' }) }
|
||||
},
|
||||
{
|
||||
path: '/disclaimers',
|
||||
component: LegalView,
|
||||
props: { page: 'disclaimers' },
|
||||
meta: { seo: seo({ titleKey: 'pages.legal.disclaimers.title', descriptionKey: 'pages.legal.disclaimers.subtitle', canonicalPath: '/disclaimers' }) }
|
||||
},
|
||||
{ path: '/admin', component: AdminView, meta: { requiredPermission: 'admin.access', seo: seo({ titleKey: 'pages.admin.title', descriptionKey: 'pages.admin.subtitle', noindex: true }) } },
|
||||
{ path: '/profile', component: UserProfileView, meta: { requiresAuth: true, seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.subtitle', noindex: true }) } },
|
||||
{ path: '/profile/:id', component: UserProfileView, meta: { seo: seo({ titleKey: 'pages.profile.title', descriptionKey: 'pages.profile.publicSubtitle' }) } },
|
||||
|
||||
@@ -413,6 +413,47 @@ svg {
|
||||
padding: 30px var(--page-padding-x) 58px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
grid-column: 2;
|
||||
width: min(100%, var(--container));
|
||||
margin: 0 auto;
|
||||
padding: 0 24px 34px;
|
||||
}
|
||||
|
||||
.site-footer__inner {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--line);
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.site-footer__copyright,
|
||||
.site-footer__notice {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.site-footer__copyright {
|
||||
color: var(--ink-soft);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.site-footer__links {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
}
|
||||
|
||||
.site-footer__links a {
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.site-footer__links a:hover {
|
||||
color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@@ -3057,6 +3098,33 @@ button:disabled,
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.legal-page__updated {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.legal-section__body {
|
||||
color: var(--ink-soft);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.legal-section__body p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.legal-source-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
margin: 4px 0 0;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.legal-source-list a {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.edit-history-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
@@ -5659,6 +5727,12 @@ button:disabled,
|
||||
padding-bottom: 44px;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
padding-right: 16px;
|
||||
padding-left: 16px;
|
||||
padding-bottom: 28px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: 32px;
|
||||
}
|
||||
|
||||
104
frontend/src/views/LegalView.vue
Normal file
104
frontend/src/views/LegalView.vue
Normal file
@@ -0,0 +1,104 @@
|
||||
<script setup lang="ts">
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import PageHeader from '../components/PageHeader.vue';
|
||||
|
||||
type LegalPage = 'privacy' | 'terms' | 'disclaimers';
|
||||
|
||||
type LegalSource = {
|
||||
labelKey: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type LegalSection = {
|
||||
key: string;
|
||||
paragraphKeys: string[];
|
||||
sources?: LegalSource[];
|
||||
};
|
||||
|
||||
defineProps<{
|
||||
page: LegalPage;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const sectionsByPage: Record<LegalPage, LegalSection[]> = {
|
||||
privacy: [
|
||||
{ key: 'overview', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'information', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'storage', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'content', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'sharing', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'choices', paragraphKeys: ['bodyOne', 'bodyTwo'] }
|
||||
],
|
||||
terms: [
|
||||
{ key: 'acceptance', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'accounts', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'contributions', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'acceptableUse', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'availability', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'changes', paragraphKeys: ['bodyOne', 'bodyTwo'] }
|
||||
],
|
||||
disclaimers: [
|
||||
{ key: 'community', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'affiliation', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'pokeapi', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{
|
||||
key: 'references',
|
||||
paragraphKeys: ['bodyOne', 'bodyTwo'],
|
||||
sources: [
|
||||
{ labelKey: 'pokeapiDocs', href: 'https://pokeapi.co/docs/v2' },
|
||||
{ labelKey: 'pokeapiApiDataLicense', href: 'https://github.com/PokeAPI/api-data/blob/master/LICENSE.txt' },
|
||||
{ labelKey: 'pokeapiSpritesLicense', href: 'https://github.com/PokeAPI/sprites/blob/master/LICENCE.txt' },
|
||||
{ labelKey: 'pokemonLegal', href: 'https://www.pokemon.com/us/legal/' },
|
||||
{ labelKey: 'pokopiaWikiReference', href: 'https://www.pokopiawiki.com/' }
|
||||
]
|
||||
},
|
||||
{ key: 'accuracy', paragraphKeys: ['bodyOne', 'bodyTwo'] },
|
||||
{ key: 'rights', paragraphKeys: ['bodyOne', 'bodyTwo'] }
|
||||
]
|
||||
};
|
||||
|
||||
function pageKey(page: LegalPage, suffix: string) {
|
||||
return `pages.legal.${page}.${suffix}`;
|
||||
}
|
||||
|
||||
function sectionKey(page: LegalPage, section: string, suffix: string) {
|
||||
return `pages.legal.${page}.sections.${section}.${suffix}`;
|
||||
}
|
||||
|
||||
function sourceKey(page: LegalPage, source: string) {
|
||||
return `pages.legal.${page}.sources.${source}`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="page-stack legal-page">
|
||||
<PageHeader :title="t(pageKey(page, 'title'))" :subtitle="t(pageKey(page, 'subtitle'))">
|
||||
<template #kicker>{{ t(pageKey(page, 'kicker')) }}</template>
|
||||
<template #meta>
|
||||
<p class="legal-page__updated">{{ t('pages.legal.lastUpdated') }}</p>
|
||||
</template>
|
||||
</PageHeader>
|
||||
|
||||
<article
|
||||
v-for="section in sectionsByPage[page]"
|
||||
:key="section.key"
|
||||
class="detail-section legal-section"
|
||||
>
|
||||
<h2>{{ t(sectionKey(page, section.key, 'title')) }}</h2>
|
||||
<div class="detail-section__body legal-section__body">
|
||||
<p v-for="paragraphKey in section.paragraphKeys" :key="paragraphKey">
|
||||
{{ t(sectionKey(page, section.key, paragraphKey)) }}
|
||||
</p>
|
||||
|
||||
<ul v-if="section.sources?.length" class="legal-source-list" :aria-label="t('pages.legal.sourceLinks')">
|
||||
<li v-for="source in section.sources" :key="source.href">
|
||||
<a :href="source.href" target="_blank" rel="noreferrer">
|
||||
{{ t(sourceKey(page, source.labelKey)) }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</template>
|
||||
Reference in New Issue
Block a user