Dynamically load Cashier Paddle buttons with renderless component

Reece May • November 13, 2020 • 7 min read

laravel paddle cashier vue

A project I have been working on has been in need of an improved billing system, with the release of the cashier-paddle package I was able to add some subscriptions to the app.

Working with the package, one can see that it's relatively easy and fluent to get paddle / cashier working in your Laravel app. And this is great!

Noticing the load times on generating links

I noticed after a while that sometimes the loading of the payment links took a while. Taking into account the API is requested for each link. Making in turn the response from the server slow, ... ... ... waiting for each link to be generated.

Obviously, the immediate thing here is that if this isn't an SPA that doesn't have to get new pages, you will notice. And you would notice on an SPA 🤷‍♂️

Now, this is greatly exaggerated if you are showing a bunch of paddle links for a user to choose. That sucks really.

So I decided to make it a little more enjoyable to load up a bunch of possible subscriptions for an end user, and also make the vue component renderless. This makes it easier for me to port it between a few projects.

So off to work on a 'simplification'.

First thing, the original blade component file:

<a href="#!" data-override="{{ $url }}" {{ $attributes->merge(['class' => 'paddle_button']) }}>
    {{ $slot }}
</a>

Basically, the important things here are: href="#!", data-override="" and the css class paddle_button

Awesome, so when the component is made we just need to make sure those are there in either out component or the added ones.

Please note, there isn't any need to move away from the blade component, it's an awesome way of rendering the links each time. For this instance it didn't always work

Renderless Paddle Subscription Component

Now to the component, it is in Vuejs because currently that is the frontend it was made for :)

We can create a js file called Subscriptions.js under your js folder, your choice. Mine are in a components folder.

We know that we need to hold the links that are retrieved, a loading state and error state, so lets get those into the component

export default {
    data() {
        return {
            payLinks: [],
            loaded: false,
            error: false,
        },
    },
    render() {
        return this.$slots.default[0];
    }
}

This would be our basic component that we can return. But, I wanted to pass some things down to the elements in the slot area to use.

So we make use of the $scopedSlots property, this allows us to pass data to the slots, and then pick up those in our html side.

export default {
    data() {
        return {
            payLinks: [],
            loaded: false,
            error: false,
        },
    },
    render() {
-       return this.$slots.default[0];
+       return this.$scopedSlots.default({
+            payLinks: this.payLinks,
+            loaded: this.loaded,
+            error: this.error,
+       })
    }
}

Fetching the Paddle PayLinks from the backend over an API endpoint

Now the fun part, getting the paddle links from the server and loading them into the component

I initially had the url for the endpoint hard coded, but then realized it would be better to have it as a prop, can then use something like: endpoint="{{ route('api.billing.links') }}".

Change the component to include the following:

export default {
    data() {
        ...
    },
+   props: {
+       /**
+        * The api endpoint to fetch the billing info
+        */
+        endpoint: {
+            type: String,
+            default: '/api/settings/billing'
+        }
+    },
+   methods: {
+        fetch() {
+            axios.get(this.endpoint)
+                .then(({
+                    data: { data }
+                }) => {
+                    this.payLinks = data;
+                    this.loaded = true;
+                    this.error = false;
+                })
+                .catch(error => {
+                    this.loaded = false;
+                    this.error = true;
+                    throw error;
+                });
+       }
+    },
    render() {
        return this.$scopedSlots.default({
            payLinks: this.payLinks,
            loaded: this.loaded,
            error: this.error,
+           fetch: this.fetch
        });
    }
}

The method fetch uses the endpoint value in the axios request, this in turn loads the data from the response into the payLinks url.

We also add the fetch method to the output of the slot scope, enabling us to trigger a reload from outside the component

Adding some messages and feedback

export default {
    data() {
        return {
            payLinks: [],
            loaded: false,
            error: false,
+           message: 'Loading Subscriptions ...',
        }
    },

    ... // props

    methods: {
        fetch() {
            axios.get(this.endpoint)
                .then(({
                    data: { data }
                }) => {
                    this.payLinks = data;
                    this.loaded = true;
                    this.error = false;
                })
                .catch(error => {
                    this.loaded = false;
                    this.error = true;
+                    this.message = 'Failed to load current subscriptions';
                    throw error;
                });
        }
    },
    render() {
        return this.$scopedSlots.default({
            payLinks: this.payLinks,
            loaded: this.loaded,
            error: this.error,
+           message: this.message,
            fetch: this.fetch
        });
    }
}

Loading Paddle after getting the links.

When using the Laravel cashier-paddle package, you use the @paddleJs directive, this loads buttons that are already there. Not our vue rendered checkout button.

When checking the methods available on the Paddle object that is loaded you find that the Paddle JS file has some methods that are callable, and one that instantiates the buttons, and we can call it, awesome stuff.

So, what we do is to make use of the fact that because we are rendering the data inside the component after fetching it, we can rely on the updated method to reload the Paddle buttons. This is done using Paddle.Button.load()

So what we can do is add a new method and stick that into the updated handler:

export default {

+   updated() {
+       this.loadPaddle()
+   },

    methods: {
+       loadPaddle() {
+           // check if paddle is loaded.
+           if (Paddle) {
+               try {
+                   Paddle.Button.load();
+               } catch (error) {
+                   console.error(error);
+               }
+           }
+       }       
    },
}

Loading up the Paddle links

Right so our file now has the needed methods and data properties, the final bit, and probably really, really important is the css paddle_button.

Now lets look at the mounted method, this is where we are going to call all the methods and add the paddle_button class to our links.

    mounted() {
        this.$nextTick(() => {
            this.fetch();

            this.$el.querySelectorAll('a')
                .forEach(element => {
                    if (element.href === '#!') {
                        element.classList.add('paddle_button');
                    }
                });
        });
    },

Basically, after the component is mounted, we wait till the next tick from vue and all that, fetch the links from the backend.

We then use some good ol' Javascript 😁. Using the querySelectorAll('a') function, we can get all the links that happen to be in the slot area, loop through them, then add the class paddle_button on any link that has the href of #!. That way we don't load up other random urls to have the paddle stuff.

Final JS file

Below is the final component file, a lot nicer than the bunch of diff markings all over...

/**
 * Paddle Subscription button renderless component.
 *
 * @author ReeceM
 * @copyright MIT
 * @filename Subscriptions.js
 */

// import axios from 'axios'; // optional..

export default {
    data() {
        return {
            payLinks: [],
            loaded: false,
            error: false,
            message: 'Loading Subscriptions ...',
        }
    },

    props: {
        /**
         * The api endpoint to fetch the billing info
         */
        endpoint: {
            type: String,
            default: '/api/settings/billing'
        }
    },

    mounted() {
        this.$nextTick(() => {
            this.fetch();

            this.$el.querySelectorAll('a')
                .forEach(element => {
                    if (element.href === '#!') {
                        element.classList.add('paddle_button');
                    }
                });
        });
    },

    updated() {
        this.loadPaddle()
    },

    methods: {
        /**
         * Fetch the Paddle Paylinks from the server.
         */
        fetch() {

            this.error = false;
            this.loaded = false;

            axios.get(this.endpoint)
                .then(({
                    data: { data }
                }) => {
                    this.payLinks = data;
                    this.loaded = true;
                })
                .catch(error => {
                    this.message = 'Failed to load current subscriptions';
                    this.loaded = false;
                    this.error = true;

                    throw error;
                });
        },

        /**
         * reload the Paddle buttons
         */
        loadPaddle() {
            Paddle ? Paddle.Button.load() : console.warn('Up a creek!');
        }
    },

    render() {
        return this.$scopedSlots.default({
            payLinks: this.payLinks,
            loaded: this.loaded,
            error: this.error,
            message: this.message,
            fetch: this.fetch
        });
    }
}

Example usage

Below is an example that uses the laravel blade file, and then builds up a table with the buttons and the paylinks. The styling is in Bulma.

<subscriptions>
    <template v-slot="{payLinks, message, loaded, error, fetch}">
        <table class="table is-striped is-fullwidth table-bordered">
            <tbody>
                <tr v-if="!loaded">
                    <td v-text="message"></td>
                    <td v-if="error">
                        <div class="notification is-warning">
                            <button
                                @click=fetch()
                                class="button is-small is-outlined">
                                Retry
                            </button>
                        </div>
                    </td>
                </tr>
                <tr v-else 
                    v-for="payLink in payLinks" 
                    :key="payLink.title" >
                    <td>@{{ payLink['title'] }} Plan</td>
                    <td>terms</td>
                    <td>
                        <a
                            href="#!"
                            :data-override="payLink['link']"
                            data-theme="none"

                            class="button is-info"
                            :class="{'is-success': payLink['current']}" >
                            @{{payLink['current'] === true ? 'Subscribed' : 'Subscribe'}}
                        </a>
                    </td>
                </tr>
            </tbody>
        </table>
    </template>
</subscriptions>

renderless_paddle_paylink_subscribe_button.gif

To make use of the slot scope, we need to use a <template> element, then add the v-slot="{payLinks, message, loaded, error, fetch}" attribute, this allows us to access the needed data.

From there we can use the stuff inside the blade file to change how we render the Subscription links, this is nice because we don't have to recompile the JS file every time we need to make a change to a bit of wording.

Just don't forget the @ before the handlebar tags, or you going to end up with a bunch of errors from the blade engine.

I will share the follow up article to this on an example backend controller to get the links with.


I hope you enjoyed the post, please share any feedback you have on it or thoughts, you can give me a shout on twitter with @iexistin3d.

0_o