paddle_rails 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +294 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/paddle_rails/application.css +16 -0
- data/app/assets/stylesheets/paddle_rails/tailwind.css +1824 -0
- data/app/assets/tailwind/application.css +1 -0
- data/app/controllers/concerns/paddle_rails/paddle_checkout_error_handler.rb +89 -0
- data/app/controllers/concerns/paddle_rails/subscription_owner.rb +16 -0
- data/app/controllers/paddle_rails/application_controller.rb +21 -0
- data/app/controllers/paddle_rails/checkout_controller.rb +121 -0
- data/app/controllers/paddle_rails/dashboard_controller.rb +37 -0
- data/app/controllers/paddle_rails/onboarding_controller.rb +55 -0
- data/app/controllers/paddle_rails/payments_controller.rb +62 -0
- data/app/controllers/paddle_rails/subscriptions_controller.rb +92 -0
- data/app/controllers/paddle_rails/webhooks_controller.rb +78 -0
- data/app/helpers/paddle_rails/application_helper.rb +121 -0
- data/app/helpers/paddle_rails/subscription_owner_helper.rb +14 -0
- data/app/jobs/paddle_rails/application_job.rb +4 -0
- data/app/jobs/paddle_rails/process_webhook_job.rb +38 -0
- data/app/mailers/paddle_rails/application_mailer.rb +6 -0
- data/app/models/concerns/paddle_rails/subscribable.rb +46 -0
- data/app/models/paddle_rails/application_record.rb +5 -0
- data/app/models/paddle_rails/payment.rb +43 -0
- data/app/models/paddle_rails/price.rb +25 -0
- data/app/models/paddle_rails/product.rb +16 -0
- data/app/models/paddle_rails/subscription.rb +87 -0
- data/app/models/paddle_rails/subscription_item.rb +35 -0
- data/app/models/paddle_rails/webhook_event.rb +51 -0
- data/app/presenters/paddle_rails/payment_presenter.rb +96 -0
- data/app/presenters/paddle_rails/product_presenter.rb +178 -0
- data/app/presenters/paddle_rails/subscription_presenter.rb +145 -0
- data/app/views/layouts/paddle_rails/application.html.erb +170 -0
- data/app/views/paddle_rails/checkout/show.html.erb +128 -0
- data/app/views/paddle_rails/dashboard/_change_plan.html.erb +286 -0
- data/app/views/paddle_rails/dashboard/_current_subscription.html.erb +66 -0
- data/app/views/paddle_rails/dashboard/_payment_history.html.erb +79 -0
- data/app/views/paddle_rails/dashboard/_payment_method.html.erb +48 -0
- data/app/views/paddle_rails/dashboard/show.html.erb +47 -0
- data/app/views/paddle_rails/onboarding/show.html.erb +100 -0
- data/app/views/paddle_rails/shared/configuration_error.html.erb +94 -0
- data/config/routes.rb +13 -0
- data/db/migrate/20251124180624_create_paddle_rails_subscription_plans.rb +18 -0
- data/db/migrate/20251124180817_create_paddle_rails_subscription_prices.rb +26 -0
- data/db/migrate/20251127221947_create_paddle_rails_webhook_events.rb +19 -0
- data/db/migrate/20251128135831_create_paddle_rails_subscriptions.rb +21 -0
- data/db/migrate/20251128142327_create_paddle_rails_subscription_items.rb +16 -0
- data/db/migrate/20251128151334_remove_paddle_price_id_from_subscriptions.rb +7 -0
- data/db/migrate/20251128151401_rename_subscription_plans_to_products.rb +6 -0
- data/db/migrate/20251128151402_rename_subscription_plan_id_to_subscription_product_id.rb +13 -0
- data/db/migrate/20251128151453_remove_subscription_price_id_from_subscriptions.rb +8 -0
- data/db/migrate/20251128151501_add_subscription_product_id_to_subscription_items.rb +8 -0
- data/db/migrate/20251128152025_remove_paddle_item_id_from_subscription_items.rb +6 -0
- data/db/migrate/20251128212046_rename_subscription_products_to_products.rb +6 -0
- data/db/migrate/20251128212047_rename_subscription_prices_to_prices.rb +6 -0
- data/db/migrate/20251128212053_rename_subscription_product_id_to_product_id_in_prices.rb +13 -0
- data/db/migrate/20251128212054_rename_fks_in_subscription_items.rb +20 -0
- data/db/migrate/20251128220016_add_scheduled_cancelation_at_to_subscriptions.rb +6 -0
- data/db/migrate/20251129121336_add_payment_method_to_subscriptions.rb +10 -0
- data/db/migrate/20251129222345_create_paddle_rails_payments.rb +24 -0
- data/lib/paddle_rails/checkout.rb +181 -0
- data/lib/paddle_rails/configuration.rb +121 -0
- data/lib/paddle_rails/engine.rb +49 -0
- data/lib/paddle_rails/product_sync.rb +176 -0
- data/lib/paddle_rails/subscription_sync.rb +303 -0
- data/lib/paddle_rails/version.rb +6 -0
- data/lib/paddle_rails/webhook_processor.rb +102 -0
- data/lib/paddle_rails/webhook_verifier.rb +110 -0
- data/lib/paddle_rails.rb +32 -0
- data/lib/tasks/paddle_rails_tasks.rake +15 -0
- metadata +157 -0
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
# Presenter for products on the onboarding page.
|
|
5
|
+
#
|
|
6
|
+
# Wraps a {Product} and its active {Price} records
|
|
7
|
+
# and exposes formatted data for the onboarding view so the template
|
|
8
|
+
# can stay mostly declarative and free from business logic.
|
|
9
|
+
#
|
|
10
|
+
# @example Building presenters in the controller
|
|
11
|
+
# products = Product.active.includes(:prices)
|
|
12
|
+
# @products = products.each_with_index.map do |product, index|
|
|
13
|
+
# PaddleRails::ProductPresenter.new(product, index: index)
|
|
14
|
+
# end
|
|
15
|
+
#
|
|
16
|
+
# @example Using presenter methods in the view
|
|
17
|
+
# <% @products.each do |product| %>
|
|
18
|
+
# <%= product.name %>
|
|
19
|
+
# <%= product.primary_label %> <!-- e.g. "29 EUR / month" -->
|
|
20
|
+
# <% end %>
|
|
21
|
+
class ProductPresenter
|
|
22
|
+
attr_reader :product, :index
|
|
23
|
+
|
|
24
|
+
# Initialize a new presenter.
|
|
25
|
+
#
|
|
26
|
+
# @param product [PaddleRails::Product] the product being presented
|
|
27
|
+
# @param index [Integer] zero-based index of the product in the list
|
|
28
|
+
# @param default_currency [String] fallback currency code, defaults to "EUR"
|
|
29
|
+
def initialize(product, index: 0, default_currency: "EUR")
|
|
30
|
+
@product = product
|
|
31
|
+
@index = index
|
|
32
|
+
@default_currency = default_currency
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# Display name for the product.
|
|
36
|
+
#
|
|
37
|
+
# Falls back to `"Product #{index + 1}"` when the record has no name.
|
|
38
|
+
#
|
|
39
|
+
# @return [String]
|
|
40
|
+
def name
|
|
41
|
+
product.name.presence || "Product #{index + 1}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Description text for the product.
|
|
45
|
+
#
|
|
46
|
+
# @return [String, nil]
|
|
47
|
+
def description
|
|
48
|
+
product.description
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# All active prices for the product, ordered by unit price.
|
|
52
|
+
#
|
|
53
|
+
# @return [ActiveRecord::Relation<PaddleRails::Price>]
|
|
54
|
+
def prices
|
|
55
|
+
@prices ||= product.prices.active.order(:unit_price)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Whether the product has any active prices.
|
|
59
|
+
#
|
|
60
|
+
# @return [Boolean]
|
|
61
|
+
def any_prices?
|
|
62
|
+
prices.any?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Whether the product has more than one active price.
|
|
66
|
+
#
|
|
67
|
+
# @return [Boolean]
|
|
68
|
+
def multiple_prices?
|
|
69
|
+
prices.many?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# The primary price used for the default selection.
|
|
73
|
+
#
|
|
74
|
+
# @return [PaddleRails::Price, nil]
|
|
75
|
+
def primary_price
|
|
76
|
+
prices.first
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# The formatted primary amount, converted from minor units.
|
|
80
|
+
#
|
|
81
|
+
# @return [Integer] whole amount (e.g. 29 for 29.00)
|
|
82
|
+
def primary_amount
|
|
83
|
+
amount_for(primary_price)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The currency code for the primary price.
|
|
87
|
+
#
|
|
88
|
+
# @return [String] e.g. "EUR"
|
|
89
|
+
def primary_currency
|
|
90
|
+
currency_for(primary_price)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The human-readable billing interval for the primary price.
|
|
94
|
+
#
|
|
95
|
+
# @return [String] e.g. "month", "12 months", or "one-time"
|
|
96
|
+
def primary_billing
|
|
97
|
+
billing_for(primary_price)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Full label for the primary price.
|
|
101
|
+
#
|
|
102
|
+
# @return [String] e.g. "29 EUR / month"
|
|
103
|
+
def primary_label
|
|
104
|
+
label_for(primary_price)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Paddle price ID of the primary price.
|
|
108
|
+
#
|
|
109
|
+
# @return [String, nil]
|
|
110
|
+
def primary_price_id
|
|
111
|
+
primary_price&.paddle_price_id
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Trial days for the primary price, if any.
|
|
115
|
+
#
|
|
116
|
+
# @return [Integer, nil]
|
|
117
|
+
def trial_days
|
|
118
|
+
primary_price&.trial_days
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Returns an array of [paddle_price_id, label] pairs
|
|
122
|
+
# for all active prices on this product.
|
|
123
|
+
#
|
|
124
|
+
# @return [Array<Array(String, String)>]
|
|
125
|
+
def price_options
|
|
126
|
+
prices.map { |price| [price.paddle_price_id, label_for(price)] }
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
private
|
|
130
|
+
|
|
131
|
+
# Convert a price's unit_price (stored in minor units) to an integer amount.
|
|
132
|
+
#
|
|
133
|
+
# @param price [PaddleRails::Price, nil]
|
|
134
|
+
# @return [Integer]
|
|
135
|
+
def amount_for(price)
|
|
136
|
+
return 0 unless price&.unit_price
|
|
137
|
+
|
|
138
|
+
(price.unit_price / 100.0).to_i
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Resolve a price's currency or fall back to the default.
|
|
142
|
+
#
|
|
143
|
+
# @param price [PaddleRails::Price, nil]
|
|
144
|
+
# @return [String]
|
|
145
|
+
def currency_for(price)
|
|
146
|
+
(price&.currency || @default_currency).upcase
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Build a human-readable billing interval from Paddle data.
|
|
150
|
+
#
|
|
151
|
+
# @param price [PaddleRails::Price, nil]
|
|
152
|
+
# @return [String]
|
|
153
|
+
def billing_for(price)
|
|
154
|
+
return "one-time" unless price&.billing_interval.present?
|
|
155
|
+
|
|
156
|
+
interval = price.billing_interval.to_s.downcase
|
|
157
|
+
count = (price.billing_interval_count || 1).to_i
|
|
158
|
+
|
|
159
|
+
case interval
|
|
160
|
+
when "month"
|
|
161
|
+
count == 1 ? "month" : "#{count} months"
|
|
162
|
+
when "year"
|
|
163
|
+
count == 1 ? "year" : "#{count} years"
|
|
164
|
+
else
|
|
165
|
+
price.billing_interval
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Build a full label for a price.
|
|
170
|
+
#
|
|
171
|
+
# @param price [PaddleRails::Price, nil]
|
|
172
|
+
# @return [String]
|
|
173
|
+
def label_for(price)
|
|
174
|
+
"#{amount_for(price)} #{currency_for(price)} / #{billing_for(price)}"
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PaddleRails
|
|
4
|
+
class SubscriptionPresenter
|
|
5
|
+
attr_reader :subscription
|
|
6
|
+
|
|
7
|
+
delegate :status, :items, to: :subscription
|
|
8
|
+
|
|
9
|
+
def initialize(subscription)
|
|
10
|
+
@subscription = subscription
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def product_name
|
|
14
|
+
subscription.product&.name || "Subscription"
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def product_initial
|
|
18
|
+
(subscription.product&.name&.chars&.first || "S").upcase
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def status_label
|
|
22
|
+
status.titleize
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def amount
|
|
26
|
+
total_cents = items.sum do |item|
|
|
27
|
+
next 0 unless item.price && item.price.unit_price
|
|
28
|
+
item.price.unit_price * item.quantity
|
|
29
|
+
end
|
|
30
|
+
(total_cents / 100.0)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def currency
|
|
34
|
+
# Assuming all items have the same currency, which is standard for a single subscription.
|
|
35
|
+
# We can check the first item's price currency.
|
|
36
|
+
first_item_with_price = items.find { |i| i.price.present? }
|
|
37
|
+
return "" unless first_item_with_price
|
|
38
|
+
first_item_with_price.price.currency.upcase
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def formatted_amount
|
|
42
|
+
# We'll let the view handle number_to_currency for now to keep view helpers available.
|
|
43
|
+
amount
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def currency_symbol
|
|
47
|
+
return "$" if currency.blank?
|
|
48
|
+
currency == "EUR" ? "€" : "$"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def interval_label
|
|
52
|
+
# Use the interval from the first recurring item, or fallback to the first item.
|
|
53
|
+
# Usually all items in a subscription share the billing cycle.
|
|
54
|
+
item = items.find { |i| i.recurring? && i.price.present? } || items.find { |i| i.price.present? }
|
|
55
|
+
return "" unless item&.price
|
|
56
|
+
|
|
57
|
+
interval = item.price.billing_interval
|
|
58
|
+
count = item.price.billing_interval_count || 1
|
|
59
|
+
|
|
60
|
+
case interval
|
|
61
|
+
when "month" then (count == 1 ? "month" : "#{count} months")
|
|
62
|
+
when "year" then (count == 1 ? "year" : "#{count} years")
|
|
63
|
+
else interval
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def billing_date_title
|
|
68
|
+
if subscription.scheduled_for_cancellation?
|
|
69
|
+
"Scheduled for cancellation"
|
|
70
|
+
elsif subscription.canceled?
|
|
71
|
+
"Canceled on"
|
|
72
|
+
else
|
|
73
|
+
"Next billing date"
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def billing_date
|
|
78
|
+
date = if subscription.scheduled_for_cancellation?
|
|
79
|
+
subscription.scheduled_cancelation_at
|
|
80
|
+
elsif subscription.canceled?
|
|
81
|
+
subscription.updated_at
|
|
82
|
+
else
|
|
83
|
+
subscription.current_period_end_at
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
date&.strftime("%B %d, %Y") || "N/A"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def price_display_text
|
|
90
|
+
return "Custom plan" if amount.zero?
|
|
91
|
+
# View will handle currency formatting
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def has_price?
|
|
96
|
+
amount > 0
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def payment_method_type
|
|
100
|
+
subscription.payment_method_type
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
def has_payment_method?
|
|
104
|
+
subscription.payment_method_id.present?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def card_brand
|
|
108
|
+
return nil unless has_payment_method?
|
|
109
|
+
details = subscription.payment_method_details || {}
|
|
110
|
+
card = details["card"] || details[:card] || {}
|
|
111
|
+
(card["brand"] || card[:brand] || "").upcase
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def card_last4
|
|
115
|
+
return nil unless has_payment_method?
|
|
116
|
+
details = subscription.payment_method_details || {}
|
|
117
|
+
card = details["card"] || details[:card] || {}
|
|
118
|
+
card["last4"] || card[:last4]
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def card_expiration
|
|
122
|
+
return nil unless has_payment_method?
|
|
123
|
+
details = subscription.payment_method_details || {}
|
|
124
|
+
card = details["card"] || details[:card] || {}
|
|
125
|
+
month = card["expiry_month"] || card[:expiry_month]
|
|
126
|
+
year = card["expiry_year"] || card[:expiry_year]
|
|
127
|
+
|
|
128
|
+
return nil unless month && year
|
|
129
|
+
|
|
130
|
+
# Format as MM/YYYY
|
|
131
|
+
"#{month.to_s.rjust(2, '0')}/#{year}"
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def payment_method_icon
|
|
135
|
+
# Return the brand name for display in the icon area
|
|
136
|
+
# This can be styled with CSS to show appropriate icons
|
|
137
|
+
card_brand.presence || "CARD"
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
private
|
|
141
|
+
|
|
142
|
+
# Removed def price as we now calculate totals across all items
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<title>Paddle rails</title>
|
|
5
|
+
<%= csrf_meta_tags %>
|
|
6
|
+
<%= csp_meta_tag %>
|
|
7
|
+
|
|
8
|
+
<%= yield :head %>
|
|
9
|
+
|
|
10
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
11
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
12
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600&display=swap" rel="stylesheet">
|
|
13
|
+
|
|
14
|
+
<%= stylesheet_link_tag "paddle_rails/application", media: "all" %>
|
|
15
|
+
<%= stylesheet_link_tag "paddle_rails/tailwind", media: "all" %>
|
|
16
|
+
|
|
17
|
+
<style>
|
|
18
|
+
body {
|
|
19
|
+
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
20
|
+
font-weight: 400;
|
|
21
|
+
}
|
|
22
|
+
</style>
|
|
23
|
+
</head>
|
|
24
|
+
<body class="bg-slate-50">
|
|
25
|
+
<div class="flex flex-col md:flex-row md:h-screen md:overflow-hidden">
|
|
26
|
+
<!-- Sidebar - On top on mobile, fixed on left on desktop -->
|
|
27
|
+
<aside class="w-full md:w-80 bg-gradient-to-b from-slate-900 via-slate-900 to-slate-800 flex-shrink-0 flex flex-col relative overflow-hidden">
|
|
28
|
+
<!-- Decorative background elements -->
|
|
29
|
+
<div class="absolute inset-0 overflow-hidden pointer-events-none">
|
|
30
|
+
<div class="absolute -top-24 -right-24 w-64 h-64 bg-indigo-500/10 rounded-full blur-3xl"></div>
|
|
31
|
+
<div class="absolute top-1/2 -left-32 w-64 h-64 bg-purple-500/10 rounded-full blur-3xl"></div>
|
|
32
|
+
<div class="absolute -bottom-32 right-0 w-48 h-48 bg-emerald-500/10 rounded-full blur-3xl"></div>
|
|
33
|
+
</div>
|
|
34
|
+
|
|
35
|
+
<div class="p-6 md:p-8 flex-1 overflow-y-auto relative z-10">
|
|
36
|
+
<!-- Icon badge -->
|
|
37
|
+
<div class="w-14 h-14 rounded-2xl bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center mb-6 shadow-lg shadow-indigo-500/25">
|
|
38
|
+
<svg class="w-7 h-7 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
39
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
40
|
+
</svg>
|
|
41
|
+
</div>
|
|
42
|
+
|
|
43
|
+
<!-- Main Heading -->
|
|
44
|
+
<h1 class="text-2xl md:text-[1.75rem] font-semibold text-white mb-3 leading-tight tracking-tight">
|
|
45
|
+
Billing & subscription
|
|
46
|
+
</h1>
|
|
47
|
+
|
|
48
|
+
<!-- Description -->
|
|
49
|
+
<p class="text-slate-400 text-sm leading-relaxed mb-8">
|
|
50
|
+
Manage your plan, view payment history, download invoices, and update payment methods.
|
|
51
|
+
</p>
|
|
52
|
+
|
|
53
|
+
<!-- Quick Links -->
|
|
54
|
+
<div class="space-y-1 mb-8">
|
|
55
|
+
<p class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-3">Quick links</p>
|
|
56
|
+
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-white/5 transition-all duration-200 group">
|
|
57
|
+
<svg class="w-4 h-4 text-slate-500 group-hover:text-indigo-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
58
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
59
|
+
</svg>
|
|
60
|
+
<span class="text-sm font-medium">Invoices & receipts</span>
|
|
61
|
+
</a>
|
|
62
|
+
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-white/5 transition-all duration-200 group">
|
|
63
|
+
<svg class="w-4 h-4 text-slate-500 group-hover:text-indigo-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
64
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
|
65
|
+
</svg>
|
|
66
|
+
<span class="text-sm font-medium">Payment methods</span>
|
|
67
|
+
</a>
|
|
68
|
+
<a href="#" class="flex items-center gap-3 px-3 py-2.5 rounded-lg text-slate-300 hover:text-white hover:bg-white/5 transition-all duration-200 group">
|
|
69
|
+
<svg class="w-4 h-4 text-slate-500 group-hover:text-indigo-400 transition-colors" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
70
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
71
|
+
</svg>
|
|
72
|
+
<span class="text-sm font-medium">Help & support</span>
|
|
73
|
+
</a>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<!-- Back Link -->
|
|
77
|
+
<% back_path_proc = PaddleRails.configuration.customer_portal_back_path %>
|
|
78
|
+
<% back_path = instance_exec(&back_path_proc) if back_path_proc %>
|
|
79
|
+
<% if back_path.present? %>
|
|
80
|
+
<%= link_to back_path, class: "inline-flex items-center gap-2 px-4 py-2.5 rounded-lg bg-white/5 border border-white/10 text-slate-300 hover:text-white hover:bg-white/10 hover:border-white/20 text-sm font-medium transition-all duration-200" do %>
|
|
81
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
82
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
|
83
|
+
</svg>
|
|
84
|
+
Back to app
|
|
85
|
+
<% end %>
|
|
86
|
+
<% end %>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Footer with User Info -->
|
|
90
|
+
<div class="p-6 md:px-8 md:py-5 border-t border-white/5 relative z-10">
|
|
91
|
+
<% if subscription_owner %>
|
|
92
|
+
<div class="flex items-center justify-between">
|
|
93
|
+
<div class="flex-1 min-w-0">
|
|
94
|
+
<% if subscription_owner.respond_to?(:name) && subscription_owner.name.present? %>
|
|
95
|
+
<p class="text-sm font-medium text-white leading-snug truncate"><%= subscription_owner.name %></p>
|
|
96
|
+
<% if subscription_owner.respond_to?(:email) %>
|
|
97
|
+
<p class="text-xs text-slate-400 leading-snug truncate mt-0.5"><%= subscription_owner.email %></p>
|
|
98
|
+
<% end %>
|
|
99
|
+
<% elsif subscription_owner.respond_to?(:email) %>
|
|
100
|
+
<p class="text-sm font-medium text-white leading-snug truncate"><%= subscription_owner.email %></p>
|
|
101
|
+
<% end %>
|
|
102
|
+
</div>
|
|
103
|
+
<div class="flex items-center gap-1.5 ml-4">
|
|
104
|
+
<div class="w-5 h-5 rounded bg-emerald-500/20 flex items-center justify-center">
|
|
105
|
+
<svg class="w-3 h-3 text-emerald-400" fill="currentColor" viewBox="0 0 20 20">
|
|
106
|
+
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
107
|
+
</svg>
|
|
108
|
+
</div>
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
<% else %>
|
|
112
|
+
<div class="flex items-center gap-2">
|
|
113
|
+
<div class="w-5 h-5 rounded bg-white/10 flex items-center justify-center">
|
|
114
|
+
<svg class="w-3 h-3 text-slate-400" fill="currentColor" viewBox="0 0 20 20">
|
|
115
|
+
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
116
|
+
</svg>
|
|
117
|
+
</div>
|
|
118
|
+
<span class="text-xs text-slate-500">Secure billing portal</span>
|
|
119
|
+
</div>
|
|
120
|
+
<% end %>
|
|
121
|
+
</div>
|
|
122
|
+
</aside>
|
|
123
|
+
|
|
124
|
+
<!-- Scrollable Content Area -->
|
|
125
|
+
<main class="flex-1 overflow-y-auto bg-white">
|
|
126
|
+
<!-- Trust Header -->
|
|
127
|
+
<div class="bg-gradient-to-r from-slate-50 to-slate-100 border-b border-slate-200/60">
|
|
128
|
+
<div class="max-w-6xl mx-auto px-4 md:px-8 py-3 flex items-center justify-between">
|
|
129
|
+
<div class="flex items-center gap-6 text-xs text-slate-600">
|
|
130
|
+
<span class="flex items-center gap-1.5">
|
|
131
|
+
<svg class="w-4 h-4 text-emerald-600" fill="currentColor" viewBox="0 0 20 20">
|
|
132
|
+
<path fill-rule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clip-rule="evenodd" />
|
|
133
|
+
</svg>
|
|
134
|
+
<span class="font-medium">Secure connection</span>
|
|
135
|
+
</span>
|
|
136
|
+
<span class="hidden sm:flex items-center gap-1.5">
|
|
137
|
+
<svg class="w-4 h-4 text-emerald-600" fill="currentColor" viewBox="0 0 20 20">
|
|
138
|
+
<path fill-rule="evenodd" d="M2.166 4.999A11.954 11.954 0 0010 1.944 11.954 11.954 0 0017.834 5c.11.65.166 1.32.166 2.001 0 5.225-3.34 9.67-8 11.317C5.34 16.67 2 12.225 2 7c0-.682.057-1.35.166-2.001zm11.541 3.708a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
139
|
+
</svg>
|
|
140
|
+
<span>256-bit SSL encrypted</span>
|
|
141
|
+
</span>
|
|
142
|
+
</div>
|
|
143
|
+
<div class="flex items-center gap-2 text-xs text-slate-500">
|
|
144
|
+
<span>Powered by</span>
|
|
145
|
+
<span class="font-semibold text-slate-700">Paddle</span>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<!-- Flash Notifications -->
|
|
151
|
+
<% if flash[:notice] || flash[:alert] %>
|
|
152
|
+
<div class="px-4 md:px-8 pt-4 max-w-6xl mx-auto">
|
|
153
|
+
<% if flash[:notice] %>
|
|
154
|
+
<div class="bg-emerald-50 border border-emerald-200 text-emerald-800 px-4 py-3 rounded-lg mb-4" role="alert">
|
|
155
|
+
<p class="text-sm font-medium"><%= raw flash[:notice] %></p>
|
|
156
|
+
</div>
|
|
157
|
+
<% end %>
|
|
158
|
+
<% if flash[:alert] %>
|
|
159
|
+
<div class="bg-red-50 border border-red-200 text-red-800 px-4 py-3 rounded-lg mb-4" role="alert">
|
|
160
|
+
<p class="text-sm font-medium"><%= raw flash[:alert] %></p>
|
|
161
|
+
</div>
|
|
162
|
+
<% end %>
|
|
163
|
+
</div>
|
|
164
|
+
<% end %>
|
|
165
|
+
|
|
166
|
+
<%= yield %>
|
|
167
|
+
</main>
|
|
168
|
+
</div>
|
|
169
|
+
</body>
|
|
170
|
+
</html>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
<div class="min-h-screen flex flex-col justify-center p-4 md:p-8 max-w-4xl mx-auto">
|
|
2
|
+
<!-- Header -->
|
|
3
|
+
<div class="text-center mb-12">
|
|
4
|
+
<h1 class="text-3xl md:text-4xl lg:text-5xl font-semibold tracking-tight text-gray-900 mb-4">
|
|
5
|
+
Complete your purchase
|
|
6
|
+
</h1>
|
|
7
|
+
<p class="text-gray-600 text-lg">Review your order and complete the checkout process</p>
|
|
8
|
+
</div>
|
|
9
|
+
|
|
10
|
+
<!-- Checkout container where Paddle.js will render the inline checkout -->
|
|
11
|
+
<div id="checkout-container" class="checkout-container">
|
|
12
|
+
<!-- Paddle.js will automatically inject the checkout here when the transaction ID is present -->
|
|
13
|
+
</div>
|
|
14
|
+
|
|
15
|
+
<!-- Processing state (hidden by default) -->
|
|
16
|
+
<div id="processing-state" class="hidden text-center">
|
|
17
|
+
<div class="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mb-4"></div>
|
|
18
|
+
<p class="text-gray-600 text-lg">Processing your order...</p>
|
|
19
|
+
</div>
|
|
20
|
+
</div>
|
|
21
|
+
|
|
22
|
+
<!-- script loaded here to make sure it's loaded after the page is rendered -->
|
|
23
|
+
<script src="https://cdn.paddle.com/paddle/v2/paddle.js"></script>
|
|
24
|
+
<script type="text/javascript">
|
|
25
|
+
Paddle.Environment.set("<%= PaddleRails.configuration.environment || 'sandbox' %>");
|
|
26
|
+
Paddle.Initialize({
|
|
27
|
+
token: "<%= PaddleRails.configuration.public_token %>",
|
|
28
|
+
checkout: {
|
|
29
|
+
settings: {
|
|
30
|
+
displayMode: "inline",
|
|
31
|
+
variant: "dark",
|
|
32
|
+
frameTarget: "checkout-container",
|
|
33
|
+
frameInitialHeight: "450",
|
|
34
|
+
frameStyle: "width: 100%; min-width: 312px; background-color: transparent; border: none;"
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
eventCallback: function(data) {
|
|
38
|
+
console.log(data);
|
|
39
|
+
if (data.name == "checkout.loaded") {
|
|
40
|
+
// Checkout loaded successfully
|
|
41
|
+
};
|
|
42
|
+
if (data.name == "checkout.completed") {
|
|
43
|
+
// Hide checkout frame
|
|
44
|
+
var checkoutContainer = document.getElementById("checkout-container");
|
|
45
|
+
if (checkoutContainer) {
|
|
46
|
+
checkoutContainer.style.display = "none";
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Show processing state
|
|
50
|
+
var processingState = document.getElementById("processing-state");
|
|
51
|
+
if (processingState) {
|
|
52
|
+
processingState.classList.remove("hidden");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Extract transaction ID from URL
|
|
56
|
+
var urlParams = new URLSearchParams(window.location.search);
|
|
57
|
+
var transactionId = urlParams.get("_ptxn");
|
|
58
|
+
|
|
59
|
+
if (transactionId) {
|
|
60
|
+
// Start polling for subscription status
|
|
61
|
+
pollSubscriptionStatus(transactionId);
|
|
62
|
+
} else {
|
|
63
|
+
console.error("Transaction ID not found in URL");
|
|
64
|
+
// Fallback: redirect after a delay
|
|
65
|
+
setTimeout(function() {
|
|
66
|
+
window.location.href = "<%= paddle_rails.root_path %>";
|
|
67
|
+
}, 2000);
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Poll the subscription status endpoint
|
|
74
|
+
function pollSubscriptionStatus(transactionId) {
|
|
75
|
+
var pollInterval = 1000; // Poll every second
|
|
76
|
+
var maxAttempts = 30; // Maximum 30 attempts (30 seconds)
|
|
77
|
+
var attempts = 0;
|
|
78
|
+
|
|
79
|
+
var poll = function() {
|
|
80
|
+
attempts++;
|
|
81
|
+
|
|
82
|
+
// Construct the status check URL
|
|
83
|
+
var baseUrl = "<%= paddle_rails.check_transaction_status_path('PLACEHOLDER') %>";
|
|
84
|
+
var statusUrl = baseUrl.replace('PLACEHOLDER', encodeURIComponent(transactionId));
|
|
85
|
+
fetch(statusUrl, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
headers: {
|
|
88
|
+
"Accept": "application/json",
|
|
89
|
+
"X-Requested-With": "XMLHttpRequest"
|
|
90
|
+
},
|
|
91
|
+
credentials: "same-origin"
|
|
92
|
+
})
|
|
93
|
+
.then(function(response) {
|
|
94
|
+
return response.json();
|
|
95
|
+
})
|
|
96
|
+
.then(function(data) {
|
|
97
|
+
if (data.status === "active") {
|
|
98
|
+
// Subscription is active, redirect to dashboard
|
|
99
|
+
if (data.redirect_url) {
|
|
100
|
+
window.location.href = data.redirect_url;
|
|
101
|
+
} else {
|
|
102
|
+
window.location.href = "<%= paddle_rails.root_path %>";
|
|
103
|
+
}
|
|
104
|
+
} else if (attempts >= maxAttempts) {
|
|
105
|
+
// Max attempts reached, redirect anyway
|
|
106
|
+
console.warn("Max polling attempts reached, redirecting...");
|
|
107
|
+
window.location.href = "<%= paddle_rails.root_path %>";
|
|
108
|
+
} else {
|
|
109
|
+
// Still pending, poll again
|
|
110
|
+
setTimeout(poll, pollInterval);
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
.catch(function(error) {
|
|
114
|
+
console.error("Error polling subscription status:", error);
|
|
115
|
+
// On error, retry after a delay
|
|
116
|
+
if (attempts < maxAttempts) {
|
|
117
|
+
setTimeout(poll, pollInterval);
|
|
118
|
+
} else {
|
|
119
|
+
// Max attempts reached, redirect anyway
|
|
120
|
+
window.location.href = "<%= paddle_rails.root_path %>";
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// Start polling
|
|
126
|
+
poll();
|
|
127
|
+
}
|
|
128
|
+
</script>
|