Quellcode durchsuchen

feat(seo): implement SSR/SSG prerendering, @unhead/vue meta management, and local SEO optimizations

unknown vor 4 Stunden
Ursprung
Commit
c46ec71ae4
11 geänderte Dateien mit 812 neuen und 22 gelöschten Zeilen
  1. 7 7
      index.html
  2. 688 7
      package-lock.json
  3. 2 1
      package.json
  4. 18 1
      src/App.vue
  5. 2 2
      src/components/ServicesSection.vue
  6. 5 0
      src/main.ts
  7. 9 0
      src/pages/About.vue
  8. 9 1
      src/pages/Contact.vue
  9. 35 1
      src/pages/Index.vue
  10. 11 1
      src/pages/Portfolio.vue
  11. 26 1
      vite.config.ts

+ 7 - 7
index.html

@@ -9,21 +9,21 @@
     
     <title>Radionica 3D | Professional 3D Printing in Montenegro</title>
     <link rel="canonical" href="https://radionica3d.me/" />
-    <meta name="description" content="Professional 3D printing and rapid prototyping services in Montenegro. Instant quotes, industrial materials (PLA, ABS, PETG, Resin), and high-precision results." />
+    <meta name="description" content="Professional 3D printing and rapid prototyping services in Montenegro. Instant quotes, industrial materials, and high-precision results." />
     
     <!-- Open Graph / Facebook -->
     <meta property="og:type" content="website" />
-    <meta property="og:url" content="https://radionica3d.com/" />
+    <meta property="og:url" content="https://radionica3d.me/" />
     <meta property="og:title" content="Radionica 3D | Professional 3D Printing in Montenegro" />
-    <meta property="og:description" content="Instant 3D printing quotes and high-quality prototyping. Fast shipping and industrial materials." />
-    <meta property="og:image" content="https://radionica3d.com/og-image.png" />
+    <meta property="og:description" content="Instant 3D printing quotes and high-quality prototyping in Herceg Novi, Montenegro." />
+    <meta property="og:image" content="https://radionica3d.me/og-image.jpg" />
 
     <!-- Twitter -->
     <meta property="twitter:card" content="summary_large_image" />
-    <meta property="twitter:url" content="https://radionica3d.com/" />
+    <meta property="twitter:url" content="https://radionica3d.me/" />
     <meta property="twitter:title" content="Radionica 3D | Professional 3D Printing in Montenegro" />
-    <meta property="twitter:description" content="Instant 3D printing quotes and high-quality prototyping." />
-    <meta property="twitter:image" content="https://radionica3d.com/og-image.png" />
+    <meta property="twitter:description" content="Instant 3D printing quotes and high-quality prototyping in Montenegro." />
+    <meta property="twitter:image" content="https://radionica3d.me/og-image.jpg" />
   </head>
   <body>
     <div id="root"></div>

Datei-Diff unterdrückt, da er zu groß ist
+ 688 - 7
package-lock.json


+ 2 - 1
package.json

@@ -43,10 +43,11 @@
     "tailwindcss": "^3.4.1",
     "typescript": "^5.3.3",
     "vite": "^5.1.5",
+    "vite-plugin-prerender": "^1.0.8",
     "vitest": "^4.1.4",
     "vue-tsc": "^2.0.7"
   },
   "overrides": {
     "esbuild": "0.25.0"
   }
-}
+}

+ 18 - 1
src/App.vue

@@ -13,11 +13,28 @@
 <script setup lang="ts">
 import { Toaster } from "vue-sonner";
 import { useAuthStore } from "@/stores/auth";
-import { defineAsyncComponent } from "vue";
+import { defineAsyncComponent, computed } from "vue";
+import { useHead } from "@unhead/vue";
+import { useI18n } from "vue-i18n";
 
 const CompleteProfileModal = defineAsyncComponent(() => import("@/components/CompleteProfileModal.vue"));
 const CookieBanner = defineAsyncComponent(() => import("@/components/CookieBanner.vue"));
 
+const { t, locale } = useI18n();
 const authStore = useAuthStore();
 authStore.init();
+
+// Global SEO Defaults
+useHead({
+  titleTemplate: (title) => title ? `${title} | Radionica 3D` : 'Radionica 3D',
+  htmlAttrs: {
+    lang: computed(() => locale.value)
+  },
+  meta: [
+    { property: 'og:type', content: 'website' },
+    { property: 'og:site_name', content: 'Radionica 3D' },
+    { property: 'og:image', content: 'https://radionica3d.me/og-image.jpg' },
+    { name: 'twitter:card', content: 'summary_large_image' },
+  ]
+});
 </script>

+ 2 - 2
src/components/ServicesSection.vue

@@ -48,9 +48,9 @@
             class="group p-8 card-apple hover:border-primary/20 animate-fade-in"
             :style="{ animationDelay: `${idx * 50}ms` }"
           >
-            <div class="text-xl font-display font-black text-foreground mb-3 group-hover:text-primary transition-colors uppercase tracking-widest border-b border-black/[0.03] pb-2">
+            <h3 class="text-xl font-display font-black text-foreground mb-3 group-hover:text-primary transition-colors uppercase tracking-widest border-b border-black/[0.03] pb-2">
               {{ material[`name_${locale}` as keyof Material] || material.name_en }}
-            </div>
+            </h3>
             <p class="text-sm text-foreground/50 leading-relaxed font-medium whitespace-pre-line">
               {{ material[`long_desc_${locale}` as keyof Material] || material[`desc_${locale}` as keyof Material] || material.desc_en }}
             </p>

+ 5 - 0
src/main.ts

@@ -2,6 +2,7 @@ import { createApp } from "vue";
 import { createPinia } from "pinia";
 import { MotionPlugin } from "@vueuse/motion";
 import App from "./App.vue";
+import { createHead } from "@unhead/vue";
 import router from "./router";
 import i18n from "./i18n";
 import "./index.css";
@@ -15,8 +16,12 @@ window.addEventListener('vite:preloadError', () => {
 const app = createApp(App);
 
 app.use(createPinia());
+app.use(createHead());
 app.use(MotionPlugin);
 app.use(router);
 app.use(i18n);
 
 app.mount("#root");
+
+// Dispatch event for prerendering
+document.dispatchEvent(new Event("render-event"));

+ 9 - 0
src/pages/About.vue

@@ -91,6 +91,15 @@
 
 <script setup lang="ts">
 import { useI18n } from "vue-i18n";
+import { useHead } from "@unhead/vue";
+import { computed } from "vue";
 
 const { t } = useI18n();
+
+useHead({
+  title: computed(() => `${t('footer.about')} | Radionica 3D`),
+  meta: [
+    { name: 'description', content: computed(() => t('about.subtitle')) }
+  ]
+});
 </script>

+ 9 - 1
src/pages/Contact.vue

@@ -231,10 +231,18 @@
 import { ref } from "vue";
 import { useI18n } from "vue-i18n";
 import { submitContactForm } from "../lib/api";
+import { useHead } from "@unhead/vue";
+import { computed } from "vue";
 
-const fileInputRef = ref<HTMLInputElement | null>(null);
 const { t } = useI18n();
 
+useHead({
+  title: computed(() => `${t('footer.contact')} | Radionica 3D`),
+  meta: [
+    { name: 'description', content: computed(() => t('contact.subtitle')) }
+  ]
+});
+
 const form = ref({
   name: "",
   email: "",

+ 35 - 1
src/pages/Index.vue

@@ -19,7 +19,41 @@
 import Header from "@/components/Header.vue";
 import HeroSection from "@/components/HeroSection.vue";
 import ServicesSection from "@/components/ServicesSection.vue";
-import { defineAsyncComponent } from "vue";
+import { defineAsyncComponent, computed } from "vue";
+import { useHead } from "@unhead/vue";
+import { useI18n } from "vue-i18n";
+
+const { t } = useI18n();
+
+useHead({
+  title: computed(() => t('seo.home.title')),
+  meta: [
+    { name: 'description', content: computed(() => t('seo.home.description')) },
+    { property: 'og:title', content: computed(() => t('seo.home.title')) },
+    { property: 'og:description', content: computed(() => t('seo.home.description')) },
+  ],
+  script: [
+    {
+      type: 'application/ld+json',
+      children: JSON.stringify({
+        "@context": "https://schema.org",
+        "@type": "LocalBusiness",
+        "name": "Radionica 3D",
+        "image": "https://radionica3d.me/logo.png",
+        "address": {
+          "@type": "PostalAddress",
+          "addressLocality": "Herceg Novi",
+          "addressCountry": "ME"
+        },
+        "url": "https://radionica3d.me",
+        "telephone": "+382...",
+        "description": t('seo.home.description'),
+        "priceRange": "$$",
+        "openingHours": "Mo-Fr 09:00-18:00"
+      })
+    }
+  ]
+});
 
 const PrintingNuancesSection = defineAsyncComponent(() => import("@/components/PrintingNuancesSection.vue"));
 const ModelUploadSection = defineAsyncComponent(() => import("@/components/ModelUploadSection.vue"));

+ 11 - 1
src/pages/Portfolio.vue

@@ -32,7 +32,7 @@
           @click="selectedImage = `${RESOURCES_BASE_URL}${item.file_path.startsWith('/') ? '' : '/'}${item.file_path}`"
         >
           <img :src="`${RESOURCES_BASE_URL}${item.file_path.startsWith('/') ? '' : '/'}${item.file_path}`" :alt="item.material_name"
-            class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" />
+            class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110" loading="lazy" />
           <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-all duration-500 flex flex-col justify-end p-6">
             <span class="text-[10px] font-bold uppercase tracking-widest text-primary mb-1">{{ item.material_name }}</span>
             <div class="flex items-center justify-end">
@@ -83,8 +83,18 @@ import { Sparkles, Loader2, Camera, ExternalLink, RefreshCw, Image as ImageIcon
 import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
 import { getPortfolio, API_BASE_URL, RESOURCES_BASE_URL } from "@/lib/api";
+import { useHead } from "@unhead/vue";
+import { computed } from "vue";
 
 const { t } = useI18n();
+
+useHead({
+  title: computed(() => t('seo.portfolio.title')),
+  meta: [
+    { name: 'description', content: computed(() => t('portfolio.description')) }
+  ]
+});
+
 const items = ref<any[]>([]);
 const isLoading = ref(true);
 const selectedImage = ref<string | null>(null);

+ 26 - 1
vite.config.ts

@@ -1,9 +1,34 @@
 import { defineConfig } from "vite";
 import vue from "@vitejs/plugin-vue";
 import path from "path";
+import prerender from "vite-plugin-prerender";
 
 export default defineConfig({
-  plugins: [vue()],
+  plugins: [
+    vue(),
+    prerender({
+      // REQUIRED: The path to the built app to prerender.
+      staticDir: path.join(__dirname, 'dist'),
+      // The routes to render.
+      routes: [
+        '/', 
+        '/en/', '/me/', '/ru/',
+        '/en/portfolio', '/me/portfolio', '/ru/portfolio',
+        '/en/about', '/me/about', '/ru/about',
+        '/en/contact', '/me/contact', '/ru/contact'
+      ],
+      renderer: new prerender.PuppeteerRenderer({
+        renderAfterDocumentEvent: 'render-event',
+        injectProperty: '__PRERENDER_INJECTED',
+        inject: {
+          foo: 'bar'
+        },
+        renderAfterTime: 2000,
+        headless: true,
+        args: ['--no-sandbox', '--disable-setuid-sandbox']
+      })
+    })
+  ],
   resolve: {
     alias: {
       "@": path.resolve(__dirname, "./src"),

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.