Selaa lähdekoodia

feat: implement order reviews system with stars and moderation status

unknown 2 päivää sitten
vanhempi
commit
06b08d5aac

+ 20 - 0
backend/routers/orders.py

@@ -175,6 +175,26 @@ async def get_my_orders(
         row['items'] = db.execute_query("SELECT * FROM order_items WHERE order_id = %s", (row['id'],))
     return {"orders": results, "total": total}
 
+@router.post("/{order_id}/review")
+async def post_order_review(order_id: int, review: schemas.OrderReview, user: dict = Depends(get_current_user)):
+    # Check if order belongs to user and is in appropriate status
+    order = db.execute_query("SELECT id, status FROM orders WHERE id = %s AND user_id = %s", (order_id, user['id']))
+    if not order:
+        raise HTTPException(status_code=404, detail="Order not found or access denied")
+    
+    if order[0]['status'] not in ['shipped', 'completed']:
+        raise HTTPException(status_code=400, detail="Reviews can only be posted for shipped or completed orders")
+    
+    db.execute_commit(
+        "UPDATE orders SET review_text = %s, rating = %s, review_approved = FALSE WHERE id = %s",
+        (review.review_text, review.rating, order_id)
+    )
+    
+    # Create audit log
+    audit_service.log(user['id'], "ORDER_REVIEW", f"Posted review for order {order_id}", order_id)
+    
+    return {"message": "Review submitted successfully and is awaiting moderation"}
+
 @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,))

+ 8 - 0
backend/schemas.py

@@ -219,9 +219,17 @@ class OrderResponse(OrderCreate):
     color_name: Optional[str] = None
     original_params: Optional[str] = None
     created_at: datetime
+    # Review fields
+    review_text: Optional[str] = None
+    rating: Optional[int] = 0
+    review_approved: bool = False
     
     model_config = ConfigDict(from_attributes=True)
 
+class OrderReview(BaseModel):
+    rating: int = Field(..., ge=1, le=5)
+    review_text: str = Field(..., min_length=2)
+
 class MessageCreate(BaseModel):
     message: str
 

+ 101 - 0
src/components/OrderReviewForm.vue

@@ -0,0 +1,101 @@
+<template>
+  <div class="mt-8 pt-6 border-t border-border/50 relative z-10">
+    <!-- Submit Form -->
+    <div v-if="!order.review_text" class="bg-primary/5 border border-primary/20 rounded-2xl p-6">
+      <div class="flex items-center gap-2 mb-4">
+        <Star class="w-4 h-4 text-primary" />
+        <h4 class="font-bold text-sm">{{ t('orders.review.writeTitle') }}</h4>
+      </div>
+      
+      <div class="flex gap-2 mb-4">
+        <button 
+          v-for="i in 5" 
+          :key="i"
+          @click="rating = i"
+          class="transition-transform hover:scale-110"
+        >
+          <Star 
+            class="w-6 h-6" 
+            :class="i <= rating ? 'text-yellow-400 fill-yellow-400' : 'text-muted-foreground/30'"
+          />
+        </button>
+      </div>
+
+      <textarea
+        v-model="reviewText"
+        :placeholder="t('orders.review.placeholder')"
+        class="w-full bg-background/50 border border-border/50 rounded-xl p-4 text-sm focus:outline-none focus:border-primary/50 transition-colors resize-none mb-4"
+        rows="3"
+      ></textarea>
+
+      <button 
+        @click="submit"
+        :disabled="isSubmitting || rating === 0 || reviewText.length < 2"
+        class="bg-primary text-primary-foreground px-6 py-2 rounded-xl text-sm font-bold hover:bg-primary/90 transition-all disabled:opacity-50 disabled:scale-100 transform active:scale-95 flex items-center gap-2"
+      >
+        <Loader2 v-if="isSubmitting" class="w-4 h-4 animate-spin" />
+        {{ t('orders.review.submit') }}
+      </button>
+    </div>
+
+    <!-- Feedback after submission -->
+    <div v-else class="flex flex-col gap-3">
+      <div class="flex items-center justify-between">
+        <div class="flex items-center gap-2">
+          <div class="flex gap-0.5">
+            <Star 
+              v-for="i in 5" 
+              :key="i"
+              class="w-3.5 h-3.5" 
+              :class="i <= order.rating ? 'text-yellow-400 fill-yellow-400' : 'text-muted-foreground/30'"
+            />
+          </div>
+          <span v-if="!order.review_approved" class="text-[10px] bg-amber-500/10 text-amber-500 px-2 py-0.5 rounded-full font-bold uppercase tracking-wider">
+            {{ t('orders.review.pending') }}
+          </span>
+          <span v-else class="text-[10px] bg-emerald-500/10 text-emerald-500 px-2 py-0.5 rounded-full font-bold uppercase tracking-wider">
+            {{ t('orders.review.approved') }}
+          </span>
+        </div>
+      </div>
+      <p class="text-sm text-foreground/80 italic leading-relaxed">"{{ order.review_text }}"</p>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue';
+import { useI18n } from 'vue-i18n';
+import { Star, Loader2 } from 'lucide-vue-next';
+import { submitOrderReview } from '@/lib/api';
+import { toast } from 'vue-sonner';
+
+const props = defineProps<{
+  order: any
+}>();
+
+const emit = defineEmits(['updated']);
+
+const { t } = useI18n();
+const rating = ref(0);
+const reviewText = ref('');
+const isSubmitting = ref(false);
+
+async function submit() {
+  if (rating.value === 0 || reviewText.value.length < 2) return;
+  
+  isSubmitting.value = true;
+  try {
+    await submitOrderReview(props.order.id, {
+      rating: rating.value,
+      review_text: reviewText.value
+    });
+    toast.success(t('orders.review.success'));
+    emit('updated');
+  } catch (e: any) {
+    toast.error(e.message || 'Failed to submit review');
+  } finally {
+    isSubmitting.value = false;
+  }
+}
+</script>

+ 14 - 0
src/lib/api.ts

@@ -124,6 +124,20 @@ export const submitContactForm = async (formData: FormData) => {
   return response.json();
 };
 
+export const submitOrderReview = async (orderId: number, data: { rating: number, review_text: string }) => {
+  const response = await fetch(`${API_BASE_URL}/orders/${orderId}/review?lang=${i18n.global.locale.value}`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(data),
+  });
+  
+  if (!response.ok) {
+    throw new Error(await getErrorMessage(response, 'Failed to submit review'));
+  }
+  
+  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',

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

@@ -2502,6 +2502,44 @@
       "ru": "Как только вы создадите проект на 3D-печать, вы сможете отслеживать его прогресс здесь.",
       "ua": "Як тільки ви створите проєкт на 3D-друк, ви зможете відстежувати його прогрес тут."
     },
+    "review": {
+      "writeTitle": {
+        "en": "Share Your Experience",
+        "me": "Podijelite Vaše iskustvo",
+        "ru": "Поделитесь впечатлениями",
+        "ua": "Поділіться вашими враженнями"
+      },
+      "placeholder": {
+        "en": "How was the print quality? Was the delivery on time?",
+        "me": "Kakav je kvalitet štampe? Da li je isporuka bila na vrijeme?",
+        "ru": "Как качество печати? Быстро ли приехал заказ?",
+        "ua": "Яка якість друку? Чи вчасно приїхало замовлення?"
+      },
+      "submit": {
+        "en": "Post Review",
+        "me": "Pošalji recenziju",
+        "ru": "Оставить отзыв",
+        "ua": "Залишити відгук"
+      },
+      "success": {
+        "en": "Thank you for your review!",
+        "me": "Hvala Vam na recenziji!",
+        "ru": "Спасибо за ваш отзыв!",
+        "ua": "Дякуємо за ваш відгук!"
+      },
+      "pending": {
+        "en": "Pending Approval",
+        "me": "Na čekanju",
+        "ru": "На модерации",
+        "ua": "На модерації"
+      },
+      "approved": {
+        "en": "Public Review",
+        "me": "Javna recenzija",
+        "ru": "Опубликован",
+        "ua": "Опубліковано"
+      }
+    },
     "titleSubtitle": {
       "en": "Track your 3D printing projects",
       "me": "Pratite tvoje projekte 3D štampe",

+ 8 - 0
src/pages/Orders.vue

@@ -125,6 +125,13 @@
               <OrderTracker :status="order.status" />
             </div>
 
+            <!-- Order Review -->
+            <OrderReviewForm 
+              v-if="['shipped', 'completed'].includes(order.status)" 
+              :order="order" 
+              @updated="fetchOrders" 
+            />
+
             <!-- Files -->
             <div v-if="order.files && order.files.length > 0" class="mt-8 pt-6 border-t border-border/50 relative z-10">
               <div class="flex items-center gap-2 mb-4">
@@ -206,6 +213,7 @@ import Header from "@/components/Header.vue";
 import Footer from "@/components/Footer.vue";
 import OrderChat from "@/components/OrderChat.vue";
 import OrderTracker from "@/components/OrderTracker.vue";
+import OrderReviewForm from "@/components/OrderReviewForm.vue";
 import { getMyOrders, API_BASE_URL, RESOURCES_BASE_URL } from "@/lib/api";
 import { useAuthStore } from "@/stores/auth";
 import { toast } from "vue-sonner";