skeuocard-rails 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (37) hide show
  1. checksums.yaml +7 -0
  2. data/Gemfile +23 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +59 -0
  5. data/lib/skeuocard-rails.rb +3 -0
  6. data/lib/skeuocard-rails/engine.rb +6 -0
  7. data/vendor/assets/fonts/ocra-webfont.eot +0 -0
  8. data/vendor/assets/fonts/ocra-webfont.svg +138 -0
  9. data/vendor/assets/fonts/ocra-webfont.ttf +0 -0
  10. data/vendor/assets/fonts/ocra-webfont.woff +0 -0
  11. data/vendor/assets/images/card-flip-arrow.png +0 -0
  12. data/vendor/assets/images/card-invalid-indicator.png +0 -0
  13. data/vendor/assets/images/card-valid-anim.gif +0 -0
  14. data/vendor/assets/images/card-valid-indicator.png +0 -0
  15. data/vendor/assets/images/issuers/amex-blackcard-front.png +0 -0
  16. data/vendor/assets/images/issuers/visa-chase-sapphire.png +0 -0
  17. data/vendor/assets/images/issuers/visa-simple-front.png +0 -0
  18. data/vendor/assets/images/products/amex-front.png +0 -0
  19. data/vendor/assets/images/products/dinersclubintl-front.png +0 -0
  20. data/vendor/assets/images/products/discover-front.png +0 -0
  21. data/vendor/assets/images/products/generic-back.png +0 -0
  22. data/vendor/assets/images/products/generic-front.png +0 -0
  23. data/vendor/assets/images/products/mastercard-front.png +0 -0
  24. data/vendor/assets/images/products/visa-back.png +0 -0
  25. data/vendor/assets/images/products/visa-front.png +0 -0
  26. data/vendor/assets/javascripts/skeuocard.js +1432 -0
  27. data/vendor/assets/javascripts/src/skeuocard.coffee +1072 -0
  28. data/vendor/assets/javascripts/vendor/css_browser_selector.js +154 -0
  29. data/vendor/assets/javascripts/vendor/demo.fix.js +17 -0
  30. data/vendor/assets/javascripts/vendor/jquery-2.0.3.min.js +5 -0
  31. data/vendor/assets/stylesheets/src/_browser_hacks.scss +32 -0
  32. data/vendor/assets/stylesheets/src/_cards.scss +318 -0
  33. data/vendor/assets/stylesheets/src/_util.scss +15 -0
  34. data/vendor/assets/stylesheets/src/demo.scss +265 -0
  35. data/vendor/assets/stylesheets/src/skeuocard.reset.scss +52 -0
  36. data/vendor/assets/stylesheets/src/skeuocard.scss +168 -0
  37. metadata +92 -0
@@ -0,0 +1,1072 @@
1
+ ###
2
+ "Skeuocard" -- A Skeuomorphic Credit-Card Input Enhancement
3
+ @description Skeuocard is a skeuomorphic credit card input plugin, supporting
4
+ progressive enhancement. It renders a credit-card input which
5
+ behaves similarly to a physical credit card.
6
+ @author Ken Keiter <ken@kenkeiter.com>
7
+ @updated 2013-07-25
8
+ @website http://kenkeiter.com/
9
+ @exports [window.Skeuocard]
10
+ ###
11
+
12
+ class Skeuocard
13
+
14
+ constructor: (el, opts = {})->
15
+ @el = {container: $(el), underlyingFields: {}}
16
+ @_inputViews = {}
17
+ @_tabViews = {}
18
+ @product = undefined
19
+ @productShortname = undefined
20
+ @issuerShortname = undefined
21
+ @_cardProductNeedsLayout = true
22
+ @acceptedCardProducts = {}
23
+ @visibleFace = 'front'
24
+ @_initialValidationState = {}
25
+ @_validationState = {number: false, exp: false, name: false, cvc: false}
26
+ @_faceFillState = {front: false, back: false}
27
+
28
+ # configure default opts
29
+ optDefaults =
30
+ debug: false
31
+ acceptedCardProducts: []
32
+ cardNumberPlaceholderChar: 'X'
33
+ genericPlaceholder: "XXXX XXXX XXXX XXXX"
34
+ typeInputSelector: '[name="cc_type"]'
35
+ numberInputSelector: '[name="cc_number"]'
36
+ expInputSelector: '[name="cc_exp"]'
37
+ nameInputSelector: '[name="cc_name"]'
38
+ cvcInputSelector: '[name="cc_cvc"]'
39
+ currentDate: new Date()
40
+ initialValues: {}
41
+ validationState: {}
42
+ strings:
43
+ hiddenFaceFillPrompt: "Click here to<br /> fill in the other side."
44
+ hiddenFaceErrorWarning: "There's a problem on the other side."
45
+ hiddenFaceSwitchPrompt: "Back to the other side..."
46
+ @options = $.extend(optDefaults, opts)
47
+
48
+ # initialize the card
49
+ @_conformDOM() # conform the DOM to match our styling requirements
50
+ @_setAcceptedCardProducts() # determine which card products to accept
51
+ @_createInputs() # create reconfigurable input views
52
+ @_updateProductIfNeeded()
53
+ @_flipToInvalidSide()
54
+
55
+
56
+ # Transform the elements within the container, conforming the DOM so that it
57
+ # becomes styleable, and that the underlying inputs are hidden.
58
+ _conformDOM: ->
59
+ # for CSS determination that this is an enhanced input, add 'js' class to
60
+ # the container
61
+ @el.container.removeClass('no-js')
62
+ @el.container.addClass("skeuocard js")
63
+ # remove anything that's not an underlying form field
64
+ @el.container.find("> :not(input,select,textarea)").remove()
65
+ @el.container.find("> input,select,textarea").hide()
66
+ # attach underlying form elements
67
+ @el.underlyingFields =
68
+ type: @el.container.find(@options.typeInputSelector)
69
+ number: @el.container.find(@options.numberInputSelector)
70
+ exp: @el.container.find(@options.expInputSelector)
71
+ name: @el.container.find(@options.nameInputSelector)
72
+ cvc: @el.container.find(@options.cvcInputSelector)
73
+ # sync initial values, with constructor options taking precedence
74
+ for fieldName, fieldValue of @options.initialValues
75
+ @el.underlyingFields[fieldName].val(fieldValue)
76
+ for fieldName, el of @el.underlyingFields
77
+ @options.initialValues[fieldName] = el.val()
78
+ # sync initial validation state, with constructor options taking precedence
79
+ # we use the underlying form values to track state
80
+ for fieldName, el of @el.underlyingFields
81
+ if @options.validationState[fieldName] is false or el.hasClass('invalid')
82
+ @_initialValidationState[fieldName] = false
83
+ unless el.hasClass('invalid')
84
+ el.addClass('invalid')
85
+ # bind change handlers to render
86
+ @el.underlyingFields.number.bind "change", (e)=>
87
+ @_inputViews.number.setValue @_getUnderlyingValue('number')
88
+ @render()
89
+ @el.underlyingFields.exp.bind "change", (e)=>
90
+ @_inputViews.exp.setValue @_getUnderlyingValue('exp')
91
+ @render()
92
+ @el.underlyingFields.name.bind "change", (e)=>
93
+ @_inputViews.exp.setValue @_getUnderlyingValue('name')
94
+ @render()
95
+ @el.underlyingFields.cvc.bind "change", (e)=>
96
+ @_inputViews.exp.setValue @_getUnderlyingValue('cvc')
97
+ @render()
98
+ # construct the necessary card elements
99
+ @el.surfaceFront = $("<div>").attr(class: "face front")
100
+ @el.surfaceBack = $("<div>").attr(class: "face back")
101
+ @el.cardBody = $("<div>").attr(class: "card-body")
102
+ # add elements to the DOM
103
+ @el.surfaceFront.appendTo(@el.cardBody)
104
+ @el.surfaceBack.appendTo(@el.cardBody)
105
+ @el.cardBody.appendTo(@el.container)
106
+ # create the validation indicator (flip tab), and attach them.
107
+ @_tabViews.front = new @FlipTabView('front')
108
+ @_tabViews.back = new @FlipTabView('back')
109
+ @el.surfaceFront.prepend(@_tabViews.front.el)
110
+ @el.surfaceBack.prepend(@_tabViews.back.el)
111
+ @_tabViews.front.hide()
112
+ @_tabViews.back.hide()
113
+
114
+ @_tabViews.front.el.click =>
115
+ @flip()
116
+ @_tabViews.back.el.click =>
117
+ @flip()
118
+
119
+ return @el.container
120
+
121
+ _setAcceptedCardProducts: ->
122
+ # build the set of accepted card products
123
+ if @options.acceptedCardProducts.length is 0
124
+ @el.underlyingFields.type.find('option').each (i, _el)=>
125
+ el = $(_el)
126
+ cardProductShortname = el.attr('data-card-product-shortname') || el.attr('value')
127
+ @options.acceptedCardProducts.push cardProductShortname
128
+ # find all matching card products by shortname, and add them to the
129
+ # list of @acceptedCardProducts
130
+ for matcher, product of CCProducts
131
+ if product.companyShortname in @options.acceptedCardProducts
132
+ @acceptedCardProducts[matcher] = product
133
+ return @acceptedCardProducts
134
+
135
+ _updateProductIfNeeded: ->
136
+ # determine if product changed; if so, change it globally, and
137
+ # call render() to render the changes.
138
+ number = @_getUnderlyingValue('number')
139
+ matchedProduct = @getProductForNumber(number)
140
+ matchedProductIdentifier = matchedProduct?.companyShortname || ''
141
+ matchedIssuerIdentifier = matchedProduct?.issuerShortname || ''
142
+
143
+ if (@productShortname isnt matchedProductIdentifier) or
144
+ (@issuerShortname isnt matchedIssuerIdentifier)
145
+ @productShortname = matchedProductIdentifier
146
+ @issuerShortname = matchedIssuerIdentifier
147
+ @product = matchedProduct
148
+ @_cardProductNeedsLayout = true
149
+ @trigger 'productWillChange.skeuocard',
150
+ [@, @productShortname, matchedProductIdentifier]
151
+ @_log("Triggering render because product changed.")
152
+ @render()
153
+ @trigger('productDidChange.skeuocard', [@, @productShortname, matchedProductIdentifier])
154
+
155
+ # Create the new inputs, and attach them to their appropriate card face els.
156
+ _createInputs: ->
157
+ @_inputViews.number = new @SegmentedCardNumberInputView()
158
+ @_inputViews.exp = new @ExpirationInputView(currentDate: @options.currentDate)
159
+ @_inputViews.name = new @TextInputView(
160
+ class: "cc-name", placeholder: "YOUR NAME")
161
+ @_inputViews.cvc = new @TextInputView(
162
+ class: "cc-cvc", placeholder: "XXX", requireMaxLength: true)
163
+
164
+ # style and attach the number view to the DOM
165
+ @_inputViews.number.el.addClass('cc-number')
166
+ @_inputViews.number.el.appendTo(@el.surfaceFront)
167
+ # attach name input
168
+ @_inputViews.name.el.appendTo(@el.surfaceFront)
169
+ # style and attach the exp view to the DOM
170
+ @_inputViews.exp.el.addClass('cc-exp')
171
+ @_inputViews.exp.el.appendTo(@el.surfaceFront)
172
+ # attach cvc field to the DOM
173
+ @_inputViews.cvc.el.appendTo(@el.surfaceBack)
174
+
175
+ # bind change events to their underlying form elements
176
+ @_inputViews.number.bind "keyup", (e, input)=>
177
+ @_setUnderlyingValue('number', input.value)
178
+ @_updateValidationStateForInputView('number')
179
+ @_updateProductIfNeeded()
180
+ @_inputViews.exp.bind "keyup", (e, input)=>
181
+ @_setUnderlyingValue('exp', input.value)
182
+ @_updateValidationStateForInputView('exp')
183
+ @_inputViews.name.bind "keyup", (e)=>
184
+ @_setUnderlyingValue('name', $(e.target).val())
185
+ @_updateValidationStateForInputView('name')
186
+ @_inputViews.cvc.bind "keyup", (e)=>
187
+ @_setUnderlyingValue('cvc', $(e.target).val())
188
+ @_updateValidationStateForInputView('cvc')
189
+
190
+ # setup default values; when render is called, these will be picked up
191
+ @_inputViews.number.setValue @_getUnderlyingValue('number')
192
+ @_inputViews.exp.setValue @_getUnderlyingValue('exp')
193
+ @_inputViews.name.el.val @_getUnderlyingValue('name')
194
+ @_inputViews.cvc.el.val @_getUnderlyingValue('cvc')
195
+
196
+ # Debugging helper; if debug is set to true at instantiation, messages will
197
+ # be printed to the console.
198
+ _log: (msg...)->
199
+ if console?.log and !!@options.debug
200
+ console.log("[skeuocard]", msg...) if @options.debug?
201
+
202
+ _flipToInvalidSide: ->
203
+ if Object.keys(@_initialValidationState).length > 0
204
+ _oppositeFace = if @visibleFace is 'front' then 'back' else 'front'
205
+ # if the back face has errors, and the front does not, flip there.
206
+ _errorCounts = {front: 0, back: 0}
207
+ for fieldName, state of @_initialValidationState
208
+ _errorCounts[@product?.layout[fieldName]]++
209
+ if _errorCounts[@visibleFace] == 0 and _errorCounts[_oppositeFace] > 0
210
+ @flip()
211
+
212
+ # Update the card's visual representation to reflect internal state.
213
+ render: ->
214
+ @_log("*** start rendering ***")
215
+
216
+ # Render card product layout changes.
217
+ if @_cardProductNeedsLayout is true
218
+ # Update product-specific details
219
+ if @product isnt undefined
220
+ # change the design and layout of the card to match the matched prod.
221
+ @_log("[render]", "Activating product", @product)
222
+ @el.container.removeClass (index, css)=>
223
+ (css.match(/\b(product|issuer)-\S+/g) || []).join(' ')
224
+ @el.container.addClass("product-#{@product.companyShortname}")
225
+ if @product.issuerShortname?
226
+ @el.container.addClass("issuer-#{@product.issuerShortname}")
227
+ # Adjust underlying card type to match detected type
228
+ @_setUnderlyingCardType(@product.companyShortname)
229
+ # Reconfigure input to match product
230
+ @_inputViews.number.reconfigure
231
+ groupings: @product.cardNumberGrouping
232
+ placeholderChar: @options.cardNumberPlaceholderChar
233
+ @_inputViews.exp.show()
234
+ @_inputViews.name.show()
235
+ @_inputViews.exp.reconfigure
236
+ pattern: @product.expirationFormat
237
+ @_inputViews.cvc.show()
238
+ @_inputViews.cvc.attr
239
+ maxlength: @product.cvcLength
240
+ placeholder: new Array(@product.cvcLength + 1).join(@options.cardNumberPlaceholderChar)
241
+ for fieldName, surfaceName of @product.layout
242
+ sel = if surfaceName is 'front' then 'surfaceFront' else 'surfaceBack'
243
+ container = @el[sel]
244
+ inputEl = @_inputViews[fieldName].el
245
+ unless container.has(inputEl).length > 0
246
+ @_log("Moving", inputEl, "=>", container)
247
+ el = @_inputViews[fieldName].el.detach()
248
+ $(el).appendTo(@el[sel])
249
+ else
250
+ @_log("[render]", "Becoming generic.")
251
+ # Reset to generic input
252
+ @_inputViews.exp.clear()
253
+ @_inputViews.cvc.clear()
254
+ @_inputViews.exp.hide()
255
+ @_inputViews.name.hide()
256
+ @_inputViews.cvc.hide()
257
+ @_inputViews.number.reconfigure
258
+ groupings: [@options.genericPlaceholder.length],
259
+ placeholder: @options.genericPlaceholder
260
+ @el.container.removeClass (index, css)=>
261
+ (css.match(/\bproduct-\S+/g) || []).join(' ')
262
+ @el.container.removeClass (index, css)=>
263
+ (css.match(/\bissuer-\S+/g) || []).join(' ')
264
+ @_cardProductNeedsLayout = false
265
+
266
+ @_log("Validation state:", @_validationState)
267
+
268
+ # Render validation changes
269
+ @showInitialValidationErrors()
270
+
271
+ # If the current face is filled, and there are validation errors, show 'em
272
+ _oppositeFace = if @visibleFace is 'front' then 'back' else 'front'
273
+ _visibleFaceFilled = @_faceFillState[@visibleFace]
274
+ _visibleFaceValid = @isFaceValid(@visibleFace)
275
+ _hiddenFaceFilled = @_faceFillState[_oppositeFace]
276
+ _hiddenFaceValid = @isFaceValid(_oppositeFace)
277
+
278
+ if _visibleFaceFilled and not _visibleFaceValid
279
+ @_log("Visible face is filled, but invalid; showing validation errors.")
280
+ @showValidationErrors()
281
+ else if not _visibleFaceFilled
282
+ @_log("Visible face hasn't been filled; hiding validation errors.")
283
+ @hideValidationErrors()
284
+ else
285
+ @_log("Visible face has been filled, and is valid.")
286
+ @hideValidationErrors()
287
+
288
+ if @visibleFace is 'front' and @fieldsForFace('back').length > 0
289
+ if _visibleFaceFilled and _visibleFaceValid and not _hiddenFaceFilled
290
+ @_tabViews.front.prompt(@options.strings.hiddenFaceFillPrompt, true)
291
+ else if _hiddenFaceFilled and not _hiddenFaceValid
292
+ @_tabViews.front.warn(@options.strings.hiddenFaceErrorWarning, true)
293
+ else if _hiddenFaceFilled and _hiddenFaceValid
294
+ @_tabViews.front.prompt(@options.strings.hiddenFaceSwitchPrompt, true)
295
+ else
296
+ @_tabViews.front.hide()
297
+ else
298
+ if _hiddenFaceValid
299
+ @_tabViews.back.prompt(@options.strings.hiddenFaceSwitchPrompt, true)
300
+ else
301
+ @_tabViews.back.warn(@options.strings.hiddenFaceErrorWarning, true)
302
+
303
+ # Update the validity indicator for the whole card body
304
+ if not @isValid()
305
+ @el.container.removeClass('valid')
306
+ @el.container.addClass('invalid')
307
+ else
308
+ @el.container.addClass('valid')
309
+ @el.container.removeClass('invalid')
310
+
311
+ @_log("*** rendering complete ***")
312
+
313
+ # We should *always* show initial validation errors; they shouldn't show and
314
+ # hide with the rest of the errors unless their value has been changed.
315
+ showInitialValidationErrors: ->
316
+ for fieldName, state of @_initialValidationState
317
+ if state is false and @_validationState[fieldName] is false
318
+ # if the error hasn't been rectified
319
+ @_inputViews[fieldName].addClass('invalid')
320
+ else
321
+ @_inputViews[fieldName].removeClass('invalid')
322
+
323
+ showValidationErrors: ->
324
+ for fieldName, state of @_validationState
325
+ if state is true
326
+ @_inputViews[fieldName].removeClass('invalid')
327
+ else
328
+ @_inputViews[fieldName].addClass('invalid')
329
+
330
+ hideValidationErrors: ->
331
+ for fieldName, state of @_validationState
332
+ if (@_initialValidationState[fieldName] is false and state is true) or
333
+ (not @_initialValidationState[fieldName]?)
334
+ @_inputViews[fieldName].el.removeClass('invalid')
335
+
336
+ setFieldValidationState: (fieldName, valid)->
337
+ if valid
338
+ @el.underlyingFields[fieldName].removeClass('invalid')
339
+ else
340
+ @el.underlyingFields[fieldName].addClass('invalid')
341
+ @_validationState[fieldName] = valid
342
+
343
+ isFaceFilled: (faceName)->
344
+ fields = @fieldsForFace(faceName)
345
+ filled = (name for name in fields when @_inputViews[name].isFilled())
346
+ if fields.length > 0
347
+ return filled.length is fields.length
348
+ else
349
+ return false
350
+
351
+ fieldsForFace: (faceName)->
352
+ if @product?.layout
353
+ return (fn for fn, face of @product.layout when face is faceName)
354
+ return []
355
+
356
+ _updateValidationStateForInputView: (fieldName)->
357
+ field = @_inputViews[fieldName]
358
+ fieldValid = field.isValid() and
359
+ not (@_initialValidationState[fieldName] is false and
360
+ field.getValue() is @options.initialValues[fieldName])
361
+ # trigger a change event if the field has changed
362
+ if fieldValid isnt @_validationState[fieldName]
363
+ @setFieldValidationState(fieldName, fieldValid)
364
+ # Update the fill state
365
+ @_faceFillState.front = @isFaceFilled('front')
366
+ @_faceFillState.back = @isFaceFilled('back')
367
+ @trigger('validationStateDidChange.skeuocard', [@, @_validationState])
368
+ @_log("Change in validation for #{fieldName} triggers re-render.")
369
+ @render()
370
+
371
+ isFaceValid: (faceName)->
372
+ valid = true
373
+ for fieldName in @fieldsForFace(faceName)
374
+ valid &= @_validationState[fieldName]
375
+ return !!valid
376
+
377
+ isValid: ->
378
+ @_validationState.number and
379
+ @_validationState.exp and
380
+ @_validationState.name and
381
+ @_validationState.cvc
382
+
383
+ # Get a value from the underlying form.
384
+ _getUnderlyingValue: (field)->
385
+ @el.underlyingFields[field].val()
386
+
387
+ # Set a value in the underlying form.
388
+ _setUnderlyingValue: (field, newValue)->
389
+ @trigger('change.skeuocard', [@]) # changing the underlying value triggers a change.
390
+ @el.underlyingFields[field].val(newValue)
391
+
392
+ # Flip the card over.
393
+ flip: ->
394
+ targetFace = if @visibleFace is 'front' then 'back' else 'front'
395
+ @trigger('faceWillBecomeVisible.skeuocard', [@, targetFace])
396
+ @visibleFace = targetFace
397
+ @render()
398
+ @el.cardBody.toggleClass('flip')
399
+ @trigger('faceDidBecomeVisible.skeuocard', [@, targetFace])
400
+
401
+ getProductForNumber: (num)->
402
+ for m, d of @acceptedCardProducts
403
+ parts = m.split('/')
404
+ matcher = new RegExp(parts[1], parts[2])
405
+ if matcher.test(num)
406
+ issuer = @getIssuerForNumber(num) || {}
407
+ return $.extend({}, d, issuer)
408
+ return undefined
409
+
410
+ getIssuerForNumber: (num)->
411
+ for m, d of CCIssuers
412
+ parts = m.split('/')
413
+ matcher = new RegExp(parts[1], parts[2])
414
+ if matcher.test(num)
415
+ return d
416
+ return undefined
417
+
418
+ _setUnderlyingCardType: (shortname)->
419
+ @el.underlyingFields.type.find('option').each (i, _el)=>
420
+ el = $(_el)
421
+ if shortname is (el.attr('data-card-product-shortname') || el.attr('value'))
422
+ el.val(el.attr('value')) # change which option is selected
423
+
424
+ trigger: (args...)->
425
+ @el.container.trigger(args...)
426
+
427
+ bind: (args...)->
428
+ @el.container.trigger(args...)
429
+
430
+ ###
431
+ Skeuocard::FlipTabView
432
+ Handles rendering of the "flip button" control and its various warning and
433
+ prompt states.
434
+ ###
435
+ class Skeuocard::FlipTabView
436
+ constructor: (face, opts = {})->
437
+ @el = $("<div class=\"flip-tab #{face}\"><p></p></div>")
438
+ @options = opts
439
+
440
+ _setText: (text)->
441
+ @el.find('p').html(text)
442
+
443
+ warn: (message, withAnimation = false)->
444
+ @_resetClasses()
445
+ @el.addClass('warn')
446
+ @_setText(message)
447
+ @show()
448
+ if withAnimation
449
+ @el.removeClass('warn-anim')
450
+ @el.addClass('warn-anim')
451
+
452
+ prompt: (message, withAnimation = false)->
453
+ @_resetClasses()
454
+ @el.addClass('prompt')
455
+ @_setText(message)
456
+ @show()
457
+ if withAnimation
458
+ @el.removeClass('valid-anim')
459
+ @el.addClass('valid-anim')
460
+
461
+ _resetClasses: ->
462
+ @el.removeClass('valid-anim')
463
+ @el.removeClass('warn-anim')
464
+ @el.removeClass('warn')
465
+ @el.removeClass('prompt')
466
+
467
+ show: ->
468
+ @el.show()
469
+
470
+ hide: ->
471
+ @el.hide()
472
+
473
+ ###
474
+ Skeuocard::TextInputView
475
+ ###
476
+ class Skeuocard::TextInputView
477
+
478
+ bind: (args...)->
479
+ @el.bind(args...)
480
+
481
+ trigger: (args...)->
482
+ @el.trigger(args...)
483
+
484
+ _getFieldCaretPosition: (el)->
485
+ input = el.get(0)
486
+ if input.selectionEnd?
487
+ return input.selectionEnd
488
+ else if document.selection
489
+ input.focus()
490
+ sel = document.selection.createRange()
491
+ selLength = document.selection.createRange().text.length
492
+ sel.moveStart('character', -input.value.length)
493
+ return selLength
494
+
495
+ _setFieldCaretPosition: (el, pos)->
496
+ input = el.get(0)
497
+ if input.createTextRange?
498
+ range = input.createTextRange()
499
+ range.move "character", pos
500
+ range.select()
501
+ else if input.selectionStart?
502
+ input.focus()
503
+ input.setSelectionRange(pos, pos)
504
+
505
+ show: ->
506
+ @el.show()
507
+
508
+ hide: ->
509
+ @el.hide()
510
+
511
+ addClass: (args...)->
512
+ @el.addClass(args...)
513
+
514
+ removeClass: (args...)->
515
+ @el.removeClass(args...)
516
+
517
+ _zeroPadNumber: (num, places)->
518
+ zero = places - num.toString().length + 1
519
+ return Array(zero).join("0") + num
520
+
521
+ class Skeuocard::SegmentedCardNumberInputView extends Skeuocard::TextInputView
522
+ constructor: (opts = {})->
523
+ # Setup option defaults
524
+ opts.value ||= ""
525
+ opts.groupings ||= [19]
526
+ opts.placeholderChar ||= "X"
527
+ @options = opts
528
+ # everythIng else
529
+ @value = @options.value
530
+ @el = $("<fieldset>")
531
+ @el.delegate "input", "keydown", (e)=> @_onGroupKeyDown(e)
532
+ @el.delegate "input", "keyup", (e)=> @_onGroupKeyUp(e)
533
+ @groupEls = $()
534
+
535
+ _onGroupKeyDown: (e)->
536
+ e.stopPropagation()
537
+ groupEl = $(e.currentTarget)
538
+
539
+ arrowKeys = [37, 38, 39, 40]
540
+ groupEl = $(e.currentTarget)
541
+ groupMaxLength = parseInt(groupEl.attr('maxlength'))
542
+ groupCaretPos = @_getFieldCaretPosition(groupEl)
543
+
544
+ if e.which is 8 and groupCaretPos is 0 and not $.isEmptyObject(groupEl.prev())
545
+ groupEl.prev().focus()
546
+
547
+ if e.which in arrowKeys
548
+ switch e.which
549
+ when 37 # left
550
+ if groupCaretPos is 0 and not $.isEmptyObject(groupEl.prev())
551
+ groupEl.prev().focus()
552
+ when 39 # right
553
+ if groupCaretPos is groupMaxLength and not $.isEmptyObject(groupEl.next())
554
+ groupEl.next().focus()
555
+ when 38 # up
556
+ if not $.isEmptyObject(groupEl.prev())
557
+ groupEl.prev().focus()
558
+ when 40 # down
559
+ if not $.isEmptyObject(groupEl.next())
560
+ groupEl.next().focus()
561
+
562
+ _onGroupKeyUp: (e)->
563
+ e.stopPropagation() # prevent event from bubbling up
564
+
565
+ specialKeys = [8, 9, 16, 17, 18, 19, 20, 27, 33, 34, 35, 36,
566
+ 37, 38, 39, 40, 45, 46, 91, 93, 144, 145, 224]
567
+ groupEl = $(e.currentTarget)
568
+ groupMaxLength = parseInt(groupEl.attr('maxlength'))
569
+ groupCaretPos = @_getFieldCaretPosition(groupEl)
570
+
571
+ if e.which not in specialKeys
572
+ # intercept bad chars, returning user to the right char pos if need be
573
+ groupValLength = groupEl.val().length
574
+ pattern = new RegExp('[^0-9]+', 'g')
575
+ groupEl.val(groupEl.val().replace(pattern, ''))
576
+ if groupEl.val().length < groupValLength # we caught bad char
577
+ @_setFieldCaretPosition(groupEl, groupCaretPos - 1)
578
+ else
579
+ @_setFieldCaretPosition(groupEl, groupCaretPos)
580
+
581
+ if e.which not in specialKeys and
582
+ groupEl.val().length is groupMaxLength and
583
+ not $.isEmptyObject(groupEl.next()) and
584
+ @_getFieldCaretPosition(groupEl) is groupMaxLength
585
+ groupEl.next().focus()
586
+
587
+ # update the value
588
+ newValue = ""
589
+ @groupEls.each -> newValue += $(@).val()
590
+ @value = newValue
591
+ @trigger("keyup", [@])
592
+ return false
593
+
594
+ setGroupings: (groupings)->
595
+ caretPos = @_caretPosition()
596
+ @el.empty() # remove all inputs
597
+ _startLength = 0
598
+ for groupLength in groupings
599
+ groupEl = $("<input>").attr
600
+ type: 'text'
601
+ size: groupLength
602
+ maxlength: groupLength
603
+ class: "group#{groupLength}"
604
+ # restore value, if necessary
605
+ if @value.length > _startLength
606
+ groupEl.val(@value.substr(_startLength, groupLength))
607
+ _startLength += groupLength
608
+ @el.append(groupEl)
609
+ @options.groupings = groupings
610
+ @groupEls = @el.find("input")
611
+ # restore to previous settings
612
+ @_caretTo(caretPos)
613
+ if @options.placeholderChar isnt undefined
614
+ @setPlaceholderChar(@options.placeholderChar)
615
+ if @options.placeholder isnt undefined
616
+ @setPlaceholder(@options.placeholder)
617
+
618
+ setPlaceholderChar: (ch)->
619
+ @groupEls.each ->
620
+ el = $(@)
621
+ el.attr 'placeholder', new Array(parseInt(el.attr('maxlength'))+1).join(ch)
622
+ @options.placeholder = undefined
623
+ @options.placeholderChar = ch
624
+
625
+ setPlaceholder: (str)->
626
+ @groupEls.each ->
627
+ $(@).attr 'placeholder', str
628
+ @options.placeholderChar = undefined
629
+ @options.placeholder = str
630
+
631
+ setValue: (newValue)->
632
+ lastPos = 0
633
+ @groupEls.each ->
634
+ el = $(@)
635
+ len = parseInt(el.attr('maxlength'))
636
+ el.val(newValue.substr(lastPos, len))
637
+ lastPos += len
638
+ @value = newValue
639
+
640
+ getValue: ->
641
+ @value
642
+
643
+ reconfigure: (changes = {})->
644
+ if changes.groupings?
645
+ @setGroupings(changes.groupings)
646
+ if changes.placeholderChar?
647
+ @setPlaceholderChar(changes.placeholderChar)
648
+ if changes.placeholder?
649
+ @setPlaceholder(changes.placeholder)
650
+ if changes.value?
651
+ @setValue(changes.value)
652
+
653
+ _caretTo: (index)->
654
+ pos = 0
655
+ inputEl = undefined
656
+ inputElIndex = 0
657
+ # figure out which group we're in
658
+ @groupEls.each (i, e)=>
659
+ el = $(e)
660
+ elLength = parseInt(el.attr('maxlength'))
661
+ if index <= elLength + pos and index >= pos
662
+ inputEl = el
663
+ inputElIndex = index - pos
664
+ pos += elLength
665
+ # move the caret there
666
+ @_setFieldCaretPosition(inputEl, inputElIndex)
667
+
668
+ _caretPosition: ->
669
+ iPos = 0
670
+ finalPos = 0
671
+ @groupEls.each (i, e)=>
672
+ el = $(e)
673
+ if el.is(':focus')
674
+ finalPos = iPos + @_getFieldCaretPosition(el)
675
+ iPos += parseInt(el.attr('maxlength'))
676
+ return finalPos
677
+
678
+ maxLength: ->
679
+ @options.groupings.reduce((a,b)->(a+b))
680
+
681
+ isFilled: ->
682
+ @value.length == @maxLength()
683
+
684
+ isValid: ->
685
+ @isFilled() and @isValidLuhn(@value)
686
+
687
+ isValidLuhn: (identifier)->
688
+ sum = 0
689
+ alt = false
690
+ for i in [identifier.length - 1..0] by -1
691
+ num = parseInt identifier.charAt(i), 10
692
+ return false if isNaN(num)
693
+ if alt
694
+ num *= 2
695
+ num = (num % 10) + 1 if num > 9
696
+ alt = !alt
697
+ sum += num
698
+ sum % 10 is 0
699
+
700
+ ###
701
+ Skeuocard::ExpirationInputView
702
+ ###
703
+ class Skeuocard::ExpirationInputView extends Skeuocard::TextInputView
704
+ constructor: (opts = {})->
705
+ # setup option defaults
706
+ opts.dateFormatter ||= (date)->
707
+ date.getDate() + "-" + (date.getMonth()+1) + "-" + date.getFullYear()
708
+ opts.dateParser ||= (value)->
709
+ dateParts = value.split('-')
710
+ new Date(dateParts[2], dateParts[1]-1, dateParts[0])
711
+ opts.currentDate ||= new Date()
712
+ opts.pattern ||= "MM/YY"
713
+
714
+ @options = opts
715
+ # setup default values
716
+ @date = undefined
717
+ @value = undefined
718
+ # create dom container
719
+ @el = $("<fieldset>")
720
+ @el.delegate "input", "keydown", (e)=> @_onKeyDown(e)
721
+ @el.delegate "input", "keyup", (e)=> @_onKeyUp(e)
722
+
723
+ _getFieldCaretPosition: (el)->
724
+ input = el.get(0)
725
+ if input.selectionEnd?
726
+ return input.selectionEnd
727
+ else if document.selection
728
+ input.focus()
729
+ sel = document.selection.createRange()
730
+ selLength = document.selection.createRange().text.length
731
+ sel.moveStart('character', -input.value.length)
732
+ return selLength
733
+
734
+ _setFieldCaretPosition: (el, pos)->
735
+ input = el.get(0)
736
+ if input.createTextRange?
737
+ range = input.createTextRange()
738
+ range.move "character", pos
739
+ range.select()
740
+ else if input.selectionStart?
741
+ input.focus()
742
+ input.setSelectionRange(pos, pos)
743
+
744
+ setPattern: (pattern)->
745
+ groupings = []
746
+ patternParts = pattern.split('')
747
+ _currentLength = 0
748
+ for char, i in patternParts
749
+ _currentLength++
750
+ if patternParts[i+1] != char
751
+ groupings.push([_currentLength, char])
752
+ _currentLength = 0
753
+ @options.groupings = groupings
754
+ @_setGroupings(@options.groupings)
755
+
756
+ _setGroupings: (groupings)->
757
+ fieldChars = ['D', 'M', 'Y']
758
+ @el.empty()
759
+ _startLength = 0
760
+ for group in groupings
761
+ groupLength = group[0]
762
+ groupChar = group[1]
763
+ if groupChar in fieldChars # this group is a field
764
+ input = $('<input>').attr
765
+ type: 'text'
766
+ placeholder: new Array(groupLength+1).join(groupChar)
767
+ maxlength: groupLength
768
+ class: 'cc-exp-field-' + groupChar.toLowerCase() +
769
+ ' group' + groupLength
770
+ input.data('fieldtype', groupChar)
771
+ @el.append(input)
772
+ else # this group is a separator
773
+ sep = $('<span>').attr
774
+ class: 'separator'
775
+ sep.html(new Array(groupLength + 1).join(groupChar))
776
+ @el.append(sep)
777
+ @groupEls = @el.find('input')
778
+ @_updateFieldValues() if @date?
779
+
780
+ _updateFieldValues: ->
781
+ currentDate = @date
782
+ unless @groupEls # they need to be created
783
+ return @setPattern(@options.pattern)
784
+ @groupEls.each (i,_el)=>
785
+ el = $(_el)
786
+ groupLength = parseInt(el.attr('maxlength'))
787
+ switch el.data('fieldtype')
788
+ when 'M'
789
+ el.val @_zeroPadNumber(currentDate.getMonth() + 1, groupLength)
790
+ when 'D'
791
+ el.val @_zeroPadNumber(currentDate.getDate(), groupLength)
792
+ when 'Y'
793
+ year = if groupLength >= 4 then currentDate.getFullYear() else
794
+ currentDate.getFullYear().toString().substr(2,4)
795
+ el.val(year)
796
+
797
+ clear: ->
798
+ @value = ""
799
+ @date = null
800
+ @groupEls.each ->
801
+ $(@).val('')
802
+
803
+ setDate: (newDate)->
804
+ @date = newDate
805
+ @value = @options.dateFormatter(newDate)
806
+ @_updateFieldValues()
807
+
808
+ setValue: (newValue)->
809
+ @value = newValue
810
+ @date = @options.dateParser(newValue)
811
+ @_updateFieldValues()
812
+
813
+ getDate: ->
814
+ @date
815
+
816
+ getValue: ->
817
+ @value
818
+
819
+ reconfigure: (opts)->
820
+ if opts.pattern?
821
+ @setPattern(opts.pattern)
822
+ if opts.value?
823
+ @setValue(opts.value)
824
+
825
+ _onKeyDown: (e)->
826
+ e.stopPropagation()
827
+ groupEl = $(e.currentTarget)
828
+
829
+ groupEl = $(e.currentTarget)
830
+ groupMaxLength = parseInt(groupEl.attr('maxlength'))
831
+ groupCaretPos = @_getFieldCaretPosition(groupEl)
832
+
833
+ prevInputEl = groupEl.prevAll('input').first()
834
+ nextInputEl = groupEl.nextAll('input').first()
835
+
836
+ # Handle delete key
837
+ if e.which is 8 and groupCaretPos is 0 and
838
+ not $.isEmptyObject(prevInputEl)
839
+ prevInputEl.focus()
840
+
841
+ if e.which in [37, 38, 39, 40] # arrow keys
842
+ switch e.which
843
+ when 37 # left
844
+ if groupCaretPos is 0 and not $.isEmptyObject(prevInputEl)
845
+ prevInputEl.focus()
846
+ when 39 # right
847
+ if groupCaretPos is groupMaxLength and not $.isEmptyObject(nextInputEl)
848
+ nextInputEl.focus()
849
+ when 38 # up
850
+ if not $.isEmptyObject(groupEl.prev('input'))
851
+ prevInputEl.focus()
852
+ when 40 # down
853
+ if not $.isEmptyObject(groupEl.next('input'))
854
+ nextInputEl.focus()
855
+
856
+ _onKeyUp: (e)->
857
+ e.stopPropagation()
858
+
859
+ specialKeys = [8, 9, 16, 17, 18, 19, 20, 27, 33, 34, 35, 36,
860
+ 37, 38, 39, 40, 45, 46, 91, 93, 144, 145, 224]
861
+ arrowKeys = [37, 38, 39, 40]
862
+ groupEl = $(e.currentTarget)
863
+ groupMaxLength = parseInt(groupEl.attr('maxlength'))
864
+ groupCaretPos = @_getFieldCaretPosition(groupEl)
865
+
866
+ if e.which not in specialKeys
867
+ # intercept bad chars, returning user to the right char pos if need be
868
+ groupValLength = groupEl.val().length
869
+ pattern = new RegExp('[^0-9]+', 'g')
870
+ groupEl.val(groupEl.val().replace(pattern, ''))
871
+ if groupEl.val().length < groupValLength # we caught bad char
872
+ @_setFieldCaretPosition(groupEl, groupCaretPos - 1)
873
+ else
874
+ @_setFieldCaretPosition(groupEl, groupCaretPos)
875
+
876
+ nextInputEl = groupEl.nextAll('input').first()
877
+
878
+ if e.which not in specialKeys and
879
+ groupEl.val().length is groupMaxLength and
880
+ not $.isEmptyObject(nextInputEl) and
881
+ @_getFieldCaretPosition(groupEl) is groupMaxLength
882
+ nextInputEl.focus()
883
+
884
+ # get a date object representing what's been entered
885
+ day = parseInt(@el.find('.cc-exp-field-d').val()) || 1
886
+ month = parseInt(@el.find('.cc-exp-field-m').val())
887
+ year = parseInt(@el.find('.cc-exp-field-y').val())
888
+ if month is 0 or year is 0
889
+ @value = ""
890
+ @date = null
891
+ else
892
+ year += 2000 if year < 2000
893
+ dateObj = new Date(year, month-1, day)
894
+ @value = @options.dateFormatter(dateObj)
895
+ @date = dateObj
896
+ @trigger("keyup", [@])
897
+ return false
898
+
899
+ _inputGroupEls: ->
900
+ @el.find("input")
901
+
902
+ isFilled: ->
903
+ for inputEl in @groupEls
904
+ el = $(inputEl)
905
+ return false if el.val().length != parseInt(el.attr('maxlength'))
906
+ return true
907
+
908
+ isValid: ->
909
+ @isFilled() and
910
+ ((@date.getFullYear() == @options.currentDate.getFullYear() and
911
+ @date.getMonth() >= @options.currentDate.getMonth()) or
912
+ @date.getFullYear() > @options.currentDate.getFullYear())
913
+
914
+
915
+ class Skeuocard::TextInputView extends Skeuocard::TextInputView
916
+ constructor: (opts)->
917
+ @el = $("<input>").attr
918
+ type: 'text'
919
+ placeholder: opts.placeholder
920
+ class: opts.class
921
+ @options = opts
922
+
923
+ clear: ->
924
+ @el.val("")
925
+
926
+ attr: (args...)->
927
+ @el.attr(args...)
928
+
929
+ isFilled: ->
930
+ return @el.val().length > 0
931
+
932
+ isValid: ->
933
+ if @options.requireMaxLength
934
+ return @el.val().length is parseInt(@el.attr('maxlength'))
935
+ else
936
+ return @isFilled()
937
+
938
+ getValue: ->
939
+ @el.val()
940
+
941
+ # Export the object.
942
+ window.Skeuocard = Skeuocard
943
+
944
+ ###
945
+ # Card Definitions
946
+ ###
947
+
948
+ # List of credit card products by matching prefix.
949
+ CCProducts = {}
950
+
951
+ CCProducts[/^30[0-5][0-9]/] =
952
+ companyName: "Diners Club"
953
+ companyShortname: "dinersclubintl"
954
+ cardNumberGrouping: [4,6,4]
955
+ expirationFormat: "MM/YY"
956
+ cvcLength: 3
957
+ layout:
958
+ number: 'front'
959
+ exp: 'front'
960
+ name: 'front'
961
+ cvc: 'back'
962
+
963
+ CCProducts[/^3095/] =
964
+ companyName: "Diners Club International"
965
+ companyShortname: "dinersclubintl"
966
+ cardNumberGrouping: [4,6,4]
967
+ expirationFormat: "MM/YY"
968
+ cvcLength: 3
969
+ layout:
970
+ number: 'front'
971
+ exp: 'front'
972
+ name: 'front'
973
+ cvc: 'back'
974
+
975
+ CCProducts[/^36\d{2}/] =
976
+ companyName: "Diners Club International"
977
+ companyShortname: "dinersclubintl"
978
+ cardNumberGrouping: [4,6,4]
979
+ expirationFormat: "MM/YY"
980
+ cvcLength: 3
981
+ layout:
982
+ number: 'front'
983
+ exp: 'front'
984
+ name: 'front'
985
+ cvc: 'back'
986
+
987
+ CCProducts[/^35\d{2}/] =
988
+ companyName: "JCB"
989
+ companyShortname: "jcb"
990
+ cardNumberGrouping: [4,4,4,4]
991
+ expirationFormat: "MM/YY"
992
+ cvcLength: 3
993
+ layout:
994
+ number: 'front'
995
+ exp: 'front'
996
+ name: 'front'
997
+ cvc: 'back'
998
+
999
+ CCProducts[/^37/] =
1000
+ companyName: "American Express"
1001
+ companyShortname: "amex"
1002
+ cardNumberGrouping: [4,6,5]
1003
+ expirationFormat: "MM/YY"
1004
+ cvcLength: 4
1005
+ layout:
1006
+ number: 'front'
1007
+ exp: 'front'
1008
+ name: 'front'
1009
+ cvc: 'front'
1010
+
1011
+ CCProducts[/^38/] =
1012
+ companyName: "Hipercard"
1013
+ companyShortname: "hipercard"
1014
+ cardNumberGrouping: [4,4,4,4]
1015
+ expirationFormat: "MM/YY"
1016
+ cvcLength: 3
1017
+ layout:
1018
+ number: 'front'
1019
+ exp: 'front'
1020
+ name: 'front'
1021
+ cvc: 'back'
1022
+
1023
+ CCProducts[/^4[0-9]\d{2}/] =
1024
+ companyName: "Visa"
1025
+ companyShortname: "visa"
1026
+ cardNumberGrouping: [4,4,4,4]
1027
+ expirationFormat: "MM/YY"
1028
+ cvcLength: 3
1029
+ layout:
1030
+ number: 'front'
1031
+ exp: 'front'
1032
+ name: 'front'
1033
+ cvc: 'back'
1034
+
1035
+ CCProducts[/^5[0-8]\d{2}/] =
1036
+ companyName: "Mastercard"
1037
+ companyShortname: "mastercard"
1038
+ cardNumberGrouping: [4,4,4,4]
1039
+ expirationFormat: "MM/YY"
1040
+ cvcLength: 3
1041
+ layout:
1042
+ number: 'front'
1043
+ exp: 'front'
1044
+ name: 'front'
1045
+ cvc: 'back'
1046
+
1047
+ CCProducts[/^6011/] =
1048
+ companyName: "Discover"
1049
+ companyShortname: "discover"
1050
+ cardNumberGrouping: [4,4,4,4]
1051
+ expirationFormat: "MM/YY"
1052
+ cvcLength: 3
1053
+ layout:
1054
+ number: 'front'
1055
+ exp: 'front'
1056
+ name: 'front'
1057
+ cvc: 'back'
1058
+
1059
+ CCIssuers = {}
1060
+
1061
+ ###
1062
+ Hack fixes the Chase Sapphire card's stupid (nice?) layout non-conformity.
1063
+ ###
1064
+ CCIssuers[/^414720/] =
1065
+ issuingAuthority: "Chase"
1066
+ issuerName: "Chase Sapphire Card"
1067
+ issuerShortname: "chase-sapphire"
1068
+ layout:
1069
+ number: 'front'
1070
+ exp: 'front'
1071
+ name: 'front'
1072
+ cvc: 'front'