Orders.vue 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358
  1. <template>
  2. <div class="min-h-screen bg-background flex flex-col">
  3. <Header />
  4. <main class="flex-grow pt-24 pb-20">
  5. <div class="container mx-auto px-4 max-w-5xl">
  6. <div class="mb-10">
  7. <RouterLink to="/" class="inline-flex items-center text-sm text-muted-foreground hover:text-primary transition-colors mb-6 group">
  8. <ArrowLeft class="w-4 h-4 mr-2 group-hover:-translate-x-1 transition-transform" />{{ t("auth.back") }}
  9. </RouterLink>
  10. <div class="flex flex-col sm:flex-row sm:items-end justify-between gap-6">
  11. <div>
  12. <h1 class="font-display text-4xl font-bold bg-clip-text text-transparent bg-gradient-to-r from-foreground to-foreground/60">
  13. {{ t("nav.myOrders") }}
  14. </h1>
  15. <p class="text-muted-foreground mt-2">{{ t("orders.titleSubtitle") }}</p>
  16. </div>
  17. <Button @click="showNewOrderForm = !showNewOrderForm" :variant="showNewOrderForm ? 'outline' : 'hero'" class="px-6 py-2.5 rounded-xl font-bold shadow-lg flex items-center gap-2 group shrink-0">
  18. <component :is="showNewOrderForm ? X : Plus" class="w-4 h-4 transition-transform group-hover:scale-110" />
  19. {{ showNewOrderForm ? t("common.cancel") : t("orders.newOrder") }}
  20. </Button>
  21. </div>
  22. </div>
  23. <div v-if="showNewOrderForm"
  24. v-motion :initial="{ opacity: 0, height: 0 }" :enter="{ opacity: 1, height: 'auto' }"
  25. class="mb-12 bg-card/40 backdrop-blur-xl border border-primary/20 rounded-3xl p-6 sm:p-10 relative overflow-hidden">
  26. <div class="absolute top-0 right-0 w-64 h-64 bg-primary/5 rounded-full blur-3xl -mr-32 -mt-32" />
  27. <ModelUploadSection embedded @success="onOrderSuccess" />
  28. </div>
  29. <div v-if="isLoading" class="flex flex-col items-center justify-center py-20 gap-4">
  30. <Loader2 class="w-10 h-10 animate-spin text-primary" />
  31. <p class="text-muted-foreground animate-pulse">{{ t("orders.loading") }}</p>
  32. </div>
  33. <div v-else-if="orders.length === 0"
  34. v-motion :initial="{ opacity: 0, y: 20 }" :enter="{ opacity: 1, y: 0 }"
  35. class="bg-card/40 backdrop-blur-xl border border-border/50 rounded-3xl p-20 text-center">
  36. <div class="w-20 h-20 bg-primary/10 rounded-full flex items-center justify-center mx-auto mb-6">
  37. <Package class="w-10 h-10 text-primary opacity-60" />
  38. </div>
  39. <h2 class="text-2xl font-bold mb-2">{{ t("orders.noOrders") }}</h2>
  40. <p class="text-muted-foreground mb-8 max-w-sm mx-auto">{{ t("orders.startProjectDesc") }}</p>
  41. </div>
  42. <div v-else class="space-y-4">
  43. <div
  44. v-for="(order, idx) in orders"
  45. :key="order.id"
  46. v-motion
  47. :initial="{ opacity: 0, x: -20 }"
  48. :enter="{ opacity: 1, x: 0, transition: { delay: idx * 50 } }"
  49. class="bg-card/40 backdrop-blur-xl border border-border/50 rounded-2xl p-6 hover:border-primary/30 transition-all group overflow-hidden relative"
  50. >
  51. <div class="absolute top-0 right-0 w-32 h-32 bg-primary/5 rounded-full blur-3xl -mr-16 -mt-16 group-hover:bg-primary/10 transition-colors" />
  52. <div class="flex flex-col md:flex-row md:items-center justify-between gap-6 relative z-10">
  53. <div class="flex items-center gap-5">
  54. <div class="w-12 h-12 bg-background/80 rounded-xl flex items-center justify-center border border-border/50">
  55. <Package class="w-6 h-6 text-primary" />
  56. </div>
  57. <div>
  58. <h3 class="font-bold text-lg leading-none mb-1">{{ t("common.orderId", { id: order.id }) }}</h3>
  59. <p class="text-xs text-muted-foreground">{{ formatDate(order.created_at) }}</p>
  60. </div>
  61. </div>
  62. <div class="flex flex-wrap items-center gap-4 md:gap-8">
  63. <div class="flex flex-col">
  64. <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.quantity") }}</span>
  65. <div class="flex items-center gap-1.5 px-2.5 py-1 bg-primary/10 rounded-lg text-primary font-bold">
  66. <Hash class="w-3 h-3" /><span class="text-sm">{{ order.quantity || 1 }}</span>
  67. </div>
  68. </div>
  69. <div class="flex flex-col">
  70. <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.status") }}</span>
  71. <StatusBadge :status="order.status" />
  72. </div>
  73. <div class="flex flex-col">
  74. <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.estimate") }}</span>
  75. <span class="font-display font-bold text-lg">{{ order.total_price ? `${order.total_price} €` : t("common.pending") }}</span>
  76. </div>
  77. <div class="flex flex-col border-l border-border/50 pl-4">
  78. <span class="text-[10px] uppercase tracking-wider text-muted-foreground font-bold mb-1">{{ t("orders.labels.materialColor") }}</span>
  79. <div class="flex flex-col">
  80. <span class="text-xs font-bold uppercase text-primary">{{ order.material_name || "PLA" }}</span>
  81. <div class="flex items-center gap-1.5 mt-0.5">
  82. <div class="w-2.5 h-2.5 rounded-full border border-border/50" :style="order.color_name ? { backgroundColor: order.color_name.toLowerCase() } : { backgroundColor: '#ccc' }"></div>
  83. <span class="text-[9px] text-muted-foreground font-medium">{{ order.color_name || t("common.default") }}</span>
  84. </div>
  85. </div>
  86. </div>
  87. <div class="flex items-center gap-2">
  88. <a v-if="order.model_link && !order.model_link.startsWith('javascript:')"
  89. :href="order.model_link" target="_blank" rel="noopener noreferrer"
  90. class="p-2 hover:bg-secondary rounded-lg transition-colors">
  91. <ExternalLink class="w-4 h-4" />
  92. </a>
  93. <!-- Removed obsolete routing link -->
  94. <Button variant="ghost" size="sm" @click="toggleChat(order.id)" class="relative">
  95. <span v-if="order.unread_count > 0" class="absolute -top-2 -right-2 bg-red-500 text-white text-[10px] w-5 h-5 flex items-center justify-center rounded-full shadow-sm animate-pulse z-10">{{ order.unread_count }}</span>
  96. <MessageCircle class="w-4 h-4" :class="openChatId === order.id ? 'text-primary' : ''" />
  97. <span class="ml-1 text-xs">{{ t("chat.open") }}</span>
  98. </Button>
  99. </div>
  100. </div>
  101. </div>
  102. <div v-if="order.notes" class="mt-8 p-4 bg-primary/5 border border-primary/10 rounded-2xl relative z-10">
  103. <div class="flex items-center gap-2 mb-2">
  104. <FileText class="w-3.5 h-3.5 text-primary" />
  105. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.myNotes") }}</span>
  106. </div>
  107. <p class="text-xs text-muted-foreground italic leading-relaxed">"{{ order.notes }}"</p>
  108. </div>
  109. <!-- Items Specification -->
  110. <div v-if="order.items && order.items.length > 0" class="mt-8 p-4 bg-secondary/20 border border-border/50 rounded-2xl relative z-10">
  111. <div class="flex items-center gap-2 mb-4">
  112. <Hash class="w-3.5 h-3.5 text-primary" />
  113. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.specification") }}</span>
  114. </div>
  115. <div class="space-y-2">
  116. <div v-for="item in order.items" :key="item.id" class="flex justify-between items-center text-xs">
  117. <span class="text-muted-foreground">{{ item.description }} <span v-if="item.quantity > 1" class="text-primary font-bold">x{{ item.quantity }}</span></span>
  118. <span class="font-bold">{{ item.total_price }} €</span>
  119. </div>
  120. <div class="pt-2 mt-2 border-t border-border/50 flex justify-between items-center font-bold">
  121. <span class="text-[10px] uppercase tracking-wider">{{ t("orders.labels.total") }}</span>
  122. <span class="text-lg text-primary">{{ order.total_price }} €</span>
  123. </div>
  124. </div>
  125. </div>
  126. <!-- Pizza Tracker -->
  127. <div class="mt-8 pt-6 border-t border-border/50 relative z-10 px-2 sm:px-8">
  128. <OrderTracker :status="order.status" />
  129. </div>
  130. <!-- Order Review -->
  131. <OrderReviewForm
  132. v-if="['shipped', 'completed'].includes(order.status)"
  133. :order="order"
  134. @updated="fetchOrders"
  135. />
  136. <!-- Files -->
  137. <div v-if="order.files && order.files.length > 0" class="mt-8 pt-6 border-t border-border/50 relative z-10">
  138. <div class="flex items-center gap-2 mb-4">
  139. <FileBox class="w-3.5 h-3.5 text-primary" />
  140. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.projectFiles") }}</span>
  141. </div>
  142. <div class="grid grid-cols-2 sm:grid-cols-3 gap-4">
  143. <template v-for="file in order.files" :key="file.id">
  144. <div v-if="file.id"
  145. class="bg-background/40 border border-border/50 rounded-2xl overflow-hidden group/file hover:border-primary/30 transition-all flex flex-col h-full">
  146. <div class="aspect-square bg-muted/20 flex items-center justify-center p-2 relative overflow-hidden">
  147. <img v-if="file.preview_path" :src="`${RESOURCES_BASE_URL}/${file.preview_path}`" class="w-full h-full object-contain" />
  148. <FileBox v-else class="w-8 h-8 text-muted-foreground/20" />
  149. <div class="absolute inset-0 bg-primary/20 opacity-0 group-hover/file:opacity-100 transition-opacity flex items-center justify-center">
  150. <a :href="`${RESOURCES_BASE_URL}/${file.file_path}`" target="_blank" class="bg-card w-8 h-8 rounded-full flex items-center justify-center shadow-lg"><Download class="w-4 h-4 text-primary" /></a>
  151. </div>
  152. </div>
  153. <div class="p-3">
  154. <p class="text-[10px] font-bold truncate">{{ file.filename }}</p>
  155. <div class="flex items-center gap-2 mt-1">
  156. <span v-if="file.quantity > 1" class="text-[8px] font-bold text-primary">x{{ file.quantity }}</span>
  157. </div>
  158. <div v-if="file.print_time || file.filament_g || file.dimensions" class="flex flex-col gap-0.5 mt-2 pt-2 border-t border-border/10">
  159. <span v-if="file.dimensions" class="text-[8px] text-primary/80">📏 {{ file.dimensions }}</span>
  160. <span v-if="file.print_time" class="text-[8px] text-primary/80">⏱️ {{ file.print_time }}</span>
  161. <span v-if="file.filament_g" class="text-[8px] text-primary/80">⚖️ {{ file.filament_g.toFixed(1) }}g</span>
  162. </div>
  163. </div>
  164. </div>
  165. </template>
  166. </div>
  167. </div>
  168. <div v-if="order.photos && order.photos.length > 0" class="mt-8 pt-6 border-t border-border/50 relative z-10">
  169. <div class="flex items-center gap-2 mb-4">
  170. <ImageIcon class="w-3.5 h-3.5 text-primary" />
  171. <span class="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">{{ t("orders.labels.progressReport") }}</span>
  172. </div>
  173. <div class="flex flex-wrap gap-3">
  174. <div v-for="photo in order.photos" :key="photo.id"
  175. class="group/photo relative w-20 h-20 rounded-xl overflow-hidden border border-border/50 bg-background/50">
  176. <img :src="`${RESOURCES_BASE_URL}/${photo.file_path}`" class="w-full h-full object-cover" alt="Print Progress" />
  177. <a :href="`${RESOURCES_BASE_URL}/${photo.file_path}`" target="_blank"
  178. class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/photo:opacity-100 transition-opacity">
  179. <ExternalLink class="w-4 h-4 text-white" />
  180. </a>
  181. </div>
  182. </div>
  183. </div>
  184. <div v-if="openChatId === order.id" :id="`chat-${order.id}`" class="mt-6 pt-4 border-t border-border/50 relative z-10">
  185. <OrderChat :orderId="order.id" compact closable @close="openChatId = null" />
  186. </div>
  187. </div>
  188. </div>
  189. <!-- Pagination -->
  190. <div v-if="totalOrders > pageSize" class="flex flex-wrap items-center justify-center gap-2 mt-12 mb-8">
  191. <button v-for="p in Math.ceil(totalOrders / pageSize)" :key="p"
  192. @click="currentPage = p"
  193. v-motion :initial="{ opacity: 0, scale: 0.9 }" :enter="{ opacity: 1, scale: 1, transition: { delay: p * 50 } }"
  194. :class="['w-10 h-10 rounded-xl font-bold transition-all border',
  195. currentPage === p ? 'bg-primary text-primary-foreground border-primary shadow-glow' : 'bg-card/40 border-border/50 text-muted-foreground hover:border-primary/30']">
  196. {{ p }}
  197. </button>
  198. </div>
  199. </div>
  200. </main>
  201. <Footer />
  202. </div>
  203. </template>
  204. <script setup lang="ts">
  205. // Orders page - user order history, status tracking and chat
  206. import { ref, onMounted, defineComponent, h, watch, nextTick, computed } from "vue";
  207. import { RouterLink, useRouter, useRoute } from "vue-router";
  208. import { useI18n } from "vue-i18n";
  209. import { Package, Clock, ShieldCheck, Truck, XCircle, ArrowLeft, Loader2, ExternalLink, Hash, FileText, Image as ImageIcon, MessageCircle, FileBox, Download, Plus, X } from "lucide-vue-next";
  210. import Button from "@/components/ui/button.vue";
  211. import Header from "@/components/Header.vue";
  212. import Footer from "@/components/Footer.vue";
  213. import OrderChat from "@/components/OrderChat.vue";
  214. import OrderTracker from "@/components/OrderTracker.vue";
  215. import OrderReviewForm from "@/components/OrderReviewForm.vue";
  216. import ModelUploadSection from "@/components/ModelUploadSection.vue";
  217. import { getMyOrders, API_BASE_URL, RESOURCES_BASE_URL } from "@/lib/api";
  218. import { useAuthStore } from "@/stores/auth";
  219. import { toast } from "vue-sonner";
  220. import { useHead } from "@unhead/vue";
  221. const { t } = useI18n();
  222. useHead({
  223. title: computed(() => t('nav.myOrders')),
  224. meta: [
  225. { name: 'robots', content: 'noindex, nofollow' }
  226. ]
  227. });
  228. const router = useRouter();
  229. const route = useRoute();
  230. const authStore = useAuthStore();
  231. const orders = ref<any[]>([]);
  232. const totalOrders = ref(0);
  233. const currentPage = ref(1);
  234. const pageSize = 10;
  235. const isLoading = ref(true);
  236. const showNewOrderForm = ref(false);
  237. const openChatId = ref<number | null>(route.query.chat ? parseInt(route.query.chat.toString()) : null);
  238. async function toggleChat(orderId: number) {
  239. const isOpening = openChatId.value !== orderId;
  240. openChatId.value = isOpening ? orderId : null;
  241. if (isOpening) {
  242. // Clear unread count locally for instant UI feedback
  243. const order = orders.value.find(o => o.id === orderId);
  244. if (order) order.unread_count = 0;
  245. authStore.refreshUnreadCount();
  246. await nextTick();
  247. const el = document.getElementById(`chat-${orderId}`);
  248. if (el) {
  249. el.scrollIntoView({ behavior: 'smooth', block: 'center' });
  250. }
  251. }
  252. }
  253. watch(openChatId, (newId) => {
  254. if (route.query.chat?.toString() !== newId?.toString()) {
  255. router.replace({ query: { ...route.query, chat: newId || undefined } });
  256. }
  257. });
  258. function onOrderSuccess() {
  259. showNewOrderForm.value = false;
  260. fetchOrders();
  261. }
  262. // StatusBadge component defined inline
  263. const StatusBadge = defineComponent({
  264. props: { status: String },
  265. setup(props) {
  266. const styles: Record<string, string> = {
  267. pending: "bg-amber-500/10 text-amber-500 border-amber-500/20",
  268. processing: "bg-blue-500/10 text-blue-500 border-blue-500/20",
  269. shipped: "bg-indigo-500/10 text-indigo-500 border-indigo-500/20",
  270. completed: "bg-emerald-500/10 text-emerald-500 border-emerald-500/20",
  271. cancelled: "bg-destructive/10 text-destructive border-destructive/20",
  272. };
  273. const icons: Record<string, any> = { pending: Clock, processing: ShieldCheck, shipped: Truck, completed: Package, cancelled: XCircle };
  274. return () => {
  275. const s = props.status ?? "pending";
  276. const Icon = icons[s] || Clock;
  277. return h("span", { class: `inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-semibold border ${styles[s] ?? styles.pending}` }, [
  278. h(Icon, { class: "w-3.5 h-3.5" }),
  279. h("span", null, t("statuses." + s)),
  280. ]);
  281. };
  282. },
  283. });
  284. function formatDate(dt: string) {
  285. return new Date(dt).toLocaleDateString(undefined, { year: "numeric", month: "long", day: "numeric" });
  286. }
  287. async function fetchOrders() {
  288. isLoading.value = true;
  289. try {
  290. const res = await getMyOrders(currentPage.value, pageSize);
  291. orders.value = res.orders;
  292. totalOrders.value = res.total;
  293. }
  294. catch (e) { console.error("Failed to fetch orders:", e); }
  295. finally { isLoading.value = false; }
  296. }
  297. onMounted(async () => {
  298. if (!localStorage.getItem("token")) { router.push("/auth"); return; }
  299. await fetchOrders();
  300. window.addEventListener("radionica:order_updated", handleRemoteUpdate);
  301. });
  302. import { onUnmounted } from "vue";
  303. onUnmounted(() => {
  304. window.removeEventListener("radionica:order_updated", handleRemoteUpdate);
  305. });
  306. async function handleRemoteUpdate(e: any) {
  307. const orderId = e.detail?.order_id;
  308. await fetchOrders();
  309. toast.success(t("chat.new_status", { id: orderId || "" }), {
  310. description: "Order data has been updated by administrator.",
  311. icon: h(Package, { class: "w-4 h-4 text-emerald-500" })
  312. });
  313. }
  314. watch(currentPage, () => {
  315. fetchOrders();
  316. window.scrollTo({ top: 0, behavior: 'smooth' });
  317. });
  318. watch(() => authStore.unreadMessagesCount, async (newVal, oldVal) => {
  319. if (newVal !== oldVal) {
  320. try {
  321. await fetchOrders();
  322. } catch (e) {
  323. console.error("Failed to auto-refresh orders:", e);
  324. }
  325. }
  326. });
  327. </script>