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

I’m having a hard time trying to figure out how to create a simple newsletter subscription form… :sweat:

Features

  • All pages/urls in my site will have a newsletter subscription form (snippet);
  • Integrated with Mailchimp’s API;
  • Uses Uniform to validade, send and to get error/success feedbacks;
  • All via Ajax (jQuery).

Questions

  1. Since the form will be in all pages, is using controllers out of question?
  2. What to use then? A route that loads a template and passes some values to it?
  3. The Uniform plugin will have to use the webhook action with JSON=true option?
  4. Will I have to serialize or somehow manipulate headers like @mzur did in Uniform here??
  5. Will I have to disable the cache for this to work or via ajax is cool?

This is how I pictured the actions/infomations would flow…

I’m stuck… any help?

i did get the uniform data in php and also php to call mailchimp via rest using a wrapper (https://github.com/vatps/mailchimp-rest-api).

As regards the controller, there will most probably be a default controller in the upcoming Kirby version: https://github.com/getkirby/kirby/pull/365

Other than that, you can always put the logic into the snippet.

A default page controller would be super… loads of use cases, specially for those procedural prone people like me :slight_smile:

The wrapper looks solid, should be easy to adapt it to work with kirby.
Could it be converted to a email::service?

Wouldn’t this create yet another level of abstraction? I mean, if Uniform sends Json formatted data and jquery’s $.post would take care of DOM manipulation after receiving the replies from mail chimp… then there’d be no need for a wrapper?

I’m sure missing something… for example, the wrapper sends the request through curl… why? What are the benefits?

If you want to have an AJAX form you only need a single page “endpoint” anyway and there is no need for multiple or default controllers. You’ve already found the tutorial, all you need to do is implement the form that way (your contact/send page “endpoint” might be newsletter/subscribe). You then can duplicate the HTML and JS part all over your page (with a snippet) because no matter on which page the form is displayed, all requests will be sent to newsletter/subscribe.

You might have to disable the cache either way because Uniform’s CSRF token changes e.g. for each visitor.

I don’t know if the webhook action is flexible enough to handle all possible responses. I’ve personally not used it yet and didn’t get any feedback on it so either it works fine for all use cases or nobody ever used it :smile: You can definitely try and use it but the safer bet might be to simply implement a new Uniform action (no email service) using the wrapper @bnomei mentioned.

Using cURL is a convenient way for sending HTTP requests with PHP. The Uniform webhook action uses it, too, through the remote helper of the Kirby Toolkit.

Have you considered a PHP-less approach, too? You can talk directly with the Mailchimp API via AJAX after all. I don’t know how your API token and CSRF is handled this way but it might be an option.

i personally did not want to publish my api key and list ids with a javascript/jquery. this is way to insecure for me. thats why i did use php. and i do set the apikey and list id as a panel field, so i need to forward that data somehow, too.

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…