How to create a newsletter subscription form using Uniform & Mailchimp?

Right, if there is no way to do this JS-only without giving away the API token or being able to protect against CSRF this is a no-go.

The standard way to embed a MailChimp form is to use their embedded form. This is actually their recommended option and does not require to publish your API key as far as I can tell.

1 Like

I was thinking about the router because the site uses the panel and I’d like the panel users to not see these pages (newsletter/subscribe)… and possibly avoid its destruction in the near future by untrained panel users. I’m also not saving any infos there… just communicating with mailchimp.

Is it still doable using the router? I was actually adapting the technic below which @texnixe taught me a while ago. But not sure it’ll work with POST requests.

c::set('routes', array(
  array(
    'pattern' => '(:all)/subscribe',
    'action'  => function($uri) {
      tpl::load(kirby()->roots()->templates() . DS . 'newsletter.php', array('uri' => $uri), false );
    }
  )
));

So all the logic from @bnomei 's wrapper goes in the Uniform action, ok got it.
Then how’d would I call it? Since it will come from several different URLs I can’t use the template controller (the question above). Using the router? Using call_user_func()?

I’m trying to see the outline of the subscribe action… to understand which script calls what and how to handle the replies from the API.

I see your point… but I have two reasons to use the API:

First is about controlling the subscription experience (custom feedback messages, nice UI, the speed of ajax, use of animation and less bloat from mail chimp (better performance);

Second is about the learning, since I’m new to this I’m trying to learn how to use a API/REST thingy, to figure out the API calls and finally meeting the matrix :smile:

Sure, if you don’t need modifiable information (like a target email adress or an API key) for the form, a route is probably the better choice. Here you can find a neat solution using a route as endpoint for an AJAX form (not using Uniform though, but it works the same).

Yes but you don’t mean to copy the logic of the wrapper, do you? You only have to install it according to the readme and use the wrapper in the action. This way you can easily update it in the future.

Even better… coz I was going to copy, slice and hack it till it talks the way I wanted (hahahaha). I just don’t know what I’m doing… which is hard most of the time :-\

Where to put it? Plugins? The readme mentions composer and laravel and stuff…

I’ll study the link and see if can put this together. For now this interaction is very cloudy still, in my mind.

Thanks.

If you don’t want to use Composer you can do it like this:

  1. Create a new directory site/plugins/uniform-mailchimp-action with a file uniform-mailchimp-action.php.
  2. Put the MailChimp.php file from the wrapper there, too.
  3. Start implementing the action in uniform-mailchimp-action.php like this:
<?php

uniform::$actions['mailchimp'] = function ($form, $actionOptions) {
   require_once __DIR__.DS.'MailChimp.php';
   $mc = new \VPS\MailChimp('yourapikeyhere-us1');
   // to be continued ...
};

If you want to use Composer, do this:

  1. Get Composer, and put the composer.phar to the root directory of your Kirby site.
  2. Run php composer.phar require vatps/mailchimp-rest-api from a terminal in the same directory.
  3. Add require __DIR__.DS.'vendor'.DS.'autoload.php'; to the index.php. Don’t forget to always upload the new vendor directory to your server from now on (the composer.phar is only required locally).
  4. Start implementing the new Uniform action as described above but without downloading the MailChimp.php and without the require_once in the action.

The benefit of using Composer is that you “only” have to run php composer.phar update vatps/mailchimp-rest-api to update the wrapper. But since this is only a single file and not very likely to receive frequent updates, simply downloading it as described above may be the better option. You can update the wrapper by downloading the new version and replacing the file, too.

Does this get you started?

Martin… you’re awesome!

I followed your advice and didn’t use composer.

Managed to make a test using call_user_func and now I can see the API response:

dump( call_user_func(uniform::$actions['mailchimp']) ); 

Is there a better way to test it?

Now I can further implement the Uniform action and make it talk to the frontend via ajax using the previous link you gave me.

Not without adding a bunch of additional complexity (a testing framework) to mock the HTTP requests I guess. The easiest option might be setting up a mailing list and test everything manually :smile:

It took me so long getting this to work that someone even marked it as solved… :slight_smile:

I ended up mixing technics from @mzur and @luxlogica and got quite confused…

Without the controller method I didn’t know how to configure the Uniform plugin to handle validation… instead of validating “manually” in my custom Uniform::action.

I’d like to not validate it via javaScript… but it seems impossible due to the way the router returns the json.

In summary, is there a way to use Uniform to handle validation? how to initialize it? Above the form in the snippet? in the router? In the custom Uniform::action?


Here’s What I did so for… it works but looks messy…

The config’s router

  array(
    'pattern' => 'newsletter/subscribe',
    'method' => 'POST',
    'action' => function() {
      // check whether this is an ajax request, and respond with an error if it isn't
      if(!kirby()->request()->ajax()){ return response::error("Page Not Found!","404");}
        
        $form_data = kirby()->request()->data();

        // add the user and get some feedbacks
        return response::json( call_user_func(uniform::$actions['mailchimp'], $form_data) );
      }
    )

The Uniform action (plugins/uniform-action-mailchimp.php)

uniform::$actions['mailchimp'] = function ($form_data) {

  require_once __DIR__.DS.'mailchimp'.DS.'src'.DS.'VPS'.DS.'MailChimp.php';
  $mc = new \VPS\MailChimp('xxxxxxxxxxxxxxxxxx-us6');

  $list_id = 'xxxxxxxxxxxx'; 


  // get form data
  $email = $form_data['_from'];
  $potty = $form_data['website'];
  $do_website = $form_data['do_website'];


  // check if email is valid and the potty is empty
  // assemble feedback array (result = [success, message, errors[] ])
  $errors = array(); 
  if(!v::email($email)) { $errors[] = 'email'; }
  if(!empty($potty)){ $errors[] = 'potty'; }
  if(empty($do_website) AND ($do_website !== 'sim') ) { $errors[] = 'do_website'; }
  $result = array();
  $result['errors'] = $errors;

  // if we have validation errors, we can stop and return them:
  if(!empty($errors)){
      $result['success'] = false;
      $result['message'] = 'Validation Failed';
      return $result;
  }



  // if we have no errors, go ahead and insert the email in the list
  $response = $mc->post('/lists/'.$list_id.'/members', array(
                'email_address' => (string) $email,
                'merge_fields' => array('DO_WEBSITE'=>$do_website),
                'status' => 'subscribed'
            ));


  // Email already in the list
  if ($response['status'] == 400) {
    $result['success'] = true;
    $result['message'] = 'Thanks, your email was already in the list.';
    return $result;

  // sloppy way to check if the API returned a pseudo "200" status
  } else if (isset($response['id'])) {
    $result['success'] = true;
    $result['message'] = 'Thank you, soon you\'ll hear from us .';
    return $result;

  } else {
    $result['success'] = false;
    $result['message'] = 'Sorry, something went wrong.';
    $result['errors'] = 'unknown';
  }

  // return proper error/success as array
  return $result; 




};

The js below the form

<script>
    // AJAX FORM PROCESSING
    $('#newsletter').on('submit', function(e){
        e.preventDefault();
        var form = $(this);
        $.ajax({
            type: 'POST',
            // use the same url here as the 'pattern' in your route
            url: 'newsletter/subscribe',
            data: form.serialize(),
            success: function(result){
                // form data successfully reached form processor api
                if(result.success){
                    // message successfully sent
                    var msg = "<b>Thank you</b> <br>";
                    form.find('.form-result').parent().removeClass('hidden');
                    form.find('.form-result').html(msg += result.message);
                } else {
                    form.find('.form-result').parent().removeClass('hidden');
                    // an issue was encountered
                    if(result.errors == undefined || result.errors == null || result.errors.length == 0){
                        // no validation errors - an email sending error was encountered
                        var msg = "<b>Ooops</b> <br>";
                        form.find('.form-result').html(msg += result.message);
                    } else {
                        // a validation error was encountered
                        var msg = "<b>Oops</b> <br>";
                        if(result.errors.indexOf('email') != -1){ 
                            msg += "Please, insert a valid email.";
                        }
                        if(result.errors.indexOf('website') != -1){ 
                            msg += "We're robots!";
                        }
                        form.find('.form-result').html(msg);
                    }
                }
            },
            error: function(result){
                // the form was unable to reach processor api
                form.find('.form-result').text('Error '+ result.status + ' - unable to process form: ' + result.statusText);
            },
            dataType: 'json'
        });
    });
</script>

You can use Uniform in the route just like you would in a controller:

array(
   'pattern' => 'newsletter/subscribe',
   'method' => 'POST',
   'action' => function() {
      // check whether this is an ajax request, and respond with an error if it isn't
      if (!kirby()->request()->ajax()) return site()->errorPage();
      
      $form = uniform('newsletter-subscription', [
         'required' => [
            'do_website' => '',
            '_from' => 'email'
         ],
         'actions' => [[
            '_action' => 'mailchimp',
            'api_key' => 'xxxxxxxxxxxxxxxxxx-us6',
            'list_id' => 'xxxxxxxxxxxx', 
         ]]
      ]);

      // get the names of all erroneous fields
      $errors = array_keys(array_filter(get(), function ($field) use ($form) {
         return $form->hasError($field);
      }, ARRAY_FILTER_USE_KEY));

      return response::json([
         'success' => $form->successful(),
         'message' => $form->message(),
         'errors' => $errors,
      ]);
})

This allows you to simplify the action a bit:

uniform::$actions['mailchimp'] = function ($form, $actionOptions) {
   require_once __DIR__.DS.'mailchimp'.DS.'src'.DS.'VPS'.DS.'MailChimp.php';

   $mc = new \VPS\MailChimp($actionOptions['api_key']);
   $list_id = $actionOptions['list_id']; 

   $response = $mc->post("/lists/{$list_id}/members", [
      'email_address' => $form['_from'],
      'merge_fields' => ['DO_WEBSITE' => $form['do_website']],
      'status' => 'subscribed'
   ]);


   // Email already in the list
   if ($response['status'] == 400) {
      return [
         'success' => true,
         'message' => 'Thanks, your email was already in the list.',
      ];
   // sloppy way to check if the API returned a pseudo "200" status
   } else if (isset($response['id'])) {
      return [
         'success' => true,
         'message' => 'Thank you, soon you\'ll hear from us.',
      ];
   } else {
      return [
         'success' => false,
         'message' => 'Sorry, something went wrong.',
      ];
   }
}

Note that the do_website field is not yet validated properly (Uniform only checks if it is not empty). If you tell me what this field is for we can implement a custom validation function for it.

It looks much cleaner now… and using the solid Uniform’s validation… thanks.
The validations is not working thou… when the form sends either valid, invalid emails or an empty input… the json always returns:

{
    "success": false,
    "message": "",
    "errors": []
}

I had to change my router’s pattern because it wasn’t working in grandchildren pages.
Also had to change the ajax url call to include the full caller’s URL:

$.ajax({
  type: 'POST',
  url: '<?php echo $page->url() ?>/newsletter/subscribe',
  data: form.serialize(),
  continues...

Here’s my router:

array(
    // in 'pattern', enter the same url being called from your ajax javascript function
    'pattern' => '(:all)/newsletter/subscribe',
    'method' => 'POST',
    'action' => function() {

      // check whether this is an ajax request, and respond with an error if it isn't
      if (!kirby()->request()->ajax()) return site()->errorPage();

      $newsletter = uniform('newsletter-subscription', [
         'required' => [
            'do_website' => 'sim',
            '_from' => 'email'
         ],
         'actions' => [[
            '_action' => 'mailchimp',
            'api_key' => 'xxxxxxxxxxxxxxxxxxxxxxx-us6',
            'list_id' => 'xxxxxxxxxx', 
         ]]
      ]);

      // get the names of all erroneous fields
      $errors = array_keys(array_filter(get(), function ($field) use ($newsletter) {
         return $newsletter->hasError($field);
      }, ARRAY_FILTER_USE_KEY));


      return response::json([
         'success' => $newsletter->successful(),
         'message' => trim($newsletter->message()),
         'errors' => $errors,
      ]); 


    }
  )

do_website is just a string with a value like: "yes" from a hidden input field… which should be saved to mailchimp.

What about the token? Should I include it? Will it make this form any more secure? Can you find any security flaw in this process?

You always have to include the token with Uniform because forms without a token like that are vulnerable to CSRF attacks. Without the correct token Uniform will ignore all requests and behave as you described.


'required' => [
   'do_website' => 'sim',
   '_from' => 'email'
],

This can’t work as there is no sim validator function. If this value is always the same then why do you include it in the form at all? Just set it in your action:

'merge_fields' => ['DO_WEBSITE' => 'sim'],

'pattern' => '(:all)/newsletter/subscribe',

Are you sure this is the right pattern for the route? This will match all routes like home/newsletter/subscribe, contact/newsletter/subscribe, error/newsletter/subscribe, etc. You usually want just one endpoint for a form submission, like api/newsletter/subscribe. Can you explain why you configured the route like that?

I keep forgetting the basics… my bad, totally :expressionless:

Question: for this form only, is there a way to show custom error messages for email validations (empty or invalid) instead of the default one? I kinda need a different feedback to assist the user. The success ones are ok because they come from the uniform action.

The javascript part of the validations wasn’t working when the request originated from grandchildren pages (i.e.: mysite.com/some-page/sub-page). Hence I also changed the js after the form like…


$.ajax({
  type: 'POST',
  url: '<?php echo $page->url() ?>/newsletter/subscribe',
  continues...

But now I’ll work on that again because everything has changed since…

Tried to do what you’ve said but the problem remains… grandchildren pages gets the following error on submit:

 ! Fatal error: Uncaught No Uniform actions were given.
 thrown in /Users/me/Sites/masite/site/plugins/uniform/lib/UniForm.php on line 126

Yes, the messages are defined in the language files:

l::set('uniform-fields-required', 'Please fill in all required fields.');
l::set('uniform-fields-not-valid', 'Some fields do not contain valid data.');

This affects all forms on the whole site, though. If you have multiple forms and only one of them should have different messages you have to do a little hack: You have to change the language strings in the controller/route just before the uniform() call and then reset them again afterwards.

And this error doesn’t occur if you use the (:all)/newsletter/subscribe route? This doesn’t make sense :confused:
The route is working properly since Uniform is called but an error like that is only thrown if the actions array is misconfigured/empty. Did you change anything in the route besides the pattern?

I ended up using jquery to show these different messages… not ideal but it’ll do till I figure out how to do this… because this site isn’t multilang so I’ve modified UniForm.php to use c::get instead of l::get… and defined those text messages in the config file.


No… it works perfectly.
I also don’t know why.


Absolutely nothing… these subpages/grandchildrem… they have another form that uses Uniform… but I’ve checked their vars and no naming conflict.

Here’s the whole form/jquery and the router:



<section class="newsletter">

  <form id="newsletter" method="post">

    <input type="hidden" name="_submit" value="<?php echo uniform('newsletter-subscription')->token() ?>">
    <label class="hidden form__potty" for="website">Please leave this field blank</label>
    <input type="text" name="website" id="website" class="hidden form__potty" />


    <div class="hidden" id="feedback">
      <div class="feedback-message">Sending...</div>
    </div>  


    <p>
      <label class="hidden" for="email">Email *</label>
      <input  type="email" name="_from" id="email" value="" placeholder="Email" required />
    </p>


    <p>
      <button type="submit">Sign up</button>
    </p>


  </form>
</section>


<script>
    // AJAX FORM PROCESSING
    $('#newsletter').on('submit', function(e){

        e.preventDefault();
        var form = $(this); 
        var feedback = $('#feedback');
        feedback.removeClass('hidden').slideDown('slow');
        feedback.find('.feedback-message').html('Sending...');

        $.ajax({
            type: 'POST',
            // use the same url here as the 'pattern' in your route
            url: '<?php echo $page->url() ?>/newsletter/subscribe',
            data: form.serialize(),

            // this success means the ajax call reached the api/action
            success: function(response){ 

              if (response.success) { // show messages from mailchimp action 
                var msg = "<b>Thank you</b> <br>"; 
                msg += response.message;
                feedback.find('.feedback-message').html(msg); 

                form.find('#email, button').prop('disabled', 'disabled');

              } else { // a validation error was encountered 
                var msg = "<b>Oops</b> <br>"; 
                msg += "This email looks wrong.";
                feedback.find('.feedback-message').html(msg);
              }

            },
            // the form was unable to reach processor api
            error: function(response){
              feedback.removeClass('hidden');
              feedback.find('.feedback-message').text('An error has occurred.');
            },
            dataType: 'json'
        });

        $('html,body').animate({
            'scrollTop':   $('#newsletter').offset().top
        }, 400);
    });
</script>

The router:

array(
    // in 'pattern', enter the same url being called from your ajax javascript function
    'pattern' => '(:all)/newsletter/subscribe',
    'method' => 'POST',
    'action' => function() {

      // check whether this is an ajax request, and respond with an error if it isn't
      if (!kirby()->request()->ajax()) return site()->errorPage();

      $newsletter = uniform('newsletter-subscription', [
         'required' => [
            '_from' => 'email'
         ],
         'actions' => [[
            '_action' => 'mailchimp',
            'api_key' => 'xxxxxxxxxxxxxxxxxxxxx-us6',
            'list_id' => 'xxxxxxx', 
         ]]
      ]);

      // get the names of all erroneous fields
      $errors = array_keys(array_filter(get(), function ($field) use ($newsletter) {
         return $newsletter->hasError($field);
      }, ARRAY_FILTER_USE_KEY));


      return response::json([
         'success' => $newsletter->successful(),
         'message' => trim($newsletter->message()),
         'errors' => $errors,
      ]); 


    }
  )

1 Like

So everything works as expected now?

Yes indeed Martin… again, thanks a lot for all your help. :vulcan:

Wow, epic thread!
The resulting code would make for a great “how-to” post :slight_smile:

3 Likes