github.com/resonatecoop/id@v1.1.0-43/frontend/src/components/forms/profile.js (about) 1 /* global fetch */ 2 3 const html = require('choo/html') 4 const Component = require('choo/component') 5 const Form = require('./generic') 6 const icon = require('@resonate/icon-element') 7 8 const isEqual = require('is-equal-shallow') 9 const logger = require('nanologger') 10 const log = logger('form:updateProfile') 11 12 const isEmpty = require('validator/lib/isEmpty') 13 const isEmail = require('validator/lib/isEmail') 14 const isInt = require('validator/lib/isInt') 15 const isDivisibleBy = require('validator/lib/isDivisibleBy') 16 const validateFormdata = require('validate-formdata') 17 const nanostate = require('nanostate') 18 const morph = require('nanomorph') 19 20 const SwaggerClient = require('swagger-client') 21 const CountrySelect = require('../select-country-list') 22 const RoleSwitcher = require('./roleSwitcher') 23 const inputField = require('../../elements/input-field') 24 25 // prices for credits 26 const prices = [ 27 { 28 amount: 0, 29 credits: 128, 30 hours: 4, 31 help: html`<p class="helptext f5 dark-gray ma0 pa0 tc">You already received free credits</p>` 32 }, 33 { 34 amount: 7, 35 credits: 5000, 36 hours: 16 37 }, 38 { 39 amount: 12, 40 credits: 10000, 41 hours: 32 42 }, 43 { 44 amount: 22, 45 credits: 20000, 46 hours: 64 47 }, 48 { 49 amount: 50, 50 credits: 50000, 51 hours: 128 52 } 53 ] 54 55 // help text or link 56 const helpText = (text, href) => { 57 const attrs = { 58 class: 'link underline f5 dark-gray tr', 59 href: href 60 } 61 if (href.startsWith('http')) { 62 attrs.target = '_blank' 63 } 64 return html` 65 <div class="flex justify-end mt2"> 66 <a ${attrs}>${text}</a> 67 </div> 68 ` 69 } 70 71 class Credits extends Component { 72 constructor (id, state, emit) { 73 super(id) 74 75 this.emit = emit 76 this.state = state 77 78 this.local = state.components[id] = {} 79 80 this.local.data = {} 81 82 this.validator = validateFormdata() 83 this.local.form = this.validator.state 84 } 85 86 createElement (props = {}) { 87 this.local.form = props.form || this.local.form || this.validator.state 88 this.onchange = props.onchange // optional callback 89 90 return html` 91 <fieldset class="bg-light-gray ba bw b--mid-gray ma0 pa0 pb4 ph2 mb2"> 92 <legend class="clip">Add credits</legend> 93 94 ${helpText('What are credits?', 'https://community.resonate.is/docs?topic=1854')} 95 96 <div class="flex"> 97 <div class="pa3 flex w-100 flex-auto"> 98 </div> 99 <div class="pa3 flex w-100 flex-auto f5 b dark-gray"> 100 Cost 101 </div> 102 <div class="pa3 flex w-100 flex-auto f5 b dark-gray"> 103 Credits 104 </div> 105 <div class="pa3 flex w-100 flex-auto f5 b dark-gray"> 106 ~Listen 107 </div> 108 </div> 109 ${prices.map((item, index) => { 110 const { amount, credits, hours, help } = item 111 const checked = amount === this.local.data.amount 112 const attrs = { 113 style: 'opacity:0;width:0;height:0;', 114 onchange: (e) => { 115 const val = Number(e.target.value) 116 log.info(`select:${val}`) 117 const index = prices.findIndex((item) => item.amount === val) 118 this.local.data = prices[index] 119 typeof this.onchange === 'function' && this.onchange(this.local.data.credits / 1000) 120 }, 121 tabindex: -1, 122 id: 'amount-' + index, 123 name: 'amount', 124 type: 'radio', 125 checked: checked, 126 value: amount 127 } 128 129 // label attrs 130 const attrs2 = { 131 class: 'flex items-center justify-center w-100 dim', 132 tabindex: 0, 133 onkeypress: e => { 134 if (e.keyCode === 13 && !e.target.control.checked) { 135 e.preventDefault() 136 e.target.control.checked = !e.target.control.checked 137 const val = parseInt(e.target.control.value, 10) 138 const index = prices.findIndex((item) => item.amount === val) 139 this.local.data = prices[index] 140 } 141 }, 142 for: 'amount-' + index 143 } 144 145 return html` 146 <div class="flex flex-column w-100 flex-auto"> 147 <div class="flex"> 148 <input ${attrs}> 149 <label ${attrs2}> 150 <div class="pa3 flex w-100 items-center justify-center flex-auto"> 151 ${icon('circle', { size: 'sm', class: 'fill-transparent' })} 152 </div> 153 <div class="pa3 flex w-100 flex-auto f3"> 154 €${amount} 155 </div> 156 <div class="pa3 flex w-100 flex-auto f4 dark-gray"> 157 ${formatCredit(credits)} 158 </div> 159 <div class="pa3 flex w-100 flex-auto f4 dark-gray"> 160 ${hours}h 161 </div> 162 </label> 163 </div> 164 ${help} 165 </div> 166 ` 167 })} 168 </fieldset> 169 ` 170 } 171 172 update () { 173 return false 174 } 175 } 176 177 // CheckBox component class 178 class CheckBox extends Component { 179 constructor (id, state, emit) { 180 super(id) 181 182 this.emit = emit 183 this.state = state 184 185 this.local = state.components[id] = {} 186 187 this.local.checked = 'off' 188 189 this.validator = validateFormdata() 190 this.local.form = this.validator.state 191 } 192 193 createElement (props = {}) { 194 this.local.form = props.form || this.local.form || this.validator.state 195 this.onchange = props.onchange // optional callback 196 197 this.local.checked = props.value ? 'on' : 'off' 198 199 const values = this.local.form.values 200 201 values[props.name] = this.local.checked 202 203 const attrs = { 204 checked: this.local.checked === 'on' ? 'checked' : false, 205 id: props.id || props.name, 206 required: false, 207 onchange: (e) => { 208 this.local.checked = e.target.checked ? 'on' : 'off' 209 values[props.name] = this.local.checked 210 e.target.setAttribute('checked', e.target.checked ? 'checked' : false) 211 212 typeof this.onchange === 'function' && this.onchange(this.local.checked === 'on') 213 }, 214 value: values[props.name], 215 class: 'o-0', 216 style: 'width:0;height:0;', 217 name: props.name, 218 type: 'checkbox' 219 } 220 221 if (props.disabled) { 222 attrs.disabled = 'disabled' 223 } 224 225 return inputField(html`<input ${attrs}>`, this.local.form)({ 226 prefix: 'flex flex-column mb3', 227 disabled: props.disabled, 228 labelText: props.labelText || '', 229 labelIconName: 'check', 230 inputName: props.name, 231 helpText: props.helpText, 232 displayErrors: true 233 }) 234 } 235 236 update () { 237 return false 238 } 239 } 240 241 class SharesAmount extends Component { 242 constructor (id, state, emit) { 243 super(id) 244 245 this.emit = emit 246 this.state = state 247 248 this.local = state.components[id] = {} 249 250 this.validator = validateFormdata() 251 this.local.form = this.validator.state 252 } 253 254 createElement (props = {}) { 255 this.local.form = props.form || this.local.form || this.validator.state 256 this.onchange = props.onchange // optional callback 257 258 return inputField(this.renderInput.bind(this)(props), this.local.form)({ 259 prefix: 'flex flex-column mb3', 260 disabled: props.disabled, 261 labelText: props.labelText || '', 262 inputName: props.name, 263 flexRow: true, 264 helpText: props.helpText, 265 displayErrors: true 266 }) 267 } 268 269 renderInput (props) { 270 const values = this.local.form.values 271 272 values[props.name] = props.value 273 274 const attrs = { 275 id: props.id || props.name, 276 required: false, 277 step: 5, 278 class: 'ba bw b--mid-gray bg-gray mr2 tr', 279 style: 'height:3rem;width:4rem;', 280 min: 0, 281 max: 10000, 282 placeholder: 0, 283 onchange: (e) => { 284 const { value } = e.target 285 if (value > 10000) { 286 this.local.amount = 10000 287 } else if (value < 0) { 288 this.local.amount = 0 289 } else if (value > 0 && value < 5) { 290 this.local.amount = 5 // positive val starts at 5 minimum 291 } else { 292 this.local.amount = Math.round(e.target.value / 5) * 5 293 } 294 295 values[props.name] = this.local.amount 296 297 morph( 298 this.element.querySelector('input[type="number"]'), 299 this.renderInput.bind(this)(Object.assign({}, props, { value: this.local.amount })) 300 ) 301 302 typeof this.onchange === 'function' && this.onchange(this.local.amount) 303 }, 304 value: values[props.name], 305 name: props.name, 306 type: 'number' 307 } 308 309 return html`<input ${attrs}>` 310 } 311 312 update () { 313 return false 314 } 315 } 316 317 // AccountForm class 318 class AccountForm extends Component { 319 constructor (id, state, emit) { 320 super(id) 321 322 this.emit = emit 323 this.state = state 324 325 this.local = state.components[id] = Object.create({ 326 machine: nanostate.parallel({ 327 form: nanostate('idle', { 328 idle: { submit: 'submitted' }, 329 submitted: { valid: 'data', invalid: 'error' }, 330 data: { reset: 'idle', submit: 'submitted' }, 331 error: { reset: 'idle', submit: 'submitted', invalid: 'error' } 332 }), 333 request: nanostate('idle', { 334 idle: { start: 'loading' }, 335 loading: { resolve: 'data', reject: 'error' }, 336 data: { start: 'loading' }, 337 error: { start: 'loading', stop: 'idle' } 338 }), 339 loader: nanostate('off', { 340 on: { toggle: 'off' }, 341 off: { toggle: 'on' } 342 }) 343 }) 344 }) 345 346 this.local.machine.on('form:reset', () => { 347 this.validator = validateFormdata() 348 this.local.form = this.validator.state 349 }) 350 351 this.local.machine.on('request:start', () => { 352 this.loaderTimeout = setTimeout(() => { 353 this.local.machine.emit('loader:toggle') 354 }, 300) 355 }) 356 357 this.local.machine.on('request:reject', () => { 358 clearTimeout(this.loaderTimeout) 359 }) 360 361 this.local.machine.on('request:resolve', () => { 362 clearTimeout(this.loaderTimeout) 363 }) 364 365 this.local.machine.on('form:valid', async () => { 366 log.info('Form is valid') 367 368 try { 369 this.local.machine.emit('request:start') 370 371 let response = await fetch('') 372 373 const csrfToken = response.headers.get('X-CSRF-Token') 374 const payload = { 375 email: this.local.data.email || '', 376 displayName: this.local.data.displayName || '', 377 membership: this.local.data.member || '', 378 newsletter: this.local.data.newsletterNotification ? 'subscribe' : '', 379 shares: this.local.shares || '', 380 credits: this.local.credits || '' 381 } 382 383 response = await fetch('', { 384 method: 'PUT', 385 headers: { 386 Accept: 'application/json', 387 'X-CSRF-Token': csrfToken 388 }, 389 body: new URLSearchParams(payload) 390 }) 391 392 const status = response.status 393 const contentType = response.headers.get('content-type') 394 395 if (status >= 400 && contentType && contentType.indexOf('application/json') !== -1) { 396 const { error } = await response.json() 397 this.local.error.message = error 398 this.local.machine.emit('request:error') 399 } else { 400 this.emit('notify', { message: 'Your account info has been successfully updated' }) 401 402 this.local.machine.emit('request:resolve') 403 404 response = await response.json() 405 406 const { data } = response 407 408 if (data.success_redirect_url) { 409 setTimeout(() => { 410 window.location = data.success_redirect_url 411 }, 0) 412 } 413 } 414 } catch (err) { 415 this.local.machine.emit('request:reject') 416 console.log(err) 417 } 418 }) 419 420 this.local.machine.on('form:invalid', () => { 421 log.info('Form is invalid') 422 423 const invalidInput = document.querySelector('.invalid') 424 425 if (invalidInput) { 426 invalidInput.focus({ preventScroll: false }) // focus to first invalid input 427 } 428 }) 429 430 this.local.machine.on('form:submit', () => { 431 log.info('Form has been submitted') 432 433 const form = this.element.querySelector('form') 434 435 for (const field of form.elements) { 436 const isRequired = field.required 437 const name = field.name || '' 438 const value = field.value || '' 439 440 if (isRequired) { 441 this.validator.validate(name, value) 442 } 443 } 444 445 this.rerender() 446 447 this.local.machine.emit(`form:${this.local.form.valid ? 'valid' : 'invalid'}`) 448 }) 449 450 this.validator = validateFormdata() 451 this.local.form = this.validator.state 452 this.local.shares = 0 453 } 454 455 createElement (props = {}) { 456 this.local.data = this.local.data || props.data 457 458 const values = this.local.form.values 459 460 for (const [key, value] of Object.entries(this.local.data)) { 461 values[key] = value 462 } 463 464 return html` 465 <div class="flex flex-column flex-auto"> 466 ${this.state.cache(Form, 'account-form-update').render({ 467 id: 'account-form', 468 method: 'POST', 469 action: '', 470 buttonText: this.state.profile.complete ? 'Update' : 'Next', 471 validate: (props) => { 472 this.local.data[props.name] = props.value 473 this.validator.validate(props.name, props.value) 474 this.rerender() 475 }, 476 form: this.local.form || { 477 changed: false, 478 valid: true, 479 pristine: {}, 480 required: {}, 481 values: {}, 482 errors: {} 483 }, 484 submit: (data) => { 485 this.local.machine.emit('form:submit') 486 }, 487 fields: [ 488 { 489 component: this.state.cache(RoleSwitcher, 'role-switcher').render({ 490 help: true, 491 value: this.state.profile.role, 492 onChangeCallback: async (value) => { 493 const specUrl = new URL('/user/user.swagger.json', 'https://' + process.env.API_DOMAIN) 494 495 this.swaggerClient = await new SwaggerClient({ 496 url: specUrl.href, 497 authorizations: { 498 bearer: 'Bearer ' + this.state.token 499 } 500 }) 501 502 const roles = [ 503 'superadmin', 504 'admin', 505 'tenantadmin', 506 'label', // 4 507 'artist', // 5 508 'user' // 6 509 ] 510 511 await this.swaggerClient.apis.Users.ResonateUser_UpdateUser({ 512 id: this.state.profile.id, // user-api user uuid 513 body: { 514 role_id: roles.indexOf(value) + 1 515 } 516 }) 517 } 518 }) 519 }, 520 { 521 type: 'text', 522 name: 'displayName', 523 required: true, 524 readonly: this.local.data.displayName ? 'readonly' : false, 525 label: 'Display name', 526 help: this.local.data.displayName ? helpText('Change your display name', '/profile') : '', 527 placeholder: 'Name' 528 }, 529 { 530 type: 'email', 531 label: 'E-mail', 532 help: helpText('Change your email', '/account-settings'), 533 readonly: 'readonly', // can't change email address here 534 disabled: true 535 }, 536 { 537 component: this.state.cache(CountrySelect, 'update-country').render({ 538 country: this.state.profile.country || '', 539 onchange: async (props) => { 540 const { country, code } = props 541 542 let response = await fetch('') 543 544 const csrfToken = response.headers.get('X-CSRF-Token') 545 546 response = await fetch('', { 547 method: 'PUT', 548 headers: { 549 Accept: 'application/json', 550 'X-CSRF-Token': csrfToken 551 }, 552 body: new URLSearchParams({ 553 country: code 554 }) 555 }) 556 557 if (response.status >= 400) { 558 throw new Error('Something went wrong') 559 } 560 561 this.state.profile.country = country 562 } 563 }) 564 }, 565 { 566 component: this.state.cache(Credits, 'credits-chooser').render({ 567 form: this.local.form, 568 onchange: (value) => { 569 this.local.credits = value 570 } 571 }) 572 }, 573 { 574 component: this.state.cache(CheckBox, 'membership').render({ 575 id: 'membership', 576 name: 'membership', 577 value: this.local.data.member, 578 disabled: this.local.data.member, // already member 579 form: this.local.form, 580 labelText: html` 581 <dl> 582 <dt class="f5">${this.local.data.member ? 'You are a member' : 'Become a member?'}</dt> 583 <dd class="f6 ma0"> 584 10 Euros a year (listener) / Membership is free for artists (and label owners) 585 </dd> 586 </dl> 587 `, 588 helpText: this.local.data.member 589 ? helpText('Access your membership details', '/membership') 590 : helpText('Benefits of membership', 'https://community.resonate.is/docs?topic=1486'), 591 onchange: (value) => { 592 this.local.data.member = value 593 } 594 }) 595 }, 596 { 597 component: this.state.cache(SharesAmount, 'shares-amount').render({ 598 id: 'shares', 599 name: 'shares', 600 labelText: html` 601 <dl> 602 <dt class="f5">Buy supporter shares</dt> 603 <dd class="f6 ma0"> 604 1 Euro per share 605 </dd> 606 </dl> 607 `, 608 form: this.local.form, 609 onchange: (value) => { 610 this.local.shares = value 611 } 612 }) 613 }, 614 { 615 component: this.state.cache(CheckBox, 'newsletter-notification').render({ 616 id: 'newsletterNotification', 617 name: 'newsletterNotification', 618 value: this.local.data.newsletterNotification, 619 form: this.local.form, 620 labelText: html` 621 <dl> 622 <dt class="f5">Subscribe to our newsletter</dt> 623 <dd class="f6 ma0">We would like to keep in touch using your email address. Is that OK?</dd> 624 </dl> 625 `, 626 helpText: helpText('About privacy', 'https://community.resonate.is/docs?search=privacy&topic=1863'), 627 onchange: (value) => { 628 this.local.data.newsletterNotification = value 629 } 630 }) 631 } 632 // { 633 // type: 'text', 634 // name: 'fullName', 635 // required: false, 636 // placeholder: 'Full name' 637 // }, 638 // { 639 // type: 'text', 640 // name: 'firstName', 641 // required: false, 642 // placeholder: 'First name' 643 // }, 644 // { 645 // type: 'text', 646 // name: 'lastName', 647 // required: false, 648 // placeholder: 'Last name' 649 // } 650 ] 651 })} 652 </div> 653 ` 654 } 655 656 load () { 657 this.validator.field('email', (data) => { 658 if (isEmpty(data)) return new Error('Email is required') 659 if (!isEmail(data)) return new Error('Email is invalid') 660 }) 661 this.validator.field('displayName', { required: true }, (data) => { 662 if (isEmpty(data)) return new Error('Name is required') 663 }) 664 this.validator.field('shares', { required: false }, (data) => { 665 if (!isInt(data, { min: 0, max: 10000 })) return new Error('Invalid shares amount') 666 if (!isDivisibleBy(data, 5)) return new Error('Invalid shares amount') 667 }) 668 // this.validator.field('fullName', { required: false }, (data) => { 669 // if (isEmpty(data)) return new Error('Full name is required') 670 // }) 671 // this.validator.field('firstName', { required: false }, (data) => { 672 // if (isEmpty(data)) return new Error('First name is required') 673 // }) 674 // this.validator.field('lastName', { required: false }, (data) => { 675 // if (isEmpty(data)) return new Error('Last name is required') 676 // }) 677 } 678 679 update (props) { 680 if (!isEqual(props.data, this.local.data)) { 681 this.local.data = props.data 682 return true 683 } 684 return false 685 } 686 } 687 688 function formatCredit (tokens) { 689 return (tokens / 1000).toFixed(4) 690 } 691 692 module.exports = AccountForm