ModelUploadSection.vue 31 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617
  1. <template>
  2. <section :id="embedded ? undefined : 'upload'" :class="[embedded ? '' : 'py-12 sm:py-20 bg-white relative']">
  3. <div v-if="!embedded" class="absolute inset-0 bg-gradient-glow opacity-50" />
  4. <div :class="[embedded ? '' : 'container mx-auto px-4 relative z-10']">
  5. <div v-if="!embedded" class="text-center mb-10 sm:mb-12">
  6. <span class="text-primary font-display text-xs font-bold tracking-[0.2em] uppercase">{{ t("upload.badge") }}</span>
  7. <h2 class="font-display text-3xl sm:text-4xl lg:text-5xl font-extrabold mt-4 mb-4 tracking-tight">
  8. {{ t("upload.title") }} <span class="text-primary">{{ t("upload.titleGradient") }}</span>
  9. </h2>
  10. <p class="text-foreground/60 max-w-xl mx-auto text-base font-medium">{{ t("upload.description") }}</p>
  11. </div>
  12. <div :class="[embedded ? '' : 'max-w-3xl mx-auto space-y-6 sm:space-y-8']">
  13. <!-- Contact -->
  14. <div class="grid sm:grid-cols-2 gap-6">
  15. <div class="space-y-1.5">
  16. <label for="upload-first-name" class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  17. {{ t("upload.firstName") }} *
  18. </label>
  19. <input id="upload-first-name" v-model="firstName" type="text" required :placeholder="t('upload.firstName')"
  20. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  21. </div>
  22. <div class="space-y-1.5">
  23. <label for="upload-last-name" class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  24. {{ t("upload.lastName") }} *
  25. </label>
  26. <input id="upload-last-name" v-model="lastName" type="text" required :placeholder="t('upload.lastName')"
  27. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  28. </div>
  29. </div>
  30. <div class="grid sm:grid-cols-2 gap-6">
  31. <div class="space-y-1.5">
  32. <label for="upload-email" class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  33. {{ t("upload.email") }} *
  34. </label>
  35. <input id="upload-email" v-model="email" type="email" required :placeholder="t('upload.email')"
  36. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  37. </div>
  38. <div class="space-y-1.5">
  39. <label for="upload-phone" class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  40. {{ t("upload.phone") }} *
  41. </label>
  42. <input id="upload-phone" v-model="phone" type="tel" required :placeholder="t('upload.phone')"
  43. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  44. </div>
  45. </div>
  46. <div class="space-y-1.5">
  47. <label for="upload-address" class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  48. {{ t("upload.shippingAddress") }} *
  49. </label>
  50. <textarea id="upload-address" v-model="address" required :placeholder="t('upload.addressPlaceholder')" rows="2"
  51. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-3 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium resize-none" />
  52. </div>
  53. <!-- Account Type Toggle -->
  54. <div class="space-y-4 p-5 bg-secondary/30 rounded-[2rem] border border-black/[0.02]">
  55. <h3 class="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground/40 flex items-center gap-2 px-1">
  56. {{ t("auth.fields.accountType") }}
  57. </h3>
  58. <div class="flex p-1 bg-white border border-black/[0.03] rounded-2xl gap-1">
  59. <button type="button" @click="isCompany = false"
  60. :class="['flex-1 px-4 py-2.5 rounded-xl text-xs font-bold transition-all', !isCompany ? 'bg-primary text-primary-foreground shadow-lg' : 'text-foreground/40 hover:bg-black/5']">
  61. {{ t("auth.fields.individual") }}
  62. </button>
  63. <button type="button" @click="isCompany = true"
  64. :class="['flex-1 px-4 py-2.5 rounded-xl text-xs font-bold transition-all', isCompany ? 'bg-primary text-primary-foreground shadow-lg' : 'text-foreground/40 hover:bg-black/5']">
  65. {{ t("auth.fields.company") }}
  66. </button>
  67. </div>
  68. </div>
  69. <!-- Company Fields -->
  70. <Transition enter-active-class="transition duration-300" enter-from-class="opacity-0 -translate-y-4" enter-to-class="opacity-100 translate-y-0">
  71. <div v-if="isCompany" class="space-y-6 sm:space-y-8 p-6 bg-secondary/20 rounded-[2.5rem] border border-primary/10">
  72. <div class="grid sm:grid-cols-2 gap-6">
  73. <div class="space-y-1.5">
  74. <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  75. {{ t("auth.fields.companyName") }} *
  76. </label>
  77. <input v-model="companyName" type="text" :required="isCompany" :placeholder="t('auth.fields.companyName')"
  78. class="w-full bg-white border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  79. </div>
  80. <div class="space-y-1.5">
  81. <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  82. {{ t("auth.fields.companyPIB") }} *
  83. </label>
  84. <input v-model="companyPib" type="text" :required="isCompany" placeholder="PIB / Tax ID"
  85. class="w-full bg-white border border-black/[0.03] rounded-2xl px-4 py-2.5 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  86. </div>
  87. </div>
  88. <div class="space-y-1.5">
  89. <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  90. {{ t("auth.fields.companyAddress") }}
  91. </label>
  92. <textarea v-model="companyAddress" rows="2" :placeholder="t('auth.fields.companyAddress')"
  93. class="w-full bg-white border border-black/[0.03] rounded-2xl px-4 py-3 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium resize-none" />
  94. </div>
  95. </div>
  96. </Transition>
  97. <!-- Material Selection -->
  98. <div class="space-y-4 p-5 bg-secondary/30 rounded-[2rem] border border-black/[0.02]">
  99. <h3 class="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground/40 flex items-center gap-2 px-1">
  100. {{ t("upload.selectMaterial") }}
  101. </h3>
  102. <div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
  103. <button
  104. v-for="m in materials"
  105. :key="m.id"
  106. type="button"
  107. @click="selectedMaterial = String(m.id)"
  108. :class="[
  109. 'p-4 rounded-2xl border text-left transition-all duration-300',
  110. selectedMaterial === String(m.id)
  111. ? 'bg-white border-primary shadow-lg scale-[1.02] ring-1 ring-primary/20'
  112. : 'bg-white/50 border-black/[0.04] hover:border-primary/20 grayscale opacity-70 hover:opacity-100 hover:grayscale-0'
  113. ]"
  114. >
  115. <p class="font-extrabold text-xs mb-0.5 tracking-tight uppercase">{{ m['name_' + locale] || m.name_en }}</p>
  116. <p class="text-[10px] text-foreground/40 leading-tight font-medium line-clamp-1">{{ m['desc_' + locale] || m.desc_en }}</p>
  117. </button>
  118. </div>
  119. </div>
  120. <!-- Color Selection (Strict) -->
  121. <Transition enter-active-class="transition duration-300" enter-from-class="opacity-0 scale-95" enter-to-class="opacity-100 scale-100">
  122. <div v-if="selectedMaterialColors.length" class="space-y-4 p-5 bg-secondary/30 rounded-[2rem] border border-black/[0.02]">
  123. <h3 class="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground/40 flex items-center gap-2 px-1">
  124. {{ t("upload.selectColor") }}
  125. </h3>
  126. <div class="flex flex-wrap gap-2">
  127. <button
  128. v-for="color in selectedMaterialColors"
  129. :key="color"
  130. type="button"
  131. @click="selectedColor = color"
  132. :class="[
  133. 'px-6 py-2.5 rounded-2xl border text-xs font-bold transition-all duration-300 capitalize',
  134. selectedColor === color
  135. ? 'bg-primary text-primary-foreground border-primary shadow-glow scale-105'
  136. : 'bg-white border-black/[0.05] text-foreground/60 hover:border-primary/30 hover:text-primary'
  137. ]"
  138. >
  139. {{ color }}
  140. </button>
  141. </div>
  142. </div>
  143. </Transition>
  144. <!-- Quantity & Model Link -->
  145. <div class="grid sm:grid-cols-5 gap-6">
  146. <div class="space-y-1.5 sm:col-span-3">
  147. <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  148. {{ t("upload.modelLink") }}
  149. </label>
  150. <div class="relative group">
  151. <input v-model="modelLink" type="url" :placeholder="t('upload.modelLinkPlaceholder')"
  152. @paste="onLinkPaste"
  153. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-2.5 pr-12 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium" />
  154. <button
  155. v-if="modelLink.includes('thingiverse.com') || modelLink.includes('printables.com')"
  156. type="button"
  157. @click="handleImportUrl"
  158. :disabled="isImporting"
  159. class="absolute right-2 top-1/2 -translate-y-1/2 p-2 rounded-xl bg-primary text-white hover:bg-primary/90 transition-all shadow-sm disabled:opacity-50 disabled:cursor-not-allowed"
  160. :title="t('common.import')"
  161. >
  162. <Loader2 v-if="isImporting" class="w-4 h-4 animate-spin" />
  163. <LinkIcon v-else class="w-4 h-4" />
  164. </button>
  165. </div>
  166. <!-- Animated Import Prompt -->
  167. <Transition enter-active-class="transition duration-300 ease-out" enter-from-class="transform -translate-y-2 opacity-0" enter-to-class="transform translate-y-0 opacity-100">
  168. <div v-if="(modelLink.includes('thingiverse.com') || modelLink.includes('printables.com')) && !files.length && !isImporting"
  169. class="mt-3 p-4 bg-primary/10 border border-primary/20 rounded-2xl flex items-center justify-between gap-4 animate-pulse-subtle">
  170. <div class="flex items-center gap-3">
  171. <div class="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center text-primary">
  172. <FileBox class="w-5 h-5" />
  173. </div>
  174. <div>
  175. <p class="text-xs font-bold text-primary uppercase tracking-tight">{{ t("common.import") }}?</p>
  176. <p class="text-[10px] text-primary/60 font-medium">{{ t("upload.importHint") || "We can automatically download files from this link!" }}</p>
  177. </div>
  178. </div>
  179. <Button @click="handleImportUrl" variant="hero" size="sm" class="h-9 px-4 rounded-xl shadow-lg">
  180. {{ t("common.import") }}
  181. </Button>
  182. </div>
  183. </Transition>
  184. </div>
  185. </div>
  186. <!-- Notes -->
  187. <div class="space-y-1.5">
  188. <label class="flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-foreground/40 ml-1">
  189. {{ t("upload.notes") }}
  190. </label>
  191. <textarea v-model="notes" :placeholder="t('upload.notesPlaceholder')" rows="2"
  192. class="w-full bg-secondary/50 border border-black/[0.03] rounded-2xl px-4 py-3 focus:outline-none focus:ring-4 focus:ring-primary/10 focus:border-primary transition-all text-sm font-medium resize-none" />
  193. </div>
  194. <!-- Drop Zone -->
  195. <div
  196. @dragover.prevent="isDragging = true"
  197. @dragleave.prevent="isDragging = false"
  198. @drop.prevent="handleDrop"
  199. :class="[
  200. 'relative border border-dashed rounded-[2rem] p-10 text-center transition-all duration-500',
  201. isDragging ? 'border-primary bg-primary/5 shadow-lg' : 'border-black/10 hover:border-primary/30 hover:bg-secondary/20'
  202. ]"
  203. >
  204. <input type="file" multiple accept=".stl,.obj,.3mf,.step" @change="handleFileSelect"
  205. class="absolute inset-0 w-full h-full opacity-0 cursor-pointer" />
  206. <div class="flex flex-col items-center gap-3">
  207. <div :class="['w-14 h-14 rounded-2xl flex items-center justify-center transition-all duration-500',
  208. isDragging ? 'bg-primary text-primary-foreground scale-110' : 'bg-primary/5 text-primary']">
  209. <Upload class="w-7 h-7" />
  210. </div>
  211. <div>
  212. <p class="font-display text-lg font-extrabold tracking-tight mb-1">
  213. {{ isDragging ? t("upload.dropzoneActive") : t("upload.dropzone") }}
  214. </p>
  215. <p class="text-xs font-medium text-foreground/40">
  216. {{ t("common.or") }} <span class="text-primary underline cursor-pointer">{{ t("upload.browse") }}</span>
  217. </p>
  218. </div>
  219. </div>
  220. </div>
  221. <!-- File list -->
  222. <div v-if="files.length" class="space-y-3">
  223. <h3 class="font-display text-lg font-semibold mb-4">{{ t("upload.uploadedFiles") }} ({{ files.length }})</h3>
  224. <div v-for="file in files" :key="file.id"
  225. class="flex items-center gap-4 p-4 bg-gradient-card rounded-xl border border-border/50 hover:border-primary/30 transition-colors">
  226. <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">
  227. <StlViewer v-if="file.name.toLowerCase().endsWith('.stl') && file.file" :file="file.file" />
  228. <FileBox v-else class="w-8 h-8 text-primary opacity-50" />
  229. </div>
  230. <div class="flex-1 min-w-0">
  231. <div class="flex items-center gap-2">
  232. <p class="font-medium truncate">{{ file.name }}</p>
  233. <div v-if="file.isUploading" class="w-3 h-3 border-2 border-primary border-t-transparent rounded-full animate-spin"></div>
  234. </div>
  235. <div class="flex items-center gap-2 text-sm text-muted-foreground">
  236. <div v-if="file.printTime || file.filamentG || file.dimensions" class="flex flex-wrap gap-2 mt-2">
  237. <div v-if="file.dimensions" class="flex items-center gap-1 bg-primary/10 text-primary px-2 py-0.5 rounded-full text-[10px] font-bold">
  238. <div class="w-1 h-1 rounded-full bg-current" />
  239. {{ file.dimensions }}
  240. </div>
  241. <div v-if="file.printTime" class="flex items-center gap-1 bg-secondary text-foreground/60 px-2 py-0.5 rounded-full text-[10px] font-bold">
  242. <div class="w-1 h-1 rounded-full bg-current" />
  243. {{ file.printTime }}
  244. </div>
  245. <div v-if="file.filamentG" class="flex items-center gap-1 bg-secondary text-foreground/60 px-2 py-0.5 rounded-full text-[10px] font-bold">
  246. <div class="w-1 h-1 rounded-full bg-current" />
  247. {{ file.filamentG.toFixed(1) }}g
  248. </div>
  249. </div>
  250. <span>{{ file.type }} • {{ formatFileSize(file.size) }}</span>
  251. <span v-if="file.basePrice" class="font-semibold text-primary ml-2 bg-primary/10 px-2 py-0.5 rounded-md">
  252. ~{{ (file.basePrice * file.quantity).toFixed(2) }} EUR
  253. </span>
  254. </div>
  255. <div v-if="SHOW_ADVANCED_METRICS && file.printTime" class="flex gap-3 text-xs text-muted-foreground mt-0.5 opacity-80">
  256. <span v-if="file.printTime">⏱️ {{ file.printTime }}</span>
  257. <span v-if="file.filamentG">⚖️ {{ file.filamentG.toFixed(1) }}g</span>
  258. </div>
  259. </div>
  260. <!-- Per-file quantity control -->
  261. <div class="flex items-center gap-1 bg-card/50 border border-border/50 rounded-lg p-1">
  262. <button type="button" @click.prevent="file.quantity > 1 && file.quantity--" class="w-8 h-8 rounded-md flex items-center justify-center hover:bg-muted text-muted-foreground transition-colors">-</button>
  263. <input type="number" v-model.number="file.quantity" min="1" step="1" class="w-12 text-center bg-transparent border-none text-sm font-medium focus:ring-0 [&::-webkit-inner-spin-button]:appearance-none" />
  264. <button type="button" @click.prevent="file.quantity++" class="w-8 h-8 rounded-md flex items-center justify-center hover:bg-muted text-muted-foreground transition-colors">+</button>
  265. </div>
  266. <div class="flex items-center gap-2">
  267. <div class="w-8 h-8 bg-green-500/10 rounded-full flex items-center justify-center">
  268. <Check class="w-4 h-4 text-green-500" />
  269. </div>
  270. <button @click="removeFile(file.id)"
  271. class="w-8 h-8 hover:bg-destructive/10 rounded-full flex items-center justify-center transition-colors group">
  272. <X class="w-4 h-4 text-muted-foreground group-hover:text-destructive" />
  273. </button>
  274. </div>
  275. </div>
  276. </div>
  277. <!-- Portfolio consent -->
  278. <div class="flex items-start gap-4 p-4 mt-6 bg-primary/5 border border-primary/20 rounded-2xl cursor-pointer hover:bg-primary/10 transition-all active:scale-[0.99] group"
  279. @click="allowPortfolio = !allowPortfolio">
  280. <div :class="['mt-0.5 w-5 h-5 rounded border flex items-center justify-center transition-all',
  281. allowPortfolio ? 'bg-primary border-primary' : 'bg-background border-border group-hover:border-primary/50']">
  282. <Check v-if="allowPortfolio" class="w-3.5 h-3.5 text-primary-foreground" />
  283. </div>
  284. <div class="flex-1 space-y-1">
  285. <p class="text-sm font-medium leading-none">{{ t("upload.allowPortfolio") }}</p>
  286. <p class="text-xs text-muted-foreground leading-relaxed">{{ t("upload.allowPortfolioDesc") }}</p>
  287. </div>
  288. <ShieldCheck :class="['w-5 h-5 transition-colors', allowPortfolio ? 'text-primary' : 'text-muted-foreground/30']" />
  289. </div>
  290. <!-- Nuances consent (Mandatory) -->
  291. <div class="flex items-start gap-4 p-4 mt-3 bg-secondary/30 border border-black/[0.03] rounded-2xl cursor-pointer hover:bg-secondary/50 transition-all active:scale-[0.99] group"
  292. @click="agreeToNuances = !agreeToNuances">
  293. <div :class="['mt-0.5 w-5 h-5 rounded border flex items-center justify-center transition-all',
  294. agreeToNuances ? 'bg-primary border-primary' : 'bg-background border-border group-hover:border-primary/50']">
  295. <Check v-if="agreeToNuances" class="w-3.5 h-3.5 text-primary-foreground" />
  296. </div>
  297. <div class="flex-1">
  298. <p class="text-sm font-medium leading-relaxed">
  299. {{ t("upload.agreeToNuances") }}
  300. <RouterLink
  301. :to="{ name: 'nuances', params: { lang: locale } }"
  302. target="_blank"
  303. class="text-primary underline hover:text-primary/80 transition-colors"
  304. @click.stop
  305. >
  306. {{ t("upload.nuancesLink") }}
  307. </RouterLink>
  308. </p>
  309. </div>
  310. <FileText :class="['w-5 h-5 transition-colors', agreeToNuances ? 'text-primary' : 'text-muted-foreground/30']" />
  311. </div>
  312. <!-- Estimate -->
  313. <Transition enter-active-class="transition duration-300" enter-from-class="opacity-0 translate-y-2" enter-to-class="opacity-100 translate-y-0">
  314. <div v-if="estimatedPrice !== null"
  315. class="p-6 bg-gradient-to-br from-primary/20 via-primary/5 to-background border border-primary/30 rounded-2xl flex items-center justify-between">
  316. <div>
  317. <p class="text-[10px] font-bold uppercase tracking-widest text-primary/80 mb-1">{{ t("upload.estimatedTotal") }}</p>
  318. <p class="text-xs text-muted-foreground">{{ t("upload.priceDisclaimer") }}</p>
  319. </div>
  320. <div class="text-right">
  321. <span class="text-3xl font-display font-bold text-primary">{{ estimatedPrice }}</span>
  322. <span class="text-xs font-bold text-primary ml-1 uppercase">EUR</span>
  323. <p class="text-[9px] text-muted-foreground mt-1 opacity-60">incl. 21% PDV</p>
  324. </div>
  325. </div>
  326. </Transition>
  327. <Button variant="hero" class="w-full mt-8 shadow-lg hover:shadow-primary/20"
  328. :disabled="!isFormValid || isSubmitting" @click="handleSubmit">
  329. <div v-if="isSubmitting" class="flex items-center gap-2">
  330. <Loader2 class="w-5 h-5 animate-spin" />{{ t("upload.submitting") }}
  331. </div>
  332. <span v-else>{{ t("upload.continue") }}</span>
  333. </Button>
  334. </div>
  335. </div>
  336. </section>
  337. </template>
  338. <script setup lang="ts">
  339. const props = defineProps<{
  340. embedded?: boolean;
  341. }>();
  342. const emit = defineEmits<{
  343. (e: 'success'): void;
  344. }>();
  345. import { ref, computed, watch, onMounted } from "vue";
  346. import { useI18n } from "vue-i18n";
  347. import { toast } from "vue-sonner";
  348. import { Upload, FileBox, X, Check, Link as LinkIcon, MapPin, User, Phone, Mail, Loader2, ShieldCheck, Hash, FileText } from "lucide-vue-next";
  349. import Button from "./ui/button.vue";
  350. import { defineAsyncComponent } from "vue";
  351. const StlViewer = defineAsyncComponent(() => import("@/components/StlViewer.vue"));
  352. import { submitOrder, getCurrentUser, getMaterials, getPriceEstimate, uploadFilesToServer, importFromUrl } from "@/lib/api";
  353. interface UploadedFile { id: string; dbId?: number; name: string; size: number; type: string; file?: File; quantity: number; basePrice?: number; isUploading?: boolean; printTime?: string; filamentG?: number; dimensions?: string; }
  354. // --- CONFIGURATION ---
  355. // Set to false to hide the hardcore slicing data (time and weight) from the end-user
  356. const SHOW_ADVANCED_METRICS = true;
  357. const { t, locale } = useI18n();
  358. const files = ref<UploadedFile[]>([]);
  359. const isDragging = ref(false);
  360. const isSubmitting = ref(false);
  361. const isImporting = ref(false);
  362. const modelLink = ref("");
  363. const address = ref("");
  364. const firstName = ref("");
  365. const lastName = ref("");
  366. const phone = ref("");
  367. const email = ref("");
  368. const isCompany = ref(false);
  369. const companyName = ref("");
  370. const companyPib = ref("");
  371. const companyAddress = ref("");
  372. const allowPortfolio = ref(false);
  373. const agreeToNuances = ref(false);
  374. const materials = ref<any[]>([]);
  375. const selectedMaterial = ref("1");
  376. const selectedColor = ref("");
  377. const selectedMaterialColors = computed(() => {
  378. const mat = materials.value.find(m => String(m.id) === selectedMaterial.value);
  379. return mat?.available_colors || [];
  380. });
  381. watch(selectedMaterial, (newVal) => {
  382. const mat = materials.value.find(m => String(m.id) === newVal);
  383. if (mat?.available_colors?.length) {
  384. selectedColor.value = mat.available_colors[0];
  385. } else {
  386. selectedColor.value = "";
  387. }
  388. });
  389. const estimatedPrice = computed(() => {
  390. if (files.value.length === 0) return null;
  391. const total = files.value.reduce((acc, f) => acc + ((f.basePrice || 0) * f.quantity), 0);
  392. return parseFloat(total.toFixed(2));
  393. });
  394. const notes = ref("");
  395. onMounted(async () => {
  396. try {
  397. const materialData = await getMaterials();
  398. materials.value = materialData;
  399. if (materialData.length > 0) selectedMaterial.value = String(materialData[0].id);
  400. const token = localStorage.getItem("token");
  401. if (token) {
  402. const user = await getCurrentUser();
  403. if (user) {
  404. firstName.value = user.first_name ?? "";
  405. lastName.value = user.last_name ?? "";
  406. phone.value = user.phone ?? "";
  407. email.value = user.email ?? "";
  408. address.value = user.shipping_address ?? "";
  409. isCompany.value = user.is_company ?? false;
  410. companyName.value = user.company_name ?? "";
  411. companyPib.value = user.company_pib ?? "";
  412. companyAddress.value = user.company_address ?? "";
  413. }
  414. }
  415. } catch (e) { console.error("Failed to load initial data:", e); }
  416. });
  417. watch([() => files.value.length, selectedMaterial, modelLink], async () => {
  418. if ((files.value.length > 0) && selectedMaterial.value) {
  419. try {
  420. const res = await getPriceEstimate({
  421. material_id: parseInt(selectedMaterial.value),
  422. file_sizes: files.value.map(f => f.size),
  423. file_quantities: files.value.map(f => 1), // Only get base unit cost
  424. });
  425. // Assign individual prices to files for local quantity multiplication
  426. if (res.file_prices && Array.isArray(res.file_prices)) {
  427. files.value.forEach((f, i) => {
  428. f.basePrice = res.file_prices[i];
  429. });
  430. // Force reactivity update
  431. files.value = [...files.value];
  432. }
  433. } catch { /* silent */ }
  434. }
  435. });
  436. const isFormValid = computed(() =>
  437. firstName.value.trim() !== "" && lastName.value.trim() !== "" &&
  438. phone.value.trim() !== "" && email.value.trim() !== "" &&
  439. address.value.trim() !== "" && selectedMaterial.value !== "" &&
  440. agreeToNuances.value &&
  441. (files.value.length > 0 || modelLink.value.trim() !== "") &&
  442. (!isCompany.value || (companyName.value.trim() !== "" && companyPib.value.trim() !== ""))
  443. );
  444. async function addFiles(rawFiles: File[]) {
  445. const valid = rawFiles.filter(f => /\.(stl|obj|3mf|step)$/i.test(f.name));
  446. const newFiles: UploadedFile[] = valid.map(f => ({
  447. id: Math.random().toString(36).substring(7),
  448. name: f.name,
  449. size: f.size,
  450. type: f.name.split(".").pop()?.toUpperCase() ?? "UNKNOWN",
  451. file: f,
  452. quantity: 1,
  453. basePrice: 0,
  454. isUploading: true,
  455. }));
  456. files.value.push(...newFiles);
  457. if (newFiles.length > 0) {
  458. try {
  459. const fd = new FormData();
  460. newFiles.forEach(f => {
  461. if (f.file) fd.append("files", f.file);
  462. });
  463. const res = await uploadFilesToServer(fd);
  464. if (res.uploaded) {
  465. res.uploaded.forEach((u: any, idx: number) => {
  466. newFiles[idx].dbId = u.id;
  467. newFiles[idx].isUploading = false;
  468. if (u.print_time) newFiles[idx].printTime = u.print_time;
  469. if (u.filament_g) newFiles[idx].filamentG = u.filament_g;
  470. if (u.dimensions) newFiles[idx].dimensions = u.dimensions;
  471. });
  472. }
  473. } catch (err) {
  474. console.error("Failed to upload files initially", err);
  475. newFiles.forEach(f => f.isUploading = false);
  476. }
  477. // Deep reactivity trigger
  478. files.value = [...files.value];
  479. }
  480. }
  481. function handleDrop(e: DragEvent) { isDragging.value = false; addFiles(Array.from(e.dataTransfer?.files ?? [])); }
  482. async function handleImportUrl() {
  483. if (!modelLink.value || isImporting.value) return;
  484. const token = localStorage.getItem("token");
  485. if (!token) {
  486. toast.error(t("auth.loginRequired"));
  487. return;
  488. }
  489. isImporting.value = true;
  490. const loadingToast = toast.loading(t("common.loading") + "...");
  491. try {
  492. const res = await importFromUrl(modelLink.value);
  493. if (res.status === "success" && res.files) {
  494. for (const f of res.files) {
  495. // Prevent duplicates
  496. if (files.value.some(existing => existing.dbId === f.id)) continue;
  497. files.value.push({
  498. id: Math.random().toString(36).substr(2, 9),
  499. dbId: f.id,
  500. name: f.filename,
  501. size: f.size,
  502. type: "model/stl",
  503. quantity: 1,
  504. printTime: f.print_time,
  505. filamentG: f.filament_g,
  506. dimensions: f.dimensions,
  507. isUploading: false
  508. });
  509. }
  510. toast.success(t("common.success"), { id: loadingToast });
  511. }
  512. } catch (e: any) {
  513. console.error("Import failed:", e);
  514. let msg = e.message || "Failed to import models";
  515. if (modelLink.value.includes("printables.com")) {
  516. msg = "Printables link failed. This site is often protected. Please download ZIP manually and upload here.";
  517. }
  518. toast.error(msg, { id: loadingToast });
  519. } finally {
  520. isImporting.value = false;
  521. }
  522. }
  523. function onLinkPaste() {
  524. // Wait for next tick to let v-model update
  525. setTimeout(() => {
  526. if ((modelLink.value.includes('thingiverse.com') || modelLink.value.includes('printables.com')) && !files.value.length) {
  527. handleImportUrl();
  528. }
  529. }, 100);
  530. }
  531. async function handleFileSelect(e: Event) { addFiles(Array.from((e.target as HTMLInputElement).files ?? [])); }
  532. function removeFile(id: string) { files.value = files.value.filter(f => f.id !== id); }
  533. function formatFileSize(bytes: number) {
  534. if (bytes < 1024) return bytes + " B";
  535. if (bytes < 1048576) return (bytes / 1024).toFixed(1) + " KB";
  536. return (bytes / 1048576).toFixed(1) + " MB";
  537. }
  538. async function handleSubmit() {
  539. if (!isFormValid.value) return;
  540. isSubmitting.value = true;
  541. const fd = new FormData();
  542. fd.append("first_name", firstName.value);
  543. fd.append("last_name", lastName.value);
  544. fd.append("phone", phone.value);
  545. fd.append("email", email.value);
  546. fd.append("shipping_address", address.value);
  547. fd.append("model_link", modelLink.value);
  548. fd.append("allow_portfolio", String(allowPortfolio.value));
  549. fd.append("notes", notes.value);
  550. fd.append("material_id", selectedMaterial.value);
  551. if (selectedColor.value) fd.append("color_name", selectedColor.value);
  552. fd.append("is_company", String(isCompany.value));
  553. if (isCompany.value) {
  554. fd.append("company_name", companyName.value);
  555. fd.append("company_pib", companyPib.value);
  556. fd.append("company_address", companyAddress.value);
  557. }
  558. const uploadedFiles = files.value.filter(f => f.dbId);
  559. fd.append("file_ids", JSON.stringify(uploadedFiles.map(f => f.dbId)));
  560. fd.append("file_quantities", JSON.stringify(uploadedFiles.map(f => f.quantity)));
  561. try {
  562. await submitOrder(fd);
  563. toast.success(t("upload.success"));
  564. files.value = []; firstName.value = ""; lastName.value = ""; phone.value = "";
  565. email.value = ""; address.value = ""; modelLink.value = "";
  566. allowPortfolio.value = false;
  567. agreeToNuances.value = false;
  568. notes.value = "";
  569. emit('success');
  570. } catch (err: any) {
  571. toast.error(err.message || t("upload.error"));
  572. } finally {
  573. isSubmitting.value = false;
  574. }
  575. }
  576. </script>