Skouter mortgage estimates. Web application with view written in PHP and Vue, but controller and models in Go.
 
 
 
 
 
 

325 行
8.4 KiB

  1. <template>
  2. <div class="page settings">
  3. <h2>Settings</h2>
  4. <section class="form inputs">
  5. <h3>Avatar</h3>
  6. <canvas class="displayer" width="200" height="200" ref="avatar"></canvas>
  7. <input type="file"
  8. @change="e => changeAvatar(e.target.files[0])"
  9. />
  10. <button @click="uploadAvatar">Upload</button>
  11. <label class="error">{{avatarError}}</label>
  12. </section>
  13. <section class="form inputs letterhead">
  14. <h3>Letterhead</h3>
  15. <canvas class="displayer" height="200" ref="letterhead"></canvas>
  16. <input type="file"
  17. @change="e => {setLetterhead(e.target.files[0])}"
  18. />
  19. <button @click="uploadLetterhead">Upload</button>
  20. <label class="error">{{letterheadError}}</label>
  21. </section>
  22. <section class="form inputs special">
  23. <h3>Profile</h3>
  24. <label for="">First Name</label>
  25. <input type="text" v-model="user.firstName">
  26. <label for="">Last Name</label>
  27. <input type="text" :value="user.lastName">
  28. <label for="">NMLS ID</label>
  29. <input type="text">
  30. <label for="">Branch ID</label>
  31. <input type="text" :value="user.branchId" disabled>
  32. <select id="" name="" :value="user.country">
  33. <option value="USA">USA</option>
  34. <option value="Canada">Canada</option>
  35. </select>
  36. <div class="address-entry">
  37. <label for="">Address</label>
  38. <input type="text" @input="searchLocation" :value="address.full">
  39. <dropdown v-if="addresses && addresses.length"
  40. :entries="addresses.map(a => ({text: a.full_address, value: a}))"
  41. @select="setAddress"
  42. >
  43. </dropdown>
  44. </div>
  45. <button @click="saveProfile">Save</button>
  46. </section>
  47. <section class="form inputs special">
  48. <h3>Subscriptions</h3>
  49. <label for="">Standard Plan ($49/month)</label>
  50. <button @click="() => unsubing = true">
  51. {{props.user.sub.cancelAtEnd ? "Subscribe" : "Unsubscribe"}}
  52. </button>
  53. <label for="">Newsletter</label>
  54. <button @click="changeNewsSub">
  55. {{props.user.newsletter ? "Unsubscribe" : "Subscribe"}}
  56. </button>
  57. </section>
  58. <section class="form inputs special">
  59. <h3>Reset Password</h3>
  60. <label for="">Old Password</label>
  61. <input type="password" v-model="oldPass">
  62. <label for="">New Password</label>
  63. <input type="password" v-model="newPass">
  64. <label for="">Confirm Password</label>
  65. <input type="password" v-model="confirmPass">
  66. <button
  67. :disabled="!(oldPass && newPass && confirmPass)"
  68. @click="changePassword">Save</button>
  69. <label class="error">{{passError}}</label>
  70. </section>
  71. <Dialog v-if="ready" @close="() => ready = false">
  72. <h3>Confirm your current password to save changes.</h3>
  73. <input type="text">
  74. <button>Confirm</button>
  75. </Dialog>
  76. <UnsubPrompt v-if="unsubing" :token="token"
  77. :cancelAtEnd="props.user.sub.cancelAtEnd"
  78. @close="() => unsubing = false"
  79. @cancelSub="() => { $emit('updateUser'); unsubing = false }"
  80. />
  81. </div>
  82. </template>
  83. <script setup>
  84. import { ref, watch, onMounted } from "vue"
  85. import Dialog from "./dialog.vue"
  86. import Dropdown from "./dropdown.vue"
  87. import UnsubPrompt from "./unsubscribe.vue"
  88. let avatar = ref(null) // the canvas element
  89. let letterhead = ref(null) // the canvas element
  90. let ready = ref(false)
  91. let unsubing = ref(false)
  92. let avatarChanged = ref(false)
  93. let avatarError = ref('')
  94. let letterheadError = ref('')
  95. let passError = ref('')
  96. let oldPass = ref('')
  97. let newPass = ref('')
  98. let confirmPass = ref('')
  99. const locationsId = ref(null)
  100. const addresses = ref([])
  101. const address = ref({})
  102. const props = defineProps(['user', 'token'])
  103. const emit = defineEmits(['updateAvatar', 'updateLetterhead', 'updateUser'])
  104. const user = Object.assign({}, props.user)
  105. function save() {
  106. }
  107. function check() {
  108. ready.value = true
  109. }
  110. function uploadAvatar() {
  111. avatar.value.toBlob(b => {
  112. fetch(`/api/user/avatar`,
  113. {method: 'POST',
  114. body: b,
  115. headers: {
  116. "Accept": "application/json",
  117. "Authorization": `Bearer ${props.token}`,
  118. },
  119. }).then(resp => {
  120. if (resp.ok) {emit('updateAvatar')}
  121. })
  122. })
  123. }
  124. function uploadLetterhead() {
  125. letterhead.value.toBlob(b => {
  126. fetch(`/api/user/letterhead`,
  127. {method: 'POST',
  128. body: b,
  129. headers: {
  130. "Accept": "application/json",
  131. "Authorization": `Bearer ${props.token}`,
  132. },
  133. }).then(resp => {
  134. if (resp.ok) {emit('updateLetterhead')}
  135. })
  136. })
  137. }
  138. function setLetterhead(f) {
  139. letterheadError.value = ""
  140. const validTypes = ['image/jpeg', 'image/png']
  141. if (!validTypes.includes(f.type)) {
  142. letterheadError.value = 'Image must be JPEG of PNG format'
  143. return
  144. }
  145. fetch(`/api/letterhead`,
  146. {method: 'POST',
  147. body: f,
  148. headers: {
  149. "Accept": "application/json",
  150. "Authorization": `Bearer ${props.token}`,
  151. },
  152. }).then(resp => {
  153. if (resp.ok) {
  154. resp.blob().then(b => changeLetterhead(b))
  155. } else {
  156. resp.text().then(e => letterheadError.value = e)
  157. }
  158. })
  159. }
  160. function changeAvatar(blob) {
  161. const validTypes = ['image/jpeg', 'image/png']
  162. if (!validTypes.includes(blob?.type)) {
  163. avatarError.value = 'Image must be JPEG of PNG format'
  164. return
  165. }
  166. avatarError.value = ''
  167. createImageBitmap(blob,
  168. {resizeWidth: 200, resizeHeight: 200, resizeQuality: 'medium'}).
  169. then((img) => {
  170. avatar.value.getContext("2d").drawImage(img, 0, 0, 200, 200)
  171. })
  172. }
  173. function changeLetterhead(blob) {
  174. const validTypes = ['image/jpeg', 'image/png']
  175. if (!validTypes.includes(blob.type)) {
  176. letterheadError.value = 'Image must be JPEG of PNG format'
  177. return
  178. }
  179. createImageBitmap(blob).
  180. then((img) => {
  181. let ctx = letterhead.value.getContext("2d")
  182. ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height)
  183. ctx.drawImage(img, 0, 0)
  184. })
  185. }
  186. function setAddress(a) {
  187. address.value = {
  188. id: address.value.id,
  189. full: a.full_address,
  190. street: a.address,
  191. city: a.context?.place.name ?? '',
  192. region: a.context?.region?.name ?? '',
  193. country: a.context?.country?.name ?? '',
  194. zip: a.context?.postcode?.name ?? '',
  195. }
  196. addresses.value = null
  197. }
  198. function saveProfile() {
  199. user.value.address = address.value
  200. fetch(`/api/user`,
  201. {method: 'PATCH',
  202. body: JSON.stringify(user.value),
  203. headers: {
  204. "Accept": "application/json",
  205. "Authorization": `Bearer ${props.token}`,
  206. },
  207. }).then(resp => {
  208. if (resp.ok) {}
  209. })
  210. }
  211. function changePassword(f) {
  212. fetch(`/api/user/password`,
  213. {method: 'POST',
  214. body: JSON.stringify({
  215. old: oldPass.value,
  216. new: newPass.value,
  217. confirm: confirmPass.value,
  218. }),
  219. headers: {
  220. "Accept": "application/json",
  221. "Authorization": `Bearer ${props.token}`,
  222. },
  223. }).then(resp => {
  224. if (resp.ok) {
  225. resp.blob().then(b => {
  226. oldPass.value = ""
  227. newPass.value = ""
  228. confirmPass.value = ""
  229. passError.value = ""
  230. })
  231. } else {
  232. resp.text().then(e => passError.value = e)
  233. }
  234. })
  235. }
  236. function searchLocation(e) {
  237. address.value.full = e.target.value
  238. clearTimeout(locationsId.value)
  239. locationsId.value = setTimeout(getLocations, 1000, e)
  240. }
  241. function getLocations(e) {
  242. let prefix = "https://api.mapbox.com/search/searchbox/v1/suggest?"
  243. let search = e.target.value
  244. let key = encodeURIComponent(process.env.MAPBOX_API_KEY)
  245. if (!search) return
  246. fetch(`${prefix}q=${search}&proximity=ip&access_token=${key}&session_token=1`
  247. ).then(resp => resp.json()).then(entries => {
  248. if (!entries?.suggestions) {
  249. addresses.value = null
  250. return
  251. }
  252. addresses.value = entries.suggestions.filter(e => e.full_address)
  253. })
  254. }
  255. function changeNewsSub() {
  256. fetch(`/api/user/newsletter`,
  257. {method: 'GET',
  258. headers: {
  259. "Accept": "application/json",
  260. "Authorization": `Bearer ${props.token}`,
  261. },
  262. }).then(resp => {
  263. if (resp.ok) emit('updateUser')
  264. })
  265. }
  266. watch(props.user, (u) => {
  267. if (props.user.avatar) {
  268. changeAvatar(props.user.avatar)
  269. }
  270. if (props.user.letterhead) {
  271. changeLetterhead(props.user.letterhead)
  272. }
  273. address.value = Object.assign({}, props.user.address)
  274. user.value = Object.assign({}, props.user)
  275. user.value.address = address.value
  276. }, {immediate: true})
  277. </script>
  278. <style scoped>
  279. div.address-entry {
  280. display: flex;
  281. flex-flow: column;
  282. position: relative;
  283. }
  284. </style>