parsbank 0.0.4 → 0.0.8

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.
@@ -1,32 +1,23 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Parsbank
2
- class Zarinpal
3
- attr_accessor :amount, :description, :email, :mobile, :merchant_id
4
+ class Zarinpal < Gates
5
+ attr_accessor :amount, :description, :email, :mobile, :merchant_id, :wsdl
4
6
  attr_reader :response, :status, :status_message, :ref_id, :logo
5
7
 
6
8
  def initialize(args = {})
7
- @mobile = args.fetch(:mobile, nil)
8
- @email = args.fetch(:email, nil)
9
+ @mobile = args.fetch(:mobile, '')
10
+ @email = args.fetch(:email, '')
9
11
  @amount = args.fetch(:amount)
10
12
  @description = args.fetch(:description, ' ')
11
13
  @callback_url = args.fetch(:callback_url,
12
14
  (default_config(:callback_url) || Parsbank.configuration.callback_url))
13
15
  @merchant_id = args.fetch(:merchant_id, default_config(:merchant_id))
14
- @wsdl = create_wsdl_client
16
+ @wsdl = create_rest_client
15
17
  rescue KeyError => e
16
18
  raise ArgumentError, "Missing required argument: #{e.message}"
17
19
  end
18
20
 
19
- def self.logo
20
- file_path = "#{__dir__}/logo.svg"
21
- return [404, { 'Content-Type' => 'text/plain' }, ['File not found']] unless File.exist?(file_path)
22
-
23
- [
24
- 200,
25
- { 'Content-Type' => 'image/svg+xml' },
26
- File.open(file_path, 'r')
27
- ]
28
- end
29
-
30
21
  def validate(response = nil)
31
22
  @response = response[:payment_request_response] || response[:payment_verification_response] || response
32
23
  @ref_id = @response[:authority]
@@ -45,61 +36,99 @@ module Parsbank
45
36
  end
46
37
 
47
38
  def call
48
- response = @wsdl.call(:payment_request, message: build_request_message)
49
- validate(response.body)
50
- rescue Savon::Error => e
51
- raise "SOAP request failed: #{e.message}"
39
+ response = @wsdl.call
40
+
41
+ Rails.logger.info "Received response with status: #{response.status}, body: #{response.body.inspect}"
42
+
43
+ if response.valid?
44
+ validate(response.body)
45
+ else
46
+ @valid = false
47
+ Rails.logger.error "POST request to #{BASE_URL}/#{endpoint} failed with status: #{response.status}, error: #{response.body.inspect}"
48
+ raise "API request failed with status #{response.status}: #{response.body}"
49
+ end
50
+ rescue Faraday::ConnectionFailed => e
51
+ Rails.logger.error "Connection failed: #{e.message}"
52
+ raise "Connection to API failed: #{e.message}"
53
+ rescue Faraday::TimeoutError => e
54
+ Rails.logger.error "Request timed out: #{e.message}"
55
+ raise "API request timed out: #{e.message}"
56
+ rescue StandardError => e
57
+ Rails.logger.error "An error occurred: #{e.message}"
58
+ raise "An unexpected error occurred: #{e.message}"
59
+
60
+ JSON.parse response.body
61
+
62
+ if response[:success]
63
+ validate(response[:body])
64
+ else
65
+ { status: :nok, message: begin
66
+ response[:error]
67
+ rescue StandardError
68
+ response
69
+ end }
70
+ end
52
71
  end
53
72
 
54
- def redirect_form
55
- "
73
+ def redirect_form(ref_id)
74
+ javascript_tag = <<-JS
56
75
  <script type='text/javascript' charset='utf-8'>
57
- function postRefId (refIdValue) {
58
- var form = document.createElement('form');
59
- form.setAttribute('method', 'POST');
60
- form.setAttribute('action', 'https://www.zarinpal.com/pg/StartPay/#{ref_id}');
61
- form.setAttribute('target', '_self');
62
- var hiddenField = document.createElement('input');
63
- hiddenField.setAttribute('name', 'RefId');
64
- hiddenField.setAttribute('value', refIdValue);
65
- form.appendChild(hiddenField);
66
- document.body.appendChild(form);
67
- form.submit();
68
- document.body.removeChild(form);
69
- }
70
-
71
-
72
- postRefId('#{ref_id}') %>')
73
- </script>
74
- "
76
+ function postRefId(refIdValue) {
77
+ var form = document.createElement('form');
78
+ form.setAttribute('method', 'POST');
79
+ form.setAttribute('action', 'https://www.zarinpal.com/pg/StartPay/' + refIdValue);
80
+ form.setAttribute('target', '_self');
81
+ var hiddenField = document.createElement('input');
82
+ hiddenField.setAttribute('name', 'RefId');
83
+ hiddenField.setAttribute('value', refIdValue);
84
+ form.appendChild(hiddenField);
85
+ document.body.appendChild(form);
86
+ form.submit();
87
+ document.body.removeChild(form);
88
+ }
89
+ postRefId('#{ref_id}');
90
+ </script>
91
+ JS
92
+
93
+ redirect_loaders do
94
+ "#{javascript_tag}#{I18n.t('actions.redirect_to_gate')}"
95
+ end
75
96
  end
76
97
 
77
98
  private
78
99
 
79
- def default_config(key)
80
- Parsbank.load_secrets_yaml[self.class.name.split('::').last.downcase][key.to_s]
81
- end
82
-
83
- def create_wsdl_client
84
- Savon.client(
85
- wsdl: default_config(:wsdl) || 'https://de.zarinpal.com/pg/services/WebGate/wsdl',
86
- pretty_print_xml: (Parsbank.configuration.debug ? true : false),
87
- namespace: 'http://interfaces.core.sw.bps.com/',
88
- log: (Parsbank.configuration.debug ? true : false),
89
- logger: Rails.logger,
90
- log_level: (Parsbank.configuration.debug ? :debug : :fatal)
100
+ def create_rest_client
101
+ Parsbank::Restfull.new(
102
+ endpoint: default_config(:endpoint) || 'https://payment.zarinpal.com/pg/v4/payment',
103
+ action: 'request.json',
104
+ headers: {
105
+ 'Content-Type' => 'application/json'
106
+ },
107
+ request_message: build_request_message,
108
+ http_method: :post,
109
+ response_type: :json
91
110
  )
111
+
112
+
92
113
  end
93
114
 
115
+ def deep_clean(hash)
116
+ hash.reject do |_, value|
117
+ (value.is_a?(Hash) && deep_clean(value).empty?) || value.nil? || value == ""
118
+ end.transform_values do |v|
119
+ v.is_a?(Hash) ? deep_clean(v) : v
120
+ end
121
+
122
+ end
123
+
94
124
  def build_request_message
95
- {
96
- 'MerchantID' => @merchant_id,
97
- 'Mobile' => @mobile,
98
- 'Email' => @email,
99
- 'Amount' => @amount,
100
- 'Description' => @description,
101
- 'CallbackURL' => @callback_url
102
- }
125
+ deep_clean({
126
+ 'merchant_id' => @merchant_id,
127
+ 'metadata' => {'mobile' => @mobile, 'email' => @email,'order_id'=> ''},
128
+ 'amount' => @amount,
129
+ 'description' => @description,
130
+ 'callback_url' => @callback_url
131
+ })
103
132
  end
104
133
 
105
134
  def perform_validation
@@ -3,7 +3,7 @@
3
3
  require 'faraday'
4
4
  require 'faraday_middleware'
5
5
  module Parsbank
6
- class Zibal
6
+ class Zibal < Gates
7
7
  attr_accessor :amount, :description, :email, :mobile, :merchant, :callbackUrl, :orderId, :allowedCards, :ledgerId,
8
8
  :nationalCode, :checkMobileWithCard
9
9
 
@@ -25,17 +25,6 @@ module Parsbank
25
25
  raise ArgumentError, "Missing required argument: #{e.message}"
26
26
  end
27
27
 
28
- def self.logo
29
- file_path = "#{__dir__}/logo.svg"
30
- return [404, { 'Content-Type' => 'text/plain' }, ['File not found']] unless File.exist?(file_path)
31
-
32
- [
33
- 200,
34
- { 'Content-Type' => 'image/svg+xml' },
35
- File.open(file_path, 'r')
36
- ]
37
- end
38
-
39
28
  def validate(response = nil)
40
29
  @response = response
41
30
  @ref_id = @response['trackId']
@@ -55,8 +44,6 @@ module Parsbank
55
44
 
56
45
  def call
57
46
  create_rest_client
58
- rescue Savon::Error => e
59
- raise "SOAP request failed: #{e.message}"
60
47
  end
61
48
 
62
49
  def redirect_form
@@ -84,10 +71,6 @@ module Parsbank
84
71
 
85
72
  private
86
73
 
87
- def default_config(key)
88
- Parsbank.load_secrets_yaml[self.class.name.split('::').last.downcase][key.to_s]
89
- end
90
-
91
74
  def create_rest_client
92
75
  connection = Parsbank::Restfull.new(
93
76
  endpoint: default_config(:endpoint) || 'https://gateway.zibal.ir',
data/lib/parsbank.rb CHANGED
@@ -1,101 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'yaml'
4
- require 'savon'
4
+ require 'uri'
5
+ require 'erb'
6
+ require 'i18n'
7
+ require 'active_support'
5
8
  require 'parsbank/version'
9
+ require 'db_setup'
6
10
  require 'parsbank/restfull'
7
- require 'parsbank/bsc-bitcoin/bsc-bitcoin'
11
+ require 'parsbank/soap'
12
+ require 'parsbank/gates'
13
+ require 'parsbank/binance/binance'
8
14
  require 'parsbank/mellat/mellat'
9
15
  require 'parsbank/zarinpal/zarinpal'
10
16
  require 'parsbank/zibal/zibal'
17
+ require 'parsbank/nobitex/nobitex'
11
18
  require 'configuration'
19
+ require 'parsbank/transaction_request'
20
+ require 'parsbank/transaction_verify'
12
21
 
13
- # Main Module
14
22
  module Parsbank
15
23
  class Error < StandardError; end
16
24
 
17
- $SUPPORTED_PSP = [
18
- 'asanpardakht': {
19
- 'name': 'Asan Pardakht CO.',
20
- 'website': 'http://asanpardakht.ir',
21
- 'tags': %w[iranian-psp ir rial]
22
- },
23
- 'damavand': {
24
- 'name': 'Electronic Card Damavand CO.',
25
- 'website': 'http://ecd-co.ir',
26
- 'tags': %w[iranian-psp ir rial]
27
- },
28
- 'mellat': {
29
- 'name': 'Behpardakht Mellat CO.',
30
- 'website': 'http://behpardakht.com',
31
- 'tags': %w[iranian-psp ir rial]
32
- },
33
- 'pep': {
34
- 'name': 'Pasargad CO.',
35
- 'website': 'http://pep.co.ir',
36
- 'tags': %w[iranian-psp ir rial]
37
- },
38
-
39
- 'sep': {
40
- 'name': 'Saman Bank CO.',
41
- 'website': 'http://sep.ir',
42
- 'tags': %w[iranian-psp ir rial]
43
- },
44
- 'pna': {
45
- 'name': 'Pardakht Novin Arian CO.',
46
- 'website': 'http://pna.co.ir',
47
- 'tags': %w[iranian-psp ir rial]
48
- },
49
- 'pec': {
50
- 'name': 'Parsian Bank CO.',
51
- 'website': 'http://pec.ir',
52
- 'tags': %w[iranian-psp ir rial]
53
- },
54
-
55
- 'sadad': {
56
- 'name': 'Sadad Bank CO.',
57
- 'website': 'http://sadadco.‌com',
58
- 'tags': %w[iranian-psp ir rial]
59
- },
60
- 'sayan': {
61
- 'name': 'Sayan Card CO.',
62
- 'website': 'http://sayancard.ir',
63
- 'tags': %w[iranian-psp ir rial]
64
- },
65
-
66
- 'fanava': {
67
- 'name': 'Fan Ava Card CO.',
68
- 'website': 'http://fanavacard.com',
69
- 'tags': %w[iranian-psp ir rial]
70
- },
71
- 'kiccc': {
72
- 'name': 'IranKish CO.',
73
- 'website': 'http://kiccc.com',
74
- 'tags': %w[iranian-psp ir rial]
75
- },
76
-
77
- 'sepehr': {
78
- 'name': 'Sepehr Bank CO.',
79
- 'website': 'http://www.sepehrpay.com',
80
- 'tags': %w[iranian-psp ir rial]
81
- },
82
-
83
- 'zarinpal': {
84
- 'name': 'Zarinpal',
85
- 'website': 'http://www.sepehrpay.com',
86
- 'tags': %w[iranian-psp ir rial]
87
- },
88
- 'zibal': {
89
- 'name': 'Zibal',
90
- 'website': 'http://zibal.ir',
91
- 'tags': %w[iranian-psp ir rial]
92
- },
93
- 'bscbitcoin': {
94
- 'name': 'Binance Bitcoin',
95
- 'website': 'https://bitcoin.org',
96
- 'tags': %w[btc bitcoin binance bsc crypto]
97
- }
98
- ]
99
25
  class << self
100
26
  attr_accessor :configuration
101
27
  end
@@ -105,6 +31,10 @@ module Parsbank
105
31
  yield configuration
106
32
  end
107
33
 
34
+ def self.supported_psp
35
+ JSON.parse(File.read(File.join(__dir__, 'psp.json')))
36
+ end
37
+
108
38
  def self.gateways_list
109
39
  load_secrets_yaml
110
40
  end
@@ -113,71 +43,32 @@ module Parsbank
113
43
  load_secrets_yaml.select { |_, value| value['enabled'] }
114
44
  end
115
45
 
46
+ def self.initialize_in_rails
47
+ return unless defined?(Rails)
116
48
 
117
- def self.redirect_to_gateway(args = {})
118
- amount = args.fetch(:amount)
119
- bank = args.fetch(:bank, 'random-irr-gates')
120
- description = args.fetch(:description, '')
121
-
122
- selected_bank = available_gateways_list.select { |k| k == bank }
123
- raise "Bank not enabled or not exists on bank_secrets.yml: #{bank}" unless selected_bank.present?
124
-
125
- default_callback = Parsbank.configuration.callback_url + "&bank_name=#{bank}"
126
-
127
- case bank
128
- when 'mellat'
129
- mellat_klass = Parsbank::Mellat.new(
130
- amount: amount,
131
- additional_data: description,
132
- callback_url: selected_bank['mellat']['callback_url'] || default_callback,
133
- orderId: rand(1...9999)
134
- )
135
- mellat_klass.call
136
- result = mellat_klass.redirect_form
137
-
138
- when 'zarinpal'
139
- zarinpal_klass = Parsbank::Zarinpal.new(
140
- amount: amount,
141
- additional_data: description,
142
- callback_url: selected_bank['zarinpal']['callback_url'] || default_callback
143
- )
144
- zarinpal_klass.call
145
- result = zarinpal_klass.redirect_form
146
-
147
- when 'zibal'
148
- Parsbank::Zibal.new(
149
- amount: amount,
150
- additional_data: description,
151
- callback_url: selected_bank['zibal']['callback_url'] || default_callback
152
- )
153
- zarinpal_klass.call
154
- result = zarinpal_klass.redirect_form
155
- when 'bscbitcoin'
156
- bscbitcoin_klass = Parsbank::BscBitcoin.new(
157
- additional_data: description
158
- )
159
- result = bscbitcoin_klass.generate_payment_address(amount: amount)
49
+ ActiveSupport.on_load(:after_initialize) do
50
+ Parsbank.initialize!
160
51
  end
52
+ end
53
+
54
+ def self.initialize!
55
+ I18n.load_path += Dir.glob(File.join(__dir__, 'locales', '*.yml'))
56
+ I18n.available_locales = %i[en fa]
57
+ I18n.enforce_available_locales = false
161
58
 
162
- result
59
+ Parsbank::DBSetup.establish_connection
163
60
  end
164
61
 
165
62
  def self.load_secrets_yaml
166
63
  # Load the YAML file specified by the secrets_path
167
64
  secrets = YAML.load_file(Parsbank.configuration.secrets_path)
168
-
169
- unless secrets.is_a?(Hash)
170
- raise "Error: Invalid format in #{Parsbank.configuration.secrets_path}. Expected a hash of bank secrets."
171
- end
172
-
173
- supported_banks = $SUPPORTED_PSP[0].keys
65
+ raise "Error: Invalid format in #{Parsbank.configuration.secrets_path}." unless secrets.is_a?(Hash)
174
66
 
175
67
  secrets.each_key do |bank_key|
176
- unless supported_banks.include?(bank_key.to_sym)
177
- raise "#{bank_key.capitalize} in #{Parsbank.configuration.secrets_path} is not supported by ParsBank. \nSupported Banks: #{supported_banks.join(', ')}"
68
+ unless supported_psp.keys.include?(bank_key.to_s)
69
+ raise "#{bank_key.capitalize} in #{Parsbank.configuration.secrets_path} is not supported by ParsBank. \nSupported Banks: #{supported_psp.keys.join(', ')}"
178
70
  end
179
71
  end
180
-
181
72
  secrets
182
73
  rescue Errno::ENOENT
183
74
  raise "Error: Secrets file not found at #{Parsbank.configuration.secrets_path}."
@@ -185,23 +76,11 @@ module Parsbank
185
76
  raise "Error: YAML syntax issue in #{Parsbank.configuration.secrets_path}: #{e.message}"
186
77
  end
187
78
 
188
-
189
- def self.gateways_list_shortcode
190
- banks_list = available_gateways_list.keys.map { |bank| render_bank_list_item(bank) }.join
191
- "<ul class='parsbank_selector'>#{banks_list}</ul>"
192
- end
193
-
194
- private
195
- def self.render_bank_list_item(bank)
196
- bank_klass=Object.const_get("Parsbank::#{bank.capitalize}")
197
- status, headers, body = bank_klass.logo rescue nil
198
- <<~HTML
199
- <li class='parsbank_radio_wrapper #{bank}_wrapper'>
200
-
201
- <input type='radio' id='#{bank}' name='bank' value='#{bank}' />
202
- <label for='#{bank}'>#{File.read(body) rescue ''} #{bank.upcase}</label>
203
- </li>
204
- HTML
79
+ def self.gateways_list_shortcode(args = {})
80
+ ERB.new(File.read(File.join(__dir__, 'tmpl', 'bank_list.html.erb'))).result(binding).gsub(/(?:\n\r?|\r\n?)/, '').gsub(/>\s+</, '><').gsub(
81
+ /\s+/, ' '
82
+ ).strip
205
83
  end
206
-
207
84
  end
85
+
86
+ Parsbank.initialize_in_rails
data/lib/psp.json ADDED
@@ -0,0 +1,84 @@
1
+ {
2
+ "asanpardakht": {
3
+ "name": "Asan Pardakht CO.",
4
+ "website": "http://asanpardakht.ir",
5
+ "tags": ["iranian-psp", "ir", "rial"]
6
+ },
7
+ "damavand": {
8
+ "name": "Electronic Card Damavand CO.",
9
+ "website": "http://ecd-co.ir",
10
+ "tags": ["iranian-psp", "ir", "rial"]
11
+ },
12
+ "mellat": {
13
+ "name": "Behpardakht Mellat CO.",
14
+ "website": "http://behpardakht.com",
15
+ "tags": ["iranian-psp", "ir", "rial"]
16
+ },
17
+ "pep": {
18
+ "name": "Pasargad CO.",
19
+ "website": "http://pep.co.ir",
20
+ "tags": ["iranian-psp", "ir", "rial"]
21
+ },
22
+ "sep": {
23
+ "name": "Saman Bank CO.",
24
+ "website": "http://sep.ir",
25
+ "tags": ["iranian-psp", "ir", "rial"]
26
+ },
27
+ "pna": {
28
+ "name": "Pardakht Novin Arian CO.",
29
+ "website": "http://pna.co.ir",
30
+ "tags": ["iranian-psp", "ir", "rial"]
31
+ },
32
+ "pec": {
33
+ "name": "Parsian Bank CO.",
34
+ "website": "http://pec.ir",
35
+ "tags": ["iranian-psp", "ir", "rial"]
36
+ },
37
+ "sadad": {
38
+ "name": "Sadad Bank CO.",
39
+ "website": "http://sadadco.com",
40
+ "tags": ["iranian-psp", "ir", "rial"]
41
+ },
42
+ "sayan": {
43
+ "name": "Sayan Card CO.",
44
+ "website": "http://sayancard.ir",
45
+ "tags": ["iranian-psp", "ir", "rial"]
46
+ },
47
+ "fanava": {
48
+ "name": "Fan Ava Card CO.",
49
+ "website": "http://fanavacard.com",
50
+ "tags": ["iranian-psp", "ir", "rial"]
51
+ },
52
+ "kiccc": {
53
+ "name": "IranKish CO.",
54
+ "website": "http://kiccc.com",
55
+ "tags": ["iranian-psp", "ir", "rial"]
56
+ },
57
+ "sepehr": {
58
+ "name": "Sepehr Bank CO.",
59
+ "website": "http://www.sepehrpay.com",
60
+ "tags": ["iranian-psp", "ir", "rial"]
61
+ },
62
+ "zarinpal": {
63
+ "name": "Zarinpal",
64
+ "website": "http://www.sepehrpay.com",
65
+ "tags": ["iranian-psp", "irt", "rial"]
66
+ },
67
+ "zibal": {
68
+ "name": "Zibal",
69
+ "website": "http://zibal.ir",
70
+ "tags": ["iranian-psp", "irt", "rial"]
71
+ },
72
+ "binance": {
73
+ "name": "Binance",
74
+ "website": "https://binance.com",
75
+ "support_coins": ["btc", "usdt"],
76
+ "tags": ["btc", "bitcoin", "binance", "bsc", "crypto"]
77
+ },
78
+ "nobitex": {
79
+ "name": "Nobitex",
80
+ "website": "https://nobitex.ir",
81
+ "support_coins": ["btc", "usdt"],
82
+ "tags": ["btc", "bitcoin", "nobitex", "bsc", "crypto"]
83
+ }
84
+ }
@@ -0,0 +1,105 @@
1
+ <html>
2
+
3
+ <head>
4
+ <title>Loading...</title>
5
+ <meta http-equiv='cache-control' content='max-age=0' />
6
+ <meta http-equiv='cache-control' content='no-cache' />
7
+ <meta http-equiv='expires' content='0' />
8
+ <meta http-equiv='expires' content='Tue, 18 May 1998 1:00:00 GMT' />
9
+ <meta http-equiv='pragma' content='no-cache' />
10
+ <meta charset='utf-8'>
11
+ <meta content='width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no' name='viewport'>
12
+ <meta content='#da532c' name='msapplication-TileColor'>
13
+ <meta name='theme-color' content='#ffffff'>
14
+ <meta name='author' content='ParsBank Gem'>
15
+ <meta name="robots" content="noindex,nofollow,noarchive" />
16
+ <style>
17
+ body {
18
+ text-align: center;
19
+ background: #5052bc;
20
+ margin-top: 50vh;
21
+ color: white;
22
+ line-height: 5rem;
23
+ }
24
+
25
+ .loader {
26
+ width: 16px;
27
+ display: block;
28
+ margin: auto;
29
+ height: 16px;
30
+ position: relative;
31
+ left: -32px;
32
+ border-radius: 50%;
33
+ color: #fff;
34
+ background: currentColor;
35
+ box-shadow: 32px 0, -32px 0, 64px 0;
36
+ z-index: -1;
37
+ }
38
+
39
+ .loader::after {
40
+ content: '';
41
+ position: absolute;
42
+ left: -32px;
43
+ top: 0;
44
+ width: 16px;
45
+ height: 16px;
46
+ border-radius: 10px;
47
+ background: #FF3D00;
48
+ animation: move 3s linear infinite alternate;
49
+ }
50
+
51
+ @keyframes move{
52
+
53
+ 0%,
54
+ 5% {
55
+ left: -32px;
56
+ width: 16px;
57
+ }
58
+
59
+ 15%,
60
+ 20% {
61
+ left: -32px;
62
+ width: 48px;
63
+ }
64
+
65
+ 30%,
66
+ 35% {
67
+ left: 0px;
68
+ width: 16px;
69
+ }
70
+
71
+ 45%,
72
+ 50% {
73
+ left: 0px;
74
+ width: 48px;
75
+ }
76
+
77
+ 60%,
78
+ 65% {
79
+ left: 32px;
80
+ width: 16px;
81
+ }
82
+
83
+ 75%,
84
+ 80% {
85
+ left: 32px;
86
+ width: 48px;
87
+ }
88
+
89
+ 95%,
90
+ 100% {
91
+ left: 64px;
92
+ width: 16px;
93
+ }
94
+ </style>
95
+ </head>
96
+
97
+ <body>
98
+ <span class='loader'></span>
99
+
100
+
101
+ <%= yield %>
102
+
103
+ </body>
104
+
105
+ </html>