Content Paywall

A stack of components and parsers to protect locked content

Part of putting together a paid courses is giving people an idea of what content they’re be getting, but not so much where there’s no point in them signing up to support your efforts.

Here’s what the paywall looks like so far on my Phlex for Rails course.

Screenshot of a paywall

Let’s take a closer look.

Markdown redactor

At the core of the paywall is a parser that replaces characters with a unicode placaholder. It parses the output of the markdown HTML and splits it into a preview, which people can see, and redacted content, which has the unicode ▓ charactors.

# ./lib/paywall.rb
class Paywall
  REDACTED_CHARACTER = "▓"

  attr_reader :preview, :redacted

  def initialize(html)
    @dom = Nokogiri::HTML.fragment(html)
    @preview = slice_nodeset(@dom, 0...5).to_html.html_safe
    @redacted = redact(slice_nodeset(@dom, 5..-1)).to_html.html_safe
  end

  private

  def redact(dom)
    dom.css("p").each do |element|
      element.content = redact_text element.content
    end

    dom.css(".locked").each do |element|
      element.content = redact_text element.content
    end

    dom
  end

  def slice_nodeset(dom, range)
    nodes = dom.children.to_a
    slice = nodes[range] || []
    Nokogiri::XML::NodeSet.new(dom.document, slice)
  end

  def redact_text(text)
    text.gsub(/\S+/) { |word| REDACTED_CHARACTER * word.length }
  end
end

This makes it a real redaction since a savvy user can’t tweak some CSS to reveal the content.

Paywall component

In my video page layout, I have a component that captures the output of the markdown HTML and stuffs it into a Component::Paywall.

<article class="prose prose-pre:bg-base-content dark:prose-pre:bg-transparent max-w-full">
  <h2><%= video_page.title %></h2>

  <%= render Components::Paywall.new(locked: video_page.status.locked?) do %>
    <%= yield %>
  <% end %>
</article>

The component handles the visual side of the paywall, not to be confused with the first Paywall class that redacts the content from the HTML.

class Components::Paywall < Components::Base
  def initialize(locked: true)
    @locked = locked
  end

  def locked? = @locked

  def around_template(&)
    if @locked
      # Captures and parses the locked and unlocked content.
      wall = ::Paywall.new helpers.capture(&)

      # Render the unlocked content HTML.
      raw wall.preview

      # Wrap the preview content in a paywall message.
      a(
        href: "/phlex#invest",
        class: "not-prose block bg-base-100/90 backdrop-blur-md rounded-2xl sticky top-1/2 transform -translate-y-1/2 shadow-xl z-10 m-4 p-4 hover:scale-101 hover:bg-base-100 hover:shadow-2xl transition space-y-2"
      ) {
        h2(class: "text-lg font-bold"){ "🔓 Unlock content"}
        p { "Pre-order this course to unlock this video, source code, and content" }
        div(class: "btn btn-sm btn-outline") {
          plain "Pre-order video course for"
          whitespace
          span(class: "line-through font-light") { "$379" }
          whitespace
          plain "$249"
        }
      }
      div(class: "opacity-75") {
        # Renders the redacted HTML content.
        raw wall.redacted
      }
    else
      yield
    end
  end
end

I don’t love the design of the UI yet, but it’s functional. I also don’t love how this is implemented in the around_template method, but I’m not sure how to capture the contents of the block yet. I might take a different approach to this since its a very odd way to implement a Phlex component. Perhaps I’ll pass the current_page into the Paywall component as the argument, have the component render it, and make the block the upsell message.

Checkout the paywall in action and consider ordering the course if you want to support this website.

Do you want to learn Phlex 💪 and enjoy these code examples?

Support Beautiful Ruby by pre-ordering the Phlex on Rails video course.

Pre-order the video course for $379 $249