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