Эх сурвалжийг харах

feat: added STL viewer, visual order tracker, confetti, and chat typing indicators

unknown 1 долоо хоног өмнө
parent
commit
5656b50192

+ 5 - 1
backend/routers/chat.py

@@ -64,6 +64,10 @@ async def ws_chat(websocket: WebSocket, order_id: int, token: str = Query(...)):
     await manager.connect(websocket, order_id, role)
     try:
         while True:
-            await websocket.receive_text()
+            data = await websocket.receive_text()
+            if data == "typing":
+                await manager.broadcast_to_order(order_id, {"type": "typing", "is_admin": role == 'admin'})
+            elif data == "stop_typing":
+                await manager.broadcast_to_order(order_id, {"type": "stop_typing", "is_admin": role == 'admin'})
     except WebSocketDisconnect:
         manager.disconnect(websocket, order_id)

+ 83 - 0
package-lock.json

@@ -9,8 +9,11 @@
       "version": "0.1.0",
       "dependencies": {
         "@tanstack/vue-query": "^5.25.0",
+        "@types/canvas-confetti": "^1.9.0",
+        "@types/three": "^0.183.1",
         "@vueuse/core": "^10.9.0",
         "@vueuse/motion": "^2.1.0",
+        "canvas-confetti": "^1.9.4",
         "class-variance-authority": "^0.7.0",
         "clsx": "^2.1.0",
         "date-fns": "^3.3.1",
@@ -19,6 +22,7 @@
         "pinia": "^2.1.7",
         "tailwind-merge": "^2.2.1",
         "tailwindcss-animate": "^1.0.7",
+        "three": "^0.183.2",
         "vue": "^3.4.21",
         "vue-i18n": "^9.13.1",
         "vue-router": "^4.3.0",
@@ -300,6 +304,12 @@
         "node": ">=20.19.0"
       }
     },
+    "node_modules/@dimforge/rapier3d-compat": {
+      "version": "0.12.0",
+      "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz",
+      "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==",
+      "license": "Apache-2.0"
+    },
     "node_modules/@emnapi/wasi-threads": {
       "version": "1.2.1",
       "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
@@ -1659,6 +1669,12 @@
         "url": "https://github.com/sponsors/tannerlinsley"
       }
     },
+    "node_modules/@tweenjs/tween.js": {
+      "version": "23.1.3",
+      "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
+      "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
+      "license": "MIT"
+    },
     "node_modules/@tybys/wasm-util": {
       "version": "0.10.1",
       "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz",
@@ -1670,6 +1686,12 @@
         "tslib": "^2.4.0"
       }
     },
+    "node_modules/@types/canvas-confetti": {
+      "version": "1.9.0",
+      "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz",
+      "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==",
+      "license": "MIT"
+    },
     "node_modules/@types/chai": {
       "version": "5.2.3",
       "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -1706,12 +1728,39 @@
         "undici-types": "~6.21.0"
       }
     },
+    "node_modules/@types/stats.js": {
+      "version": "0.17.4",
+      "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz",
+      "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==",
+      "license": "MIT"
+    },
+    "node_modules/@types/three": {
+      "version": "0.183.1",
+      "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz",
+      "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==",
+      "license": "MIT",
+      "dependencies": {
+        "@dimforge/rapier3d-compat": "~0.12.0",
+        "@tweenjs/tween.js": "~23.1.3",
+        "@types/stats.js": "*",
+        "@types/webxr": ">=0.5.17",
+        "@webgpu/types": "*",
+        "fflate": "~0.8.2",
+        "meshoptimizer": "~1.0.1"
+      }
+    },
     "node_modules/@types/web-bluetooth": {
       "version": "0.0.20",
       "resolved": "https://registry.npmjs.org/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
       "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
       "license": "MIT"
     },
+    "node_modules/@types/webxr": {
+      "version": "0.5.24",
+      "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz",
+      "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==",
+      "license": "MIT"
+    },
     "node_modules/@vitejs/plugin-vue": {
       "version": "5.2.4",
       "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -2062,6 +2111,12 @@
         "url": "https://github.com/sponsors/antfu"
       }
     },
+    "node_modules/@webgpu/types": {
+      "version": "0.1.69",
+      "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.69.tgz",
+      "integrity": "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ==",
+      "license": "BSD-3-Clause"
+    },
     "node_modules/abbrev": {
       "version": "2.0.0",
       "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz",
@@ -2388,6 +2443,16 @@
       ],
       "license": "CC-BY-4.0"
     },
+    "node_modules/canvas-confetti": {
+      "version": "1.9.4",
+      "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz",
+      "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==",
+      "license": "ISC",
+      "funding": {
+        "type": "donate",
+        "url": "https://www.paypal.me/kirilvatev"
+      }
+    },
     "node_modules/chai": {
       "version": "6.2.2",
       "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
@@ -2854,6 +2919,12 @@
         "reusify": "^1.0.4"
       }
     },
+    "node_modules/fflate": {
+      "version": "0.8.2",
+      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
+      "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
+      "license": "MIT"
+    },
     "node_modules/fill-range": {
       "version": "7.1.1",
       "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -3568,6 +3639,12 @@
         "node": ">= 8"
       }
     },
+    "node_modules/meshoptimizer": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz",
+      "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==",
+      "license": "MIT"
+    },
     "node_modules/micromatch": {
       "version": "4.0.8",
       "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -4637,6 +4714,12 @@
         "node": ">=0.8"
       }
     },
+    "node_modules/three": {
+      "version": "0.183.2",
+      "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz",
+      "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==",
+      "license": "MIT"
+    },
     "node_modules/tinybench": {
       "version": "2.9.0",
       "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",

+ 4 - 0
package.json

@@ -14,8 +14,11 @@
   },
   "dependencies": {
     "@tanstack/vue-query": "^5.25.0",
+    "@types/canvas-confetti": "^1.9.0",
+    "@types/three": "^0.183.1",
     "@vueuse/core": "^10.9.0",
     "@vueuse/motion": "^2.1.0",
+    "canvas-confetti": "^1.9.4",
     "class-variance-authority": "^0.7.0",
     "clsx": "^2.1.0",
     "date-fns": "^3.3.1",
@@ -24,6 +27,7 @@
     "pinia": "^2.1.7",
     "tailwind-merge": "^2.2.1",
     "tailwindcss-animate": "^1.0.7",
+    "three": "^0.183.2",
     "vue": "^3.4.21",
     "vue-i18n": "^9.13.1",
     "vue-router": "^4.3.0",

+ 4 - 2
src/components/ModelUploadSection.vue

@@ -133,8 +133,9 @@
           <h3 class="font-display text-lg font-semibold mb-4">{{ t("upload.uploadedFiles") }} ({{ files.length }})</h3>
           <div v-for="file in files" :key="file.id"
             class="flex items-center gap-4 p-4 bg-gradient-card rounded-xl border border-border/50 hover:border-primary/30 transition-colors">
-            <div class="w-12 h-12 bg-primary/10 rounded-lg flex items-center justify-center">
-              <FileBox class="w-6 h-6 text-primary" />
+            <div class="w-16 h-16 bg-primary/5 rounded-xl border border-black/[0.05] flex items-center justify-center relative overflow-hidden shrink-0">
+              <StlViewer v-if="file.name.toLowerCase().endsWith('.stl')" :file="file.file" />
+              <FileBox v-else class="w-8 h-8 text-primary opacity-50" />
             </div>
             <div class="flex-1 min-w-0">
               <div class="flex items-center gap-2">
@@ -217,6 +218,7 @@ import { useI18n } from "vue-i18n";
 import { toast } from "vue-sonner";
 import { Upload, FileBox, X, Check, Link as LinkIcon, MapPin, User, Phone, Mail, Loader2, ShieldCheck, Hash, FileText } from "lucide-vue-next";
 import Button from "./ui/button.vue";
+import StlViewer from "@/components/StlViewer.vue";
 import { submitOrder, getCurrentUser, getMaterials, getPriceEstimate, uploadFilesToServer } from "@/lib/api";
 
 interface UploadedFile { id: string; dbId?: number; name: string; size: number; type: string; file: File; quantity: number; basePrice?: number; isUploading?: boolean; printTime?: string; filamentG?: number; }

+ 34 - 1
src/components/OrderChat.vue

@@ -49,6 +49,15 @@
           </div>
         </div>
       </template>
+
+      <!-- Typing Indicator -->
+      <div v-if="isOtherPartyTyping" class="flex justify-start mb-2 animate-in fade-in zoom-in duration-300">
+        <div class="bg-secondary/60 text-muted-foreground px-4 py-3 rounded-2xl rounded-bl-md flex items-center gap-1.5 shadow-sm">
+          <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 0ms"></span>
+          <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 150ms"></span>
+          <span class="w-1.5 h-1.5 bg-foreground/40 rounded-full animate-bounce" style="animation-delay: 300ms"></span>
+        </div>
+      </div>
     </div>
 
     <!-- Input area -->
@@ -60,7 +69,7 @@
           rows="1"
           class="flex-1 resize-none bg-background/80 border border-border/50 rounded-xl px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/40 transition-all placeholder:text-muted-foreground/50 max-h-[80px] overflow-y-auto"
           @keydown.enter.exact.prevent="handleSend"
-          @input="autoResize"
+          @input="onTextareaInput($event); autoResize($event)"
           ref="textareaRef"
         />
         <button
@@ -102,6 +111,8 @@ const wsConnected = ref(false);
 const messagesContainer = ref<HTMLElement | null>(null);
 const textareaRef = ref<HTMLTextAreaElement | null>(null);
 const otherPartyOnline = ref(false);
+const isOtherPartyTyping = ref(false);
+let typingTimeout: ReturnType<typeof setTimeout> | null = null;
 const authStore = useAuthStore();
 
 function playDing() {
@@ -151,6 +162,16 @@ function connectWebSocket() {
         return;
       }
       
+      if (msg.type === "typing" || msg.type === "stop_typing") {
+        const myRole = authStore.user?.role === 'admin' ? 'admin' : 'user';
+        const isFromOther = (msg.is_admin && myRole === 'user') || (!msg.is_admin && myRole === 'admin');
+        if (isFromOther) {
+          isOtherPartyTyping.value = (msg.type === "typing");
+          scrollToBottom();
+        }
+        return;
+      }
+      
       // Avoid duplicates (we already optimistically added our own message)
       if (!messages.value.find(m => m.id === msg.id)) {
         messages.value.push(msg);
@@ -239,6 +260,18 @@ function autoResize(e: Event) {
   el.style.height = Math.min(el.scrollHeight, 80) + "px";
 }
 
+function onTextareaInput(e: Event) {
+  if (wsConnected.value && ws) {
+    ws.send("typing");
+    if (typingTimeout) clearTimeout(typingTimeout);
+    typingTimeout = setTimeout(() => {
+      if (ws && ws.readyState === WebSocket.OPEN) {
+        ws.send("stop_typing");
+      }
+    }, 2000);
+  }
+}
+
 function formatTime(dt: string) {
   if (!dt) return "";
   const d = new Date(dt);

+ 84 - 0
src/components/OrderTracker.vue

@@ -0,0 +1,84 @@
+<template>
+  <div class="w-full py-4">
+    <div class="flex items-center justify-between relative">
+      <!-- Background line -->
+      <div class="absolute left-0 top-1/2 -translate-y-1/2 w-full h-1 bg-border/50 rounded-full z-0"></div>
+      
+      <!-- Colored line up to current step -->
+      <div 
+        class="absolute left-0 top-1/2 -translate-y-1/2 h-1 bg-primary rounded-full z-0 transition-all duration-1000 ease-in-out"
+        :style="{ width: progressWidth }"
+      ></div>
+
+      <!-- Steps -->
+      <div 
+        v-for="(step, i) in steps" 
+        :key="step.id"
+        class="relative z-10 flex flex-col items-center gap-2"
+        :class="[i <= currentIndex ? 'opacity-100' : 'opacity-40 grayscale']"
+      >
+        <div 
+          class="w-8 h-8 sm:w-10 sm:h-10 rounded-full flex items-center justify-center transition-all duration-500 shadow-sm"
+          :class="[
+            i < currentIndex ? 'bg-primary text-primary-foreground' : 
+            i === currentIndex ? 'bg-primary text-primary-foreground ring-4 ring-primary/20 animate-pulse' : 
+            'bg-card border-2 border-border text-muted-foreground'
+          ]"
+        >
+          <component :is="step.icon" class="w-4 h-4 sm:w-5 sm:h-5" />
+        </div>
+        <span class="text-[10px] sm:text-xs font-bold whitespace-nowrap hidden sm:block" :class="i <= currentIndex ? 'text-foreground' : 'text-muted-foreground'">
+          {{ step.label }}
+        </span>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue';
+import { Clock, ShieldCheck, Printer, Truck, PackageCheck, XCircle } from 'lucide-vue-next';
+import { useI18n } from 'vue-i18n';
+
+const props = defineProps<{
+  status: string;
+}>();
+
+const { t } = useI18n();
+
+const steps = computed(() => {
+  if (props.status === 'cancelled') {
+    return [
+      { id: 'pending', icon: Clock, label: 'Pending' },
+      { id: 'cancelled', icon: XCircle, label: 'Cancelled' }
+    ];
+  }
+  return [
+    { id: 'pending', icon: Clock, label: 'Pending' },
+    { id: 'processing', icon: ShieldCheck, label: 'Approved' },
+    { id: 'printing', icon: Printer, label: 'Printing' },
+    { id: 'shipped', icon: Truck, label: 'Shipped' },
+    { id: 'completed', icon: PackageCheck, label: 'Delivered' }
+  ];
+});
+
+const currentIndex = computed(() => {
+  // If printing doesn't exist in actual DB, we map processing->processing
+  // but if the status is "printing", we'd use it.
+  // Standard statuses: pending, processing, shipped, completed
+  const map: Record<string, number> = {
+    'pending': 0,
+    'processing': 1,
+    'printing': 2,
+    'shipped': 3,
+    'completed': 4,
+    'cancelled': 1
+  };
+  return map[props.status] ?? 0;
+});
+
+const progressWidth = computed(() => {
+  if (steps.value.length === 0) return '0%';
+  return `${(currentIndex.value / (steps.value.length - 1)) * 100}%`;
+});
+</script>

+ 99 - 0
src/components/StlViewer.vue

@@ -0,0 +1,99 @@
+<template>
+  <div ref="container" class="w-full h-full cursor-move" title="Drag to rotate" />
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted, onUnmounted } from "vue";
+import * as THREE from "three";
+import { STLLoader } from "three/examples/jsm/loaders/STLLoader";
+import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
+
+const props = defineProps<{
+  file: File;
+}>();
+
+const container = ref<HTMLElement | null>(null);
+let animationId = 0;
+
+onMounted(() => {
+  if (!container.value) return;
+
+  const width = container.value.clientWidth || 80;
+  const height = container.value.clientHeight || 80;
+
+  const scene = new THREE.Scene();
+
+  const camera = new THREE.PerspectiveCamera(45, width / height, 0.1, 2000);
+  camera.position.set(0, 0, 150);
+
+  const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
+  renderer.setSize(width, height);
+  renderer.setClearColor(0x000000, 0); // transparent
+  container.value.appendChild(renderer.domElement);
+
+  const controls = new OrbitControls(camera, renderer.domElement);
+  controls.enableDamping = true;
+  controls.autoRotate = true;
+  controls.autoRotateSpeed = 3.0;
+
+  // Add lights
+  const hemiLight = new THREE.HemisphereLight(0xffffff, 0x444444, 1);
+  hemiLight.position.set(0, 200, 0);
+  scene.add(hemiLight);
+
+  const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
+  dirLight.position.set(100, 200, 100);
+  scene.add(dirLight);
+
+  const loader = new STLLoader();
+  const objectUrl = URL.createObjectURL(props.file);
+  
+  loader.load(objectUrl, (geometry) => {
+    geometry.center();
+    geometry.computeVertexNormals();
+
+    const material = new THREE.MeshPhongMaterial({ 
+      color: 0x3b82f6,
+      specular: 0x111111, 
+      shininess: 100 
+    });
+    
+    const mesh = new THREE.Mesh(geometry, material);
+    
+    geometry.computeBoundingSphere();
+    const radius = geometry.boundingSphere?.radius || 50;
+    const scale = 50 / radius;
+    mesh.scale.set(scale, scale, scale);
+
+    scene.add(mesh);
+    URL.revokeObjectURL(objectUrl);
+  });
+
+  const animate = () => {
+    animationId = requestAnimationFrame(animate);
+    controls.update();
+    renderer.render(scene, camera);
+  };
+  animate();
+
+  const handleResize = () => {
+    if (!container.value) return;
+    const w = container.value.clientWidth;
+    const h = container.value.clientHeight;
+    camera.aspect = w / h;
+    camera.updateProjectionMatrix();
+    renderer.setSize(w, h);
+  };
+
+  window.addEventListener("resize", handleResize);
+
+  onUnmounted(() => {
+    cancelAnimationFrame(animationId);
+    window.removeEventListener("resize", handleResize);
+    if (renderer.domElement.parentNode) {
+      renderer.domElement.parentNode.removeChild(renderer.domElement);
+    }
+    renderer.dispose();
+  });
+});
+</script>

+ 27 - 2
src/pages/Orders.vue

@@ -88,7 +88,12 @@
                 <FileText class="w-3.5 h-3.5 text-primary" />
                 <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">My Notes</span>
               </div>
-            <p class="text-xs text-muted-foreground italic leading-relaxed">"{{ order.notes }}"</p>
+              <p class="text-xs text-muted-foreground italic leading-relaxed">"{{ order.notes }}"</p>
+            </div>
+
+            <!-- Pizza Tracker -->
+            <div class="mt-8 pt-6 border-t border-border/50 relative z-10 px-2 sm:px-8">
+              <OrderTracker :status="order.status" />
             </div>
 
             <!-- Files -->
@@ -160,7 +165,9 @@ import Button from "@/components/ui/button.vue";
 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 { getMyOrders } from "@/lib/api";
+import confetti from "canvas-confetti";
 
 const { t } = useI18n();
 const router = useRouter();
@@ -201,7 +208,25 @@ function formatDate(dt: string) {
 
 onMounted(async () => {
   if (!localStorage.getItem("token")) { router.push("/auth"); return; }
-  try { orders.value = await getMyOrders(); }
+  try { 
+    orders.value = await getMyOrders(); 
+    
+    // Check if there's a recently completed order to celebrate
+    const hasRecentCompleted = orders.value.some(o => 
+      o.status === "completed" && 
+      (new Date().getTime() - new Date(o.created_at).getTime() < 7 * 24 * 60 * 60 * 1000)
+    );
+    if (hasRecentCompleted) {
+      setTimeout(() => {
+        confetti({
+          particleCount: 100,
+          spread: 70,
+          origin: { y: 0.6 },
+          colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']
+        });
+      }, 500);
+    }
+  }
   catch (e) { console.error("Failed to fetch orders:", e); }
   finally { isLoading.value = false; }
 });