skeuocard-rails 0.0.1

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.
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'