Просмотр исходного кода

feat: full review lifecycle with admin moderation and public display on home page

unknown 2 дней назад
Родитель
Сommit
e78c4cfbb5

+ 0 - 4
backend/routers/auth.py

@@ -188,14 +188,10 @@ async def forgot_password(request: schemas.ForgotPassword, lang: str = "en"):
 
 @router.post("/verify-reset-token")
 async def verify_reset_token_post(data: schemas.TokenVerify, lang: str = "en"):
-    # Debug: log to server console
-    print(f"BACKEND: POST verify token={data.token} lang={lang}")
     return await _verify_token_internal(data.token, lang)
 
 @router.get("/verify-reset-token")
 async def verify_reset_token_get(token: str, lang: str = "en"):
-    # Debug: log to server console
-    print(f"BACKEND: GET verify token={token} lang={lang}")
     return await _verify_token_internal(token, lang)
 
 async def _verify_token_internal(token: str, lang: str):

+ 12 - 0
backend/routers/orders.py

@@ -195,6 +195,18 @@ async def post_order_review(order_id: int, review: schemas.OrderReview, user: di
     
     return {"message": "Review submitted successfully and is awaiting moderation"}
 
+@router.patch("/{order_id}/review/approve")
+async def approve_order_review(order_id: int, admin: dict = Depends(require_admin)):
+    db.execute_commit("UPDATE orders SET review_approved = TRUE WHERE id = %s", (order_id,))
+    audit_service.log(admin['id'], "ORDER_REVIEW_APPROVE", f"Approved review for order {order_id}", order_id)
+    return {"message": "Review approved successfully"}
+
+@router.get("/reviews/public")
+async def get_public_reviews():
+    # Only return approved reviews, anonymized (only first name)
+    query = "SELECT first_name, rating, review_text FROM orders WHERE review_approved = TRUE ORDER BY created_at DESC LIMIT 10"
+    return db.execute_query(query)
+
 @router.post("/estimate")
 async def get_price_estimate(data: schemas.EstimateRequest):
     material = db.execute_query("SELECT price_per_cm3 FROM materials WHERE id = %s", (data.material_id,))

+ 7 - 0
scratch/check_redis.py

@@ -0,0 +1,7 @@
+import redis
+try:
+    r = redis.Redis(host='localhost', port=6379, db=0, socket_timeout=5)
+    print(f"PING: {r.ping()}")
+    print("Redis is ALIVE")
+except Exception as e:
+    print(f"Redis ERROR: {e}")

+ 10 - 0
scratch/find_orders_line.py

@@ -0,0 +1,10 @@
+import json
+
+with open('src/locales/translations.user.json', 'r', encoding='utf-8') as f:
+    for i, line in enumerate(f, 1):
+        if '"home": {' in line:
+            print(f"FOUND home at line {i}: {line.strip()}")
+        if '"index": {' in line:
+            print(f"FOUND index at line {i}: {line.strip()}")
+        if '"hero": {' in line:
+            print(f"FOUND hero at line {i}: {line.strip()}")

+ 72 - 0
src/components/ReviewsSection.vue

@@ -0,0 +1,72 @@
+<template>
+  <section v-if="reviews.length > 0" class="py-24 bg-background relative overflow-hidden">
+    <!-- Background Decor -->
+    <div class="absolute top-0 left-1/2 -translate-x-1/2 w-full max-w-7xl h-full pointer-events-none">
+      <div class="absolute top-24 left-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
+      <div class="absolute bottom-24 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
+    </div>
+
+    <div class="container mx-auto px-4 relative z-10">
+      <div class="text-center mb-16">
+        <h2 class="font-display text-4xl font-bold mb-4 bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/60">
+          {{ t('home.reviews.title') }}
+        </h2>
+        <p class="text-muted-foreground max-w-2xl mx-auto">
+          {{ t('home.reviews.subtitle') }}
+        </p>
+      </div>
+
+      <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
+        <div 
+          v-for="(review, idx) in reviews" 
+          :key="idx"
+          v-motion
+          :initial="{ opacity: 0, y: 20 }"
+          :enter="{ opacity: 1, y: 0, transition: { delay: idx * 100 } }"
+          class="bg-card/40 backdrop-blur-xl border border-border/50 p-8 rounded-3xl hover:border-primary/30 transition-all group"
+        >
+          <div class="flex gap-0.5 mb-6">
+            <Star 
+              v-for="i in 5" 
+              :key="i"
+              class="w-4 h-4" 
+              :class="i <= review.rating ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground/20'"
+            />
+          </div>
+          
+          <p class="text-foreground/80 leading-relaxed mb-8 italic">
+            "{{ review.review_text }}"
+          </p>
+
+          <div class="flex items-center gap-3">
+            <div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-sm">
+              {{ review.first_name[0] }}
+            </div>
+            <div>
+              <p class="font-bold text-sm">{{ review.first_name }}</p>
+              <p class="text-[10px] text-muted-foreground uppercase tracking-widest">{{ t('home.reviews.verifiedCustomer') }}</p>
+            </div>
+          </div>
+        </div>
+      </div>
+    </div>
+  </section>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Star } from 'lucide-vue-next';
+import { getPublicReviews } from '@/lib/api';
+
+const { t } = useI18n();
+const reviews = ref<any[]>([]);
+
+onMounted(async () => {
+  try {
+    reviews.value = await getPublicReviews();
+  } catch (e) {
+    console.error('Failed to load reviews:', e);
+  }
+});
+</script>

+ 20 - 0
src/lib/api.ts

@@ -138,6 +138,26 @@ export const submitOrderReview = async (orderId: number, data: { rating: number,
   return response.json();
 };
 
+export const approveOrderReview = async (orderId: number) => {
+  const token = localStorage.getItem('token');
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/review/approve?lang=${i18n.global.locale.value}`, {
+    method: 'PATCH',
+    headers: { 'Authorization': `Bearer ${token}` }
+  });
+  
+  if (!response.ok) {
+    throw new Error(await getErrorMessage(response, 'Failed to approve review'));
+  }
+  
+  return response.json();
+};
+
+export const getPublicReviews = async () => {
+  const response = await fetch(`${API_BASE_URL}/orders/reviews/public?lang=${i18n.global.locale.value}`);
+  if (!response.ok) return [];
+  return response.json();
+};
+
 export const loginUser = async (userData: any) => {
   const response = await fetch(`${API_BASE_URL}/auth/login?lang=${i18n.global.locale.value}`, {
     method: 'POST',

+ 28 - 0
src/locales/translations.user.json

@@ -341,6 +341,12 @@
       "ua": "Студія 3D Друку"
     },
     "toasts": {
+      "reviewApproved": {
+        "en": "Review approved and published",
+        "me": "Recenzija odobrena i objavljena",
+        "ru": "Отзыв одобрен и опубликован",
+        "ua": "Відгук схвалено та опубліковано"
+      },
       "accountCreated": {
         "en": "Account created!",
         "me": "Nalog je kreiran!",
@@ -2051,6 +2057,28 @@
       "ua": "Як ми можемо допомогти?"
     }
   },
+  "home": {
+    "reviews": {
+      "title": {
+        "en": "Client Experiences",
+        "me": "Iskustva klijenata",
+        "ru": "Отзывы наших клиентов",
+        "ua": "Відгуки наших клієнтів"
+      },
+      "subtitle": {
+        "en": "Discover what our customers say about our print quality and reliability.",
+        "me": "Saznajte što naši klijenti misle o kvalitetu i pouzdanosti naše štampe.",
+        "ru": "Узнайте, что наши клиенты думают о качестве нашей печати и надежности сервиса.",
+        "ua": "Дізнайтеся, що наші клієнти думають про якість нашого друку та надійність сервісу."
+      },
+      "verifiedCustomer": {
+        "en": "Verified Customer",
+        "me": "Verifikovani kupac",
+        "ru": "Проверенный заказ",
+        "ua": "Перевірене замовлення"
+      }
+    }
+  },
   "hero": {
     "badge": {
       "en": "Trust in Every Layer",

+ 38 - 2
src/pages/Admin.vue

@@ -360,6 +360,32 @@
               <button @click="handleDeleteMaterial(m.id, m.name_en)" class="p-2 hover:bg-rose-500/10 rounded-lg text-muted-foreground hover:text-rose-500 transition-colors"><Trash2 class="w-4 h-4" /></button>
             </div>
           </div>
+
+          <!-- Review Moderation Block -->
+          <div v-if="order.review_text" class="p-4 bg-amber-500/5 border-t border-border/50 flex flex-col sm:flex-row items-center justify-between gap-4">
+            <div class="flex items-start gap-4 flex-1">
+              <div class="pt-1">
+                <div class="flex gap-0.5">
+                  <Star v-for="i in 5" :key="i" class="w-3 h-3" :class="i <= order.rating ? 'text-yellow-500 fill-yellow-500' : 'text-muted-foreground/30'" />
+                </div>
+              </div>
+              <div>
+                <p class="text-[11px] font-bold text-foreground italic">"{{ order.review_text }}"</p>
+                <div class="flex items-center gap-2 mt-1">
+                  <span class="text-[8px] font-black uppercase tracking-widest text-muted-foreground">Customer Feedback</span>
+                  <span v-if="order.review_approved" class="flex items-center gap-1 text-[8px] font-black uppercase tracking-widest text-emerald-500">
+                    <CheckCircle2 class="w-2.5 h-2.5" /> Approved
+                  </span>
+                  <span v-else class="flex items-center gap-1 text-[8px] font-black uppercase tracking-widest text-amber-500">
+                    <Clock class="w-2.5 h-2.5" /> Pending Moderation
+                  </span>
+                </div>
+              </div>
+            </div>
+            <Button v-if="!order.review_approved" variant="hero" size="sm" class="h-8 px-4 text-[10px]" @click="handleApproveReview(order.id)">
+              Approve Feedback
+            </Button>
+          </div>
         </div>
       </div>
 
@@ -915,7 +941,7 @@ import { RouterLink, useRouter, useRoute } from "vue-router";
 import { useI18n } from "vue-i18n";
 import { loadAdminTranslations } from "@/i18n";
 import { toast } from "vue-sonner";
-import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper, History, X, Users, Save, Key } from "lucide-vue-next";
+import { Package, Clock, CheckCircle2, Truck, XCircle, AlertCircle, FileText, ExternalLink, ShieldCheck, Eye, RefreshCw, Search, Filter, Layers, Settings, Plus, Edit2, Trash2, ToggleLeft, ToggleRight, Database, Image as ImageIcon, EyeOff, Hash, MessageCircle, FileBox, Download, Newspaper, History, X, Users, Save, Key, Star } from "lucide-vue-next";
 import Button from "@/components/ui/button.vue";
 import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
@@ -925,7 +951,7 @@ import {
   adminGetOrders, adminUpdateOrder, adminGetMaterials, adminCreateMaterial, adminUpdateMaterial, adminDeleteMaterial, 
   adminGetServices, adminCreateService, adminUpdateService, adminDeleteService, adminUploadOrderPhoto, 
   adminUpdatePhotoStatus, adminDeletePhoto, adminGetAllPhotos, adminAttachFile, adminDeleteFile, adminDeleteOrder, getBlogPosts, adminCreatePost, adminUpdatePost, adminDeletePost, adminUpdateUser, adminGetUsers, adminCreateUser, adminGetAuditLogs,
-  adminGetOrderItems, adminUpdateOrderItems, API_BASE_URL, RESOURCES_BASE_URL
+  adminGetOrderItems, adminUpdateOrderItems, approveOrderReview, API_BASE_URL, RESOURCES_BASE_URL
 } from "@/lib/api";
 
 const { t, locale } = useI18n();
@@ -1122,6 +1148,16 @@ async function fetchData() {
   finally { isLoading.value = false; }
 }
 
+async function handleApproveReview(orderId: number) {
+  try {
+    await approveOrderReview(orderId);
+    toast.success(t('admin.toasts.reviewApproved'));
+    fetchData();
+  } catch (e: any) {
+    toast.error(e.message || 'Failed to approve review');
+  }
+}
+
 async function fetchUsers() {
   try {
     const res = await adminGetUsers(userPage.value, 50, userSearch.value);

+ 2 - 0
src/pages/Index.vue

@@ -8,6 +8,7 @@
       <ModelUploadSection />
       <QuotingSection />
       <ProcessSection />
+      <ReviewsSection />
       <CTASection />
     </main>
     <Footer />
@@ -24,6 +25,7 @@ const PrintingNuancesSection = defineAsyncComponent(() => import("@/components/P
 const ModelUploadSection = defineAsyncComponent(() => import("@/components/ModelUploadSection.vue"));
 const QuotingSection = defineAsyncComponent(() => import("@/components/QuotingSection.vue"));
 const ProcessSection = defineAsyncComponent(() => import("@/components/ProcessSection.vue"));
+const ReviewsSection = defineAsyncComponent(() => import("@/components/ReviewsSection.vue"));
 const CTASection = defineAsyncComponent(() => import("@/components/CTASection.vue"));
 const Footer = defineAsyncComponent(() => import("@/components/Footer.vue"));
 </script>

+ 0 - 2
src/router/index.ts

@@ -45,8 +45,6 @@ router.beforeEach(async (to) => {
   let lang = to.params.lang as string;
   const savedLang = localStorage.getItem('locale') || 'en';
   
-  console.log(`Router: path=${to.path}, langParam=${lang}, stored=${savedLang}`);
-
   if (!lang) {
     const segments = to.path.split('/').filter(Boolean);
     const firstSegment = segments[0];