├── log └── .keep ├── app ├── mailers │ └── .keep ├── models │ ├── .keep │ ├── concerns │ │ └── .keep │ ├── user.rb │ ├── booking.rb │ └── api_client.rb ├── assets │ ├── images │ │ ├── .keep │ │ └── auth-flow.gif │ ├── javascripts │ │ ├── components │ │ │ ├── .gitkeep │ │ │ ├── api_client.js │ │ │ ├── has_timer.js.jsx │ │ │ ├── has_drop_menu.js.jsx │ │ │ ├── loading_overlay.js.jsx │ │ │ ├── book_now_button.js.jsx │ │ │ ├── new_booking_page.js.jsx │ │ │ ├── primary_navigation.js.jsx │ │ │ ├── edit_booking_page.js.jsx │ │ │ ├── booking_pending_button.js.jsx │ │ │ ├── booking_complete_button.js.jsx │ │ │ ├── booking_form.js.jsx │ │ │ ├── list_page.js.jsx │ │ │ └── login_page.js.jsx │ │ ├── components.js │ │ └── application.js │ └── stylesheets │ │ ├── spinkit │ │ ├── _variables.scss │ │ ├── spinkit.scss │ │ └── spinners │ │ │ ├── 5-pulse.scss │ │ │ ├── 1-rotating-plane.scss │ │ │ ├── 9-wordpress.scss │ │ │ ├── 2-double-bounce.scss │ │ │ ├── 3-wave.scss │ │ │ ├── 7-three-bounce.scss │ │ │ ├── 6-chasing-dots.scss │ │ │ ├── 4-wandering-cubes.scss │ │ │ ├── 9-cube-grid.scss │ │ │ ├── 8-circle.scss │ │ │ └── 10-fading-circle.scss │ │ └── application.css.scss ├── controllers │ ├── concerns │ │ └── .keep │ ├── pages_controller.rb │ ├── sessions_controller.rb │ ├── locations_controller.rb │ ├── users_controller.rb │ ├── authentication_controller.rb │ ├── application_controller.rb │ └── bookings_controller.rb ├── helpers │ └── application_helper.rb ├── views │ ├── bookings │ │ ├── new.html.erb │ │ └── edit.html.erb │ ├── pages │ │ ├── home.html.erb │ │ └── rentals.html.erb │ ├── authentication │ │ └── new.html.erb │ └── layouts │ │ └── application.html.erb ├── serializers │ └── booking_serializer.rb └── services │ └── perform_bookings.rb ├── lib ├── assets │ └── .keep └── tasks │ ├── .keep │ └── perform_bookings.rake ├── public ├── favicon.ico ├── robots.txt ├── 500.html ├── 422.html └── 404.html ├── vendor └── assets │ ├── javascripts │ └── .keep │ └── stylesheets │ └── .keep ├── README.md ├── Procfile ├── bin ├── rake ├── bundle ├── rails └── setup ├── config ├── boot.rb ├── initializers │ ├── twilio.rb │ ├── geocoder.rb │ ├── cookies_serializer.rb │ ├── active_model_serializer.rb │ ├── mime_types.rb │ ├── filter_parameter_logging.rb │ ├── session_store.rb │ ├── backtrace_silencers.rb │ ├── assets.rb │ ├── wrap_parameters.rb │ └── inflections.rb ├── environment.rb ├── unicorn.rb ├── locales │ └── en.yml ├── secrets.yml ├── environments │ ├── development.rb │ ├── test.rb │ └── production.rb ├── application.rb ├── routes.rb └── database.yml ├── config.ru ├── db ├── migrate │ ├── 20150322223317_add_password_digest_to_users.rb │ ├── 20150324011147_add_car_plate_to_bookings.rb │ ├── 20150324235103_add_asap_to_bookings.rb │ ├── 20150325034141_add_reservation_id_to_bookings.rb │ ├── 20150325034449_add_reservation_response_to_bookings.rb │ ├── 20150324235432_add_in_progress_to_bookings.rb │ ├── 20150325155804_change_car_booked_time_column.rb │ ├── 20150323213950_add_completed_fields_to_booking.rb │ ├── 20150322194743_create_users.rb │ └── 20150322234851_create_bookings.rb ├── seeds.rb └── schema.rb ├── clock.rb ├── Rakefile ├── .gitignore ├── Gemfile └── Gemfile.lock /log/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/mailers/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/assets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /lib/tasks/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/models/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/controllers/concerns/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/javascripts/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /vendor/assets/stylesheets/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Schedule and book cars for car2go in Vancouver 2 | -------------------------------------------------------------------------------- /app/helpers/application_helper.rb: -------------------------------------------------------------------------------- 1 | module ApplicationHelper 2 | end 3 | -------------------------------------------------------------------------------- /app/views/bookings/new.html.erb: -------------------------------------------------------------------------------- 1 | <%= react_component 'NewBookingPage' %> 2 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | web: bundle exec unicorn -p $PORT -c ./config/unicorn.rb 2 | clock: bundle exec clockwork clock.rb 3 | -------------------------------------------------------------------------------- /app/assets/images/auth-flow.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brentvatne/concierge/HEAD/app/assets/images/auth-flow.gif -------------------------------------------------------------------------------- /bin/rake: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require_relative '../config/boot' 3 | require 'rake' 4 | Rake.application.run 5 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/_variables.scss: -------------------------------------------------------------------------------- 1 | $spinkit-spinner-margin: 0 auto !default; 2 | $spinkit-spinner-color: #333 !default; 3 | -------------------------------------------------------------------------------- /app/views/bookings/edit.html.erb: -------------------------------------------------------------------------------- 1 | <%= react_component 'EditBookingPage', 2 | {booking: BookingSerializer.new(@booking, root: false) } %> 3 | -------------------------------------------------------------------------------- /bin/bundle: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 3 | load Gem.bin_path('bundler', 'bundle') 4 | -------------------------------------------------------------------------------- /config/boot.rb: -------------------------------------------------------------------------------- 1 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile', __FILE__) 2 | 3 | require 'bundler/setup' # Set up gems listed in the Gemfile. 4 | -------------------------------------------------------------------------------- /config/initializers/twilio.rb: -------------------------------------------------------------------------------- 1 | Twilio.configure do |config| 2 | config.account_sid = ENV['TWILIO_SID'] 3 | config.auth_token = ENV['TWILIO_AUTH_TOKEN'] 4 | end 5 | -------------------------------------------------------------------------------- /bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | APP_PATH = File.expand_path('../../config/application', __FILE__) 3 | require_relative '../config/boot' 4 | require 'rails/commands' 5 | -------------------------------------------------------------------------------- /config.ru: -------------------------------------------------------------------------------- 1 | # This file is used by Rack-based servers to start the application. 2 | 3 | require ::File.expand_path('../config/environment', __FILE__) 4 | run Rails.application 5 | -------------------------------------------------------------------------------- /config/initializers/geocoder.rb: -------------------------------------------------------------------------------- 1 | Geokit::default_units = :meters 2 | Geokit::default_formula = :sphere 3 | Geokit::Geocoders::GoogleGeocoder.api_key = ENV['GOOGLE_API_KEY'] 4 | -------------------------------------------------------------------------------- /lib/tasks/perform_bookings.rake: -------------------------------------------------------------------------------- 1 | task :perform_bookings => [:environment] do 2 | PerformBookings.execute( 3 | Booking.within_booking_window.incomplete 4 | ) 5 | end 6 | -------------------------------------------------------------------------------- /config/initializers/cookies_serializer.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | Rails.application.config.action_dispatch.cookies_serializer = :json 4 | -------------------------------------------------------------------------------- /config/environment.rb: -------------------------------------------------------------------------------- 1 | # Load the Rails application. 2 | require File.expand_path('../application', __FILE__) 3 | 4 | # Initialize the Rails application. 5 | Rails.application.initialize! 6 | -------------------------------------------------------------------------------- /app/assets/javascripts/components.js: -------------------------------------------------------------------------------- 1 | //= require_self 2 | //= require ./components/has_drop_menu 3 | //= require_tree ./components 4 | 5 | var self, window, global = global || window || self; 6 | -------------------------------------------------------------------------------- /config/initializers/active_model_serializer.rb: -------------------------------------------------------------------------------- 1 | # Convert attributes from snake_case to lowerCamelCase 2 | ActiveModel::Serializer.setup do |config| 3 | config.key_format = :lower_camel 4 | end 5 | -------------------------------------------------------------------------------- /config/initializers/mime_types.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new mime types for use in respond_to blocks: 4 | # Mime::Type.register "text/richtext", :rtf 5 | -------------------------------------------------------------------------------- /db/migrate/20150322223317_add_password_digest_to_users.rb: -------------------------------------------------------------------------------- 1 | class AddPasswordDigestToUsers < ActiveRecord::Migration 2 | def change 3 | add_column :users, :password_digest, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150324011147_add_car_plate_to_bookings.rb: -------------------------------------------------------------------------------- 1 | class AddCarPlateToBookings < ActiveRecord::Migration 2 | def change 3 | add_column :bookings, :car_license_plate, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150324235103_add_asap_to_bookings.rb: -------------------------------------------------------------------------------- 1 | class AddAsapToBookings < ActiveRecord::Migration 2 | def change 3 | add_column :bookings, :asap, :boolean, default: false, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150325034141_add_reservation_id_to_bookings.rb: -------------------------------------------------------------------------------- 1 | class AddReservationIdToBookings < ActiveRecord::Migration 2 | def change 3 | add_column :bookings, :reservation_id, :string 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150325034449_add_reservation_response_to_bookings.rb: -------------------------------------------------------------------------------- 1 | class AddReservationResponseToBookings < ActiveRecord::Migration 2 | def change 3 | add_column :bookings, :reservation_response, :text 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /db/migrate/20150324235432_add_in_progress_to_bookings.rb: -------------------------------------------------------------------------------- 1 | class AddInProgressToBookings < ActiveRecord::Migration 2 | def change 3 | add_column :bookings, :in_progress, :boolean, default: false, null: false 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /public/robots.txt: -------------------------------------------------------------------------------- 1 | # See http://www.robotstxt.org/robotstxt.html for documentation on how to use the robots.txt file 2 | # 3 | # To ban all spiders from the entire site uncomment the next two lines: 4 | # User-agent: * 5 | # Disallow: / 6 | -------------------------------------------------------------------------------- /app/views/pages/home.html.erb: -------------------------------------------------------------------------------- 1 | <% if current_user.present? %> 2 | <%= react_component 'ListPage', {upcomingBookings: @upcoming_bookings} %> 3 | <% else %> 4 | <%= react_component 'LoginPage', {authorizationUrl: @authorization_url} %> 5 | <% end %> 6 | -------------------------------------------------------------------------------- /config/initializers/filter_parameter_logging.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Configure sensitive parameters which will be filtered from the log file. 4 | Rails.application.config.filter_parameters += [:password] 5 | -------------------------------------------------------------------------------- /db/migrate/20150325155804_change_car_booked_time_column.rb: -------------------------------------------------------------------------------- 1 | class ChangeCarBookedTimeColumn < ActiveRecord::Migration 2 | def change 3 | remove_column :bookings, :car_booked_time 4 | add_column :bookings, :car_booked_time, :datetime 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /clock.rb: -------------------------------------------------------------------------------- 1 | require 'clockwork' 2 | require_relative './config/boot' 3 | require_relative './config/environment' 4 | 5 | include Clockwork 6 | 7 | handler do |job| 8 | end 9 | 10 | every(1.minute, 'Performing bookings') { 11 | `rake perform_bookings` 12 | } 13 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # Add your own tasks in files placed in lib/tasks ending in .rake, 2 | # for example lib/tasks/capistrano.rake, and they will automatically be available to Rake. 3 | 4 | require File.expand_path('../config/application', __FILE__) 5 | 6 | Rails.application.load_tasks 7 | -------------------------------------------------------------------------------- /config/initializers/session_store.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Rails.application.config.session_store :cookie_store, key: '_concierge_session' 4 | Rails.application.config.session_store ActionDispatch::Session::CacheStore, expire_after: 3.months 5 | -------------------------------------------------------------------------------- /db/migrate/20150323213950_add_completed_fields_to_booking.rb: -------------------------------------------------------------------------------- 1 | class AddCompletedFieldsToBooking < ActiveRecord::Migration 2 | def change 3 | add_column :bookings, :car_address, :string 4 | add_column :bookings, :car_distance, :string 5 | add_column :bookings, :car_booked_time, :string 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /db/migrate/20150322194743_create_users.rb: -------------------------------------------------------------------------------- 1 | class CreateUsers < ActiveRecord::Migration 2 | def change 3 | create_table :users do |t| 4 | t.string :oauth_token_secret 5 | t.string :oauth_token 6 | t.string :email 7 | t.string :phone 8 | t.string :name 9 | t.timestamps 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /app/controllers/pages_controller.rb: -------------------------------------------------------------------------------- 1 | class PagesController < ApplicationController 2 | 3 | def home 4 | if current_user.present? 5 | @upcoming_bookings = current_user.bookings.upcoming_or_active.order('time ASC') 6 | @upcoming_bookings.to_a.map! { |b| BookingSerializer.new(b, root: false) } 7 | end 8 | end 9 | 10 | end 11 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/api_client.js: -------------------------------------------------------------------------------- 1 | global.ApiClient = { 2 | bookNow: function(data) { 3 | $.post('/book-now', data). 4 | complete(function() { window.location.reload() }) 5 | }, 6 | 7 | addressForCoords: function(data, callback) { 8 | $.get('/locations/address-for-coords', data). 9 | success(callback); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /db/seeds.rb: -------------------------------------------------------------------------------- 1 | # This file should contain all the record creation needed to seed the database with its default values. 2 | # The data can then be loaded with the rake db:seed (or created alongside the db with db:setup). 3 | # 4 | # Examples: 5 | # 6 | # cities = City.create([{ name: 'Chicago' }, { name: 'Copenhagen' }]) 7 | # Mayor.create(name: 'Emanuel', city: cities.first) 8 | -------------------------------------------------------------------------------- /app/controllers/sessions_controller.rb: -------------------------------------------------------------------------------- 1 | class SessionsController < ApplicationController 2 | 3 | def create 4 | @user = User.find_by_email(params[:email]) 5 | 6 | if @user.authenticate(params[:password]) 7 | session[:current_user_id] = @user.id 8 | render json: {success: true} 9 | else 10 | render json: {success: false} 11 | end 12 | end 13 | 14 | end 15 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinkit.scss: -------------------------------------------------------------------------------- 1 | @import 2 | "spinners/1-rotating-plane", 3 | "spinners/2-double-bounce", 4 | "spinners/3-wave", 5 | "spinners/4-wandering-cubes", 6 | "spinners/5-pulse", 7 | "spinners/6-chasing-dots", 8 | "spinners/7-three-bounce", 9 | "spinners/8-circle", 10 | "spinners/9-cube-grid", 11 | "spinners/9-wordpress", 12 | "spinners/10-fading-circle"; 13 | -------------------------------------------------------------------------------- /db/migrate/20150322234851_create_bookings.rb: -------------------------------------------------------------------------------- 1 | class CreateBookings < ActiveRecord::Migration 2 | def change 3 | create_table :bookings do |t| 4 | t.string :title 5 | t.datetime :time 6 | t.string :address 7 | t.float :lat 8 | t.float :lon 9 | t.boolean :complete, default: false, null: false 10 | t.references :user 11 | t.timestamps 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /app/views/pages/rentals.html.erb: -------------------------------------------------------------------------------- 1 |

Rentals:

2 | 3 | 4 | 5 | 6 | 7 | 8 | <% @rentals.each do |rental| %> 9 | 10 | 11 | 12 | 13 | 14 | <% end %> 15 |
CostStartEnd
<%= rental.cost %><%= rental.start_location %> at <%= rental.start_time %><%= rental.end_location %> at <%= rental.end_time %>
16 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/has_timer.js.jsx: -------------------------------------------------------------------------------- 1 | global.HasTimer = { 2 | componentWillMount: function() { 3 | this.intervals = []; 4 | }, 5 | 6 | setInterval: function() { 7 | this.intervals.push(setInterval.apply(null, arguments)); 8 | }, 9 | 10 | componentWillUnmount: function() { 11 | this.clearIntervals(); 12 | }, 13 | 14 | clearIntervals: function() { 15 | this.intervals.map(clearInterval); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /app/serializers/booking_serializer.rb: -------------------------------------------------------------------------------- 1 | class BookingSerializer < ActiveModel::Serializer 2 | attributes :id, :title, :time, :day, :date, :address, :lat, :lon, :complete, 3 | :car_address, :car_license_plate, :in_progress 4 | 5 | def time 6 | object.time.strftime('%l:%M %p') 7 | end 8 | 9 | def day 10 | object.time.strftime('%A') 11 | end 12 | 13 | def date 14 | object.time.strftime('%B %d, %Y') 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /app/controllers/locations_controller.rb: -------------------------------------------------------------------------------- 1 | class LocationsController < ApplicationController 2 | 3 | def address_for_coords 4 | result = Geokit::Geocoders::GoogleGeocoder.geocode("#{params[:lat]} #{params[:lon]}") 5 | 6 | if result.success? 7 | render json: { 8 | address: result.full_address 9 | } 10 | else 11 | render json: { 12 | error: "Could not find address" 13 | } 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /config/initializers/backtrace_silencers.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # You can add backtrace silencers for libraries that you're using but don't wish to see in your backtraces. 4 | # Rails.backtrace_cleaner.add_silencer { |line| line =~ /my_noisy_library/ } 5 | 6 | # You can also remove all the silencers if you're trying to debug a problem that might stem from framework code. 7 | # Rails.backtrace_cleaner.remove_silencers! 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files for more about ignoring files. 2 | # 3 | # If you find yourself ignoring temporary files generated by your text editor 4 | # or operating system, you probably want to add a global ignore instead: 5 | # git config --global core.excludesfile '~/.gitignore_global' 6 | 7 | # Ignore bundler config. 8 | /.bundle 9 | 10 | # Ignore all logfiles and tempfiles. 11 | /log/* 12 | !/log/.keep 13 | /tmp 14 | .env 15 | lib/car2go/**/* 16 | -------------------------------------------------------------------------------- /config/initializers/assets.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Version of your assets, change this if you want to expire all your assets. 4 | Rails.application.config.assets.version = '1.0' 5 | 6 | # Add additional assets to the asset load path 7 | # Rails.application.config.assets.paths << Emoji.images_path 8 | 9 | # Precompile additional assets. 10 | # application.js, application.css, and all non-JS/CSS in app/assets folder are already added. 11 | # Rails.application.config.assets.precompile += %w( search.js ) 12 | -------------------------------------------------------------------------------- /config/initializers/wrap_parameters.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # This file contains settings for ActionController::ParamsWrapper which 4 | # is enabled by default. 5 | 6 | # Enable parameter wrapping for JSON. You can disable this by setting :format to an empty array. 7 | ActiveSupport.on_load(:action_controller) do 8 | wrap_parameters format: [:json] if respond_to?(:wrap_parameters) 9 | end 10 | 11 | # To enable root element in JSON for ActiveRecord objects. 12 | # ActiveSupport.on_load(:active_record) do 13 | # self.include_root_in_json = true 14 | # end 15 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/5-pulse.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | * 6 | */ 7 | @import "../variables"; 8 | 9 | .sk-spinner-pulse { 10 | &.sk-spinner { 11 | width: 40px; 12 | height: 40px; 13 | margin: $spinkit-spinner-margin; 14 | background-color: $spinkit-spinner-color; 15 | 16 | border-radius: 100%; 17 | animation: sk-pulseScaleOut 1.0s infinite ease-in-out; 18 | } 19 | } 20 | 21 | @keyframes sk-pulseScaleOut { 22 | 0% { 23 | transform: scale(0.0); 24 | } 100% { 25 | transform: scale(1.0); 26 | opacity: 0; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/controllers/users_controller.rb: -------------------------------------------------------------------------------- 1 | class UsersController < ApplicationController 2 | 3 | def create 4 | @user = User.create( 5 | oauth_token_secret: session[:oauth_token_secret], 6 | oauth_token: session[:oauth_token], 7 | name: user_params[:name], 8 | email: user_params[:email], 9 | phone: user_params[:phone], 10 | password: user_params[:password], 11 | password_confirmation: user_params[:password] 12 | ) 13 | 14 | session[:current_user_id] = @user.id 15 | render json: {success: true, id: @user.id} 16 | end 17 | 18 | def user_params 19 | params.require(:user).permit(:name, :email, :phone, :password) 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /config/unicorn.rb: -------------------------------------------------------------------------------- 1 | worker_processes Integer(ENV["WEB_CONCURRENCY"] || 3) 2 | timeout 15 3 | preload_app true 4 | 5 | before_fork do |server, worker| 6 | Signal.trap 'TERM' do 7 | puts 'Unicorn master intercepting TERM and sending myself QUIT instead' 8 | Process.kill 'QUIT', Process.pid 9 | end 10 | 11 | defined?(ActiveRecord::Base) and 12 | ActiveRecord::Base.connection.disconnect! 13 | end 14 | 15 | after_fork do |server, worker| 16 | Signal.trap 'TERM' do 17 | puts 'Unicorn worker intercepting TERM and doing nothing. Wait for master to send QUIT' 18 | end 19 | 20 | defined?(ActiveRecord::Base) and 21 | ActiveRecord::Base.establish_connection 22 | end 23 | -------------------------------------------------------------------------------- /app/controllers/authentication_controller.rb: -------------------------------------------------------------------------------- 1 | class AuthenticationController < ApplicationController 2 | 3 | def new 4 | @authorization_url = ApiClient.authorization_url(session) 5 | end 6 | 7 | def create 8 | client = ApiClient.new(session[:oauth_token_secret], session[:oauth_token]) 9 | tokens = client.get_access_tokens(params[:verification_code]) 10 | 11 | if tokens[:oauth_token].present? && tokens[:oauth_token_secret].present? 12 | session[:oauth_token] = tokens[:oauth_token] 13 | session[:oauth_token_secret] = tokens[:oauth_token_secret] 14 | render json: {success: true} 15 | else 16 | render json: {success: false} 17 | end 18 | end 19 | 20 | end 21 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | 3 | gem 'rails', '4.2.0' 4 | gem 'pg' 5 | gem 'sass-rails', '~> 5.0' 6 | gem 'uglifier', '>= 1.3.0' 7 | gem 'coffee-rails', '~> 4.1.0' 8 | gem 'jquery-rails' 9 | gem 'jbuilder', '~> 2.0' 10 | gem 'react-rails', '~> 1.0.0.pre', github: 'reactjs/react-rails' 11 | gem 'httparty' 12 | gem 'autoprefixer-rails' 13 | gem 'bcrypt', '~> 3.1.7' 14 | gem 'geokit' 15 | gem 'active_model_serializers' 16 | gem 'chronic' 17 | gem 'twilio-ruby' 18 | gem 'clockwork' 19 | gem 'dalli' 20 | 21 | group :production do 22 | gem 'unicorn' 23 | gem 'rails_12factor' 24 | end 25 | 26 | group :development, :test do 27 | gem 'dotenv-rails' 28 | gem 'byebug' 29 | gem 'web-console', '~> 2.0' 30 | end 31 | -------------------------------------------------------------------------------- /config/initializers/inflections.rb: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Add new inflection rules using the following format. Inflections 4 | # are locale specific, and you may define rules for as many different 5 | # locales as you wish. All of these examples are active by default: 6 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 7 | # inflect.plural /^(ox)$/i, '\1en' 8 | # inflect.singular /^(ox)en/i, '\1' 9 | # inflect.irregular 'person', 'people' 10 | # inflect.uncountable %w( fish sheep ) 11 | # end 12 | 13 | # These inflection rules are supported but not enabled by default: 14 | # ActiveSupport::Inflector.inflections(:en) do |inflect| 15 | # inflect.acronym 'RESTful' 16 | # end 17 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/has_drop_menu.js.jsx: -------------------------------------------------------------------------------- 1 | global.HasDropMenu = { 2 | getInitialState: function() { 3 | return { 4 | menuIsOpen: false 5 | } 6 | }, 7 | 8 | componentDidMount: function() { 9 | $('body').on('click', this.clickOuter); 10 | }, 11 | 12 | componentWillUnmount: function() { 13 | $('body').off('click', this.clickOuter); 14 | }, 15 | 16 | clickOuter: function(e) { 17 | var root = this.refs.root.getDOMNode(); 18 | if (! $.contains(root, e.target) && root != e.target) { 19 | this.setState({menuIsOpen: false}); 20 | } 21 | }, 22 | 23 | toggleMenu: function(e) { 24 | e.preventDefault(); 25 | this.setState({menuIsOpen: !this.state.menuIsOpen}) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/views/authentication/new.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Read this first

4 |

This should have opened in a new window. Car2Go doesn't yet have a slick 5 | way of integrating with 3rd party apps, so once you have approved access 6 | we'll need you to copy the verification code then close this popup and 7 | paste the code into the verfication code field back on our site.
8 | Allow me to demonstrate:

9 | 10 | <%= image_tag 'auth-flow.gif', class: 'auth-flow-example' %> 11 | 12 | 13 | Got it, let's do this 14 | 15 |
16 |
17 | -------------------------------------------------------------------------------- /config/locales/en.yml: -------------------------------------------------------------------------------- 1 | # Files in the config/locales directory are used for internationalization 2 | # and are automatically loaded by Rails. If you want to use locales other 3 | # than English, add the necessary files in this directory. 4 | # 5 | # To use the locales, use `I18n.t`: 6 | # 7 | # I18n.t 'hello' 8 | # 9 | # In views, this is aliased to just `t`: 10 | # 11 | # <%= t('hello') %> 12 | # 13 | # To use a different locale, set it with `I18n.locale`: 14 | # 15 | # I18n.locale = :es 16 | # 17 | # This would use the information in config/locales/es.yml. 18 | # 19 | # To learn more, please read the Rails Internationalization guide 20 | # available at http://guides.rubyonrails.org/i18n.html. 21 | 22 | en: 23 | hello: "Hello world" 24 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/loading_overlay.js.jsx: -------------------------------------------------------------------------------- 1 | global.LoadingOverlay = React.createClass({ 2 | getDefaultProps: function() { 3 | return { 4 | isVisible: false 5 | } 6 | }, 7 | 8 | render: function() { 9 | var classes = React.addons.classSet({ 10 | 'loading-overlay': true, 11 | 'loading-overlay--is-visible': this.props.isVisible 12 | }); 13 | 14 | return ( 15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ) 24 | } 25 | }); 26 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/1-rotating-plane.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | * 6 | */ 7 | @import "../variables"; 8 | 9 | .sk-spinner-rotating-plane { 10 | &.sk-spinner { 11 | width: 30px; 12 | height: 30px; 13 | background-color: $spinkit-spinner-color; 14 | 15 | margin: $spinkit-spinner-margin; 16 | animation: sk-rotatePlane 1.2s infinite ease-in-out; 17 | } 18 | } 19 | 20 | @keyframes sk-rotatePlane { 21 | 0% { 22 | transform: perspective(120px) rotateX(0deg) rotateY(0deg); 23 | } 50% { 24 | transform: perspective(120px) rotateX(-180.1deg) rotateY(0deg); 25 | } 100% { 26 | transform: perspective(120px) rotateX(-180deg) rotateY(-179.9deg); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/9-wordpress.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | * 6 | *
7 | * 8 | */ 9 | @import "../variables"; 10 | 11 | .sk-spinner-wordpress { 12 | &.sk-spinner { 13 | background-color: $spinkit-spinner-color; 14 | width: 30px; 15 | height: 30px; 16 | border-radius: 30px; 17 | position: relative; 18 | margin: $spinkit-spinner-margin; 19 | animation: sk-innerCircle 1s linear infinite; 20 | } 21 | 22 | .sk-inner-circle { 23 | display: block; 24 | background-color: #fff; 25 | width: 8px; 26 | height: 8px; 27 | position: absolute; 28 | border-radius: 8px; 29 | top: 5px; 30 | left: 5px; 31 | } 32 | } 33 | 34 | @keyframes sk-innerCircle { 35 | 0% { transform: rotate(0) } 36 | 100% { transform: rotate(360deg) } 37 | } 38 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | require 'pathname' 3 | 4 | # path to your application root. 5 | APP_ROOT = Pathname.new File.expand_path('../../', __FILE__) 6 | 7 | Dir.chdir APP_ROOT do 8 | # This script is a starting point to setup your application. 9 | # Add necessary setup steps to this file: 10 | 11 | puts "== Installing dependencies ==" 12 | system "gem install bundler --conservative" 13 | system "bundle check || bundle install" 14 | 15 | # puts "\n== Copying sample files ==" 16 | # unless File.exist?("config/database.yml") 17 | # system "cp config/database.yml.sample config/database.yml" 18 | # end 19 | 20 | puts "\n== Preparing database ==" 21 | system "bin/rake db:setup" 22 | 23 | puts "\n== Removing old logs and tempfiles ==" 24 | system "rm -f log/*" 25 | system "rm -rf tmp/cache" 26 | 27 | puts "\n== Restarting application server ==" 28 | system "touch tmp/restart.txt" 29 | end 30 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/2-double-bounce.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | * 9 | */ 10 | @import "../variables"; 11 | 12 | .sk-spinner-double-bounce { 13 | &.sk-spinner { 14 | width: 40px; 15 | height: 40px; 16 | 17 | position: relative; 18 | margin: $spinkit-spinner-margin; 19 | } 20 | 21 | .sk-double-bounce1, .sk-double-bounce2 { 22 | width: 100%; 23 | height: 100%; 24 | border-radius: 50%; 25 | background-color: $spinkit-spinner-color; 26 | opacity: 0.6; 27 | position: absolute; 28 | top: 0; 29 | left: 0; 30 | 31 | animation: sk-doubleBounce 2.0s infinite ease-in-out; 32 | } 33 | 34 | .sk-double-bounce2 { 35 | animation-delay: -1.0s; 36 | } 37 | } 38 | 39 | @keyframes sk-doubleBounce { 40 | 0%, 100% { transform: scale(0.0) } 41 | 50% { transform: scale(1.0) } 42 | } 43 | -------------------------------------------------------------------------------- /config/secrets.yml: -------------------------------------------------------------------------------- 1 | # Be sure to restart your server when you modify this file. 2 | 3 | # Your secret key is used for verifying the integrity of signed cookies. 4 | # If you change this key, all old signed cookies will become invalid! 5 | 6 | # Make sure the secret is at least 30 characters and all random, 7 | # no regular words or you'll be exposed to dictionary attacks. 8 | # You can use `rake secret` to generate a secure secret key. 9 | 10 | # Make sure the secrets in this file are kept private 11 | # if you're sharing your code publicly. 12 | 13 | development: 14 | secret_key_base: 44ae6ffcc518d32a8586bb5addcaa9ddb0eeca72ac1bf17532b60a0afcbe6a05103c30f366422ae6fd0496317a573a9afc4a27687caaa5254786ebd682b09c6b 15 | 16 | test: 17 | secret_key_base: 5d6bc4e25f1476fdbcc30e6c9115d55822bf7a87904d7f08ac9b6c64ce4e67907b393da56f1812edcf49b27b08683a03e8b2a10ea11a495aac32eb5011b1f906 18 | 19 | # Do not keep production secrets in the repository, 20 | # instead read values from the environment. 21 | production: 22 | secret_key_base: <%= ENV["SECRET_KEY_BASE"] %> 23 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/book_now_button.js.jsx: -------------------------------------------------------------------------------- 1 | global.BookNowButton = React.createClass({ 2 | 3 | getInitialState: function() { 4 | return { 5 | isLoading: false 6 | } 7 | }, 8 | 9 | createBooking: function(e) { 10 | e.preventDefault(); 11 | var self = this; 12 | 13 | self.setState({isLoading: true}); 14 | 15 | var bookNow = function(position) { 16 | var data = {lat: position.coords.latitude, lon: position.coords.longitude} 17 | ApiClient.bookNow(data); 18 | }; 19 | 20 | var failedToFindLocation = function() { 21 | alert("Sorry, your location could not be found"); 22 | }; 23 | 24 | navigator.geolocation.getCurrentPosition(bookNow, failedToFindLocation, { 25 | maximumAge: 60000, enableHighAccuracy: true 26 | }); 27 | }, 28 | 29 | render: function() { 30 | return ( 31 | 32 | 33 | 34 | Book now 35 | 36 | 37 | ) 38 | } 39 | 40 | }); 41 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/3-wave.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | *
9 | *
10 | *
11 | * 12 | */ 13 | @import "../variables"; 14 | 15 | .sk-spinner-wave { 16 | &.sk-spinner { 17 | margin: $spinkit-spinner-margin; 18 | width: 50px; 19 | height: 30px; 20 | text-align: center; 21 | font-size: 10px; 22 | } 23 | 24 | div { 25 | background-color: $spinkit-spinner-color; 26 | height: 100%; 27 | width: 6px; 28 | display: inline-block; 29 | 30 | animation: sk-waveStretchDelay 1.2s infinite ease-in-out; 31 | } 32 | 33 | .sk-rect2 { animation-delay: -1.1s } 34 | .sk-rect3 { animation-delay: -1.0s } 35 | .sk-rect4 { animation-delay: -0.9s } 36 | .sk-rect5 { animation-delay: -0.8s } 37 | } 38 | 39 | @keyframes sk-waveStretchDelay { 40 | 0%, 40%, 100% { transform: scaleY(0.4) } 41 | 20% { transform: scaleY(1.0) } 42 | } 43 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/7-three-bounce.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | *
9 | * 10 | */ 11 | @import "../variables"; 12 | 13 | .sk-spinner-three-bounce { 14 | &.sk-spinner { 15 | margin: $spinkit-spinner-margin; 16 | width: 70px; 17 | text-align: center; 18 | } 19 | 20 | div { 21 | width: 18px; 22 | height: 18px; 23 | background-color: $spinkit-spinner-color; 24 | 25 | border-radius: 100%; 26 | display: inline-block; 27 | animation: sk-threeBounceDelay 1.4s infinite ease-in-out; 28 | /* Prevent first frame from flickering when animation starts */ 29 | animation-fill-mode: both; 30 | } 31 | 32 | .sk-bounce1 { 33 | animation-delay: -0.32s; 34 | } 35 | 36 | .sk-bounce2 { 37 | animation-delay: -0.16s; 38 | } 39 | } 40 | 41 | @keyframes sk-threeBounceDelay { 42 | 0%, 80%, 100% { 43 | transform: scale(0.0); 44 | } 40% { 45 | transform: scale(1.0); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | class ApplicationController < ActionController::Base 2 | # Prevent CSRF attacks by raising an exception. 3 | # For APIs, you may want to use :null_session instead. 4 | protect_from_forgery with: :exception 5 | 6 | # Disable the root node, eg: {projects: [{..}, {..}]} 7 | def default_serializer_options 8 | {root: false} 9 | end 10 | 11 | def current_user 12 | return nil if session[:current_user_id].nil? 13 | @current_user ||= begin 14 | user = User.find_by_id(session[:current_user_id]) 15 | 16 | # Clear it if no user was found with that id 17 | if user.nil? 18 | session[:current_user_id] = nil 19 | end 20 | 21 | user 22 | end 23 | end 24 | helper_method :current_user 25 | 26 | before_filter :deep_snake_case_params! 27 | def deep_snake_case_params!(val = params) 28 | case val 29 | when Array 30 | val.map {|v| deep_snake_case_params! v } 31 | when Hash 32 | val.keys.each do |k, v = val[k]| 33 | val.delete k 34 | val[k.underscore] = deep_snake_case_params!(v) 35 | end 36 | val 37 | else 38 | val 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/new_booking_page.js.jsx: -------------------------------------------------------------------------------- 1 | global.NewBookingPage = React.createClass({ 2 | 3 | getInitialState: function() { 4 | return { 5 | isLoading: false 6 | } 7 | }, 8 | 9 | submitForm: function(booking) { 10 | var self = this, 11 | data = {booking: booking}; 12 | 13 | this.setState({isLoading: true}); 14 | 15 | $.post('/bookings', data).done(function(response) { 16 | if (response.success == true) { 17 | window.location.href = '/' 18 | } else { 19 | self.setState({isLoading: false}); 20 | alert('Something went wrong') 21 | } 22 | }).complete(function() { 23 | // Problem! 24 | }); 25 | }, 26 | 27 | render: function() { 28 | return ( 29 |
30 | 31 | 32 | 33 |
34 |

Schedule a new booking

35 | 36 |
37 | 38 |
39 |
40 |
41 | ) 42 | } 43 | }); 44 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/primary_navigation.js.jsx: -------------------------------------------------------------------------------- 1 | global.PrimaryNavigation = React.createClass({ 2 | getDefaultProps: function() { 3 | return { 4 | extraClasses: '', 5 | showScheduleBooking: false, 6 | showBackHome: false 7 | } 8 | }, 9 | 10 | render: function() { 11 | var links = []; 12 | 13 | if (this.props.showBookNow) { 14 | links.push() 15 | } 16 | 17 | if (this.props.showScheduleBooking) { 18 | links.push( 19 | 20 | Schedule for later 21 | 22 | ) 23 | } 24 | 25 | if (this.props.showBackHome) { 26 | links.push( 27 | 28 | Go back home 29 | 30 | ) 31 | } 32 | 33 | return ( 34 |
35 |
36 |

Concierge

37 | 38 |
39 | {links} 40 |
41 |
42 |
43 | ) 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/6-chasing-dots.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | * 9 | */ 10 | @import "../variables"; 11 | 12 | .sk-spinner-chasing-dots { 13 | &.sk-spinner { 14 | margin: $spinkit-spinner-margin; 15 | width: 40px; 16 | height: 40px; 17 | position: relative; 18 | text-align: center; 19 | 20 | animation: sk-chasingDotsRotate 2.0s infinite linear; 21 | } 22 | 23 | .sk-dot1, .sk-dot2 { 24 | width: 60%; 25 | height: 60%; 26 | display: inline-block; 27 | position: absolute; 28 | top: 0; 29 | background-color: $spinkit-spinner-color; 30 | border-radius: 100%; 31 | 32 | animation: sk-chasingDotsBounce 2.0s infinite ease-in-out; 33 | } 34 | 35 | .sk-dot2 { 36 | top: auto; 37 | bottom: 0px; 38 | animation-delay: -1.0s; 39 | } 40 | } 41 | 42 | @keyframes sk-chasingDotsRotate { 43 | 100% { 44 | transform: rotate(360deg); 45 | } 46 | } 47 | 48 | @keyframes sk-chasingDotsBounce { 49 | 0%, 100% { 50 | transform: scale(0.0); 51 | } 50% { 52 | transform: scale(1.0); 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /app/assets/javascripts/application.js: -------------------------------------------------------------------------------- 1 | // This is a manifest file that'll be compiled into application.js, which will include all the files 2 | // listed below. 3 | // 4 | // Any JavaScript/Coffee file within this directory, lib/assets/javascripts, vendor/assets/javascripts, 5 | // or any plugin's vendor/assets/javascripts directory can be referenced here using a relative path. 6 | // 7 | // It's not advisable to add code directly here, but if you do, it'll appear at the bottom of the 8 | // compiled file. 9 | // 10 | // Read Sprockets README (https://github.com/sstephenson/sprockets#sprockets-directives) for details 11 | // about supported directives. 12 | // 13 | //= require jquery 14 | //= require jquery_ujs 15 | //= require_self 16 | //= require react 17 | //= require react_ujs 18 | //= require components 19 | //= require_tree . 20 | 21 | $.ajaxSetup({ 22 | headers: { 23 | 'X-CSRF-Token': $('meta[name="csrf-token"]').attr('content') 24 | } 25 | }); 26 | 27 | $.put = function(url, data, callback, type){ 28 | if ( $.isFunction(data) ){ 29 | type = type || callback, 30 | callback = data, 31 | data = {} 32 | } 33 | 34 | return $.ajax({ 35 | url: url, 36 | type: 'PUT', 37 | success: callback, 38 | data: data, 39 | contentType: type 40 | }); 41 | } 42 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/4-wandering-cubes.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | * 9 | */ 10 | @import "../variables"; 11 | 12 | .sk-spinner-wandering-cubes { 13 | &.sk-spinner { 14 | margin: $spinkit-spinner-margin; 15 | width: 32px; 16 | height: 32px; 17 | position: relative; 18 | } 19 | 20 | .sk-cube1, .sk-cube2 { 21 | background-color: $spinkit-spinner-color; 22 | width: 10px; 23 | height: 10px; 24 | position: absolute; 25 | top: 0; 26 | left: 0; 27 | 28 | animation: sk-wanderingCubeMove 1.8s infinite ease-in-out; 29 | } 30 | 31 | .sk-cube2 { 32 | animation-delay: -0.9s; 33 | } 34 | } 35 | 36 | @keyframes sk-wanderingCubeMove { 37 | 25% { 38 | transform: translateX(42px) rotate(-90deg) scale(0.5); 39 | } 50% { 40 | /* Hack to make FF rotate in the right direction */ 41 | transform: translateX(42px) translateY(42px) rotate(-179deg); 42 | } 50.1% { 43 | transform: translateX(42px) translateY(42px) rotate(-180deg); 44 | } 75% { 45 | transform: translateX(0px) translateY(42px) rotate(-270deg) scale(0.5); 46 | } 100% { 47 | transform: rotate(-360deg); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/edit_booking_page.js.jsx: -------------------------------------------------------------------------------- 1 | global.EditBookingPage = React.createClass({ 2 | getDefaultProps: function() { 3 | return { booking: {} } 4 | }, 5 | 6 | getInitialState: function() { 7 | return { 8 | isLoading: false 9 | } 10 | }, 11 | 12 | submitForm: function(booking) { 13 | var self = this, 14 | data = {booking: booking}; 15 | 16 | this.setState({isLoading: true}); 17 | 18 | $.put('/bookings/' + this.props.booking.id, data).done(function(response) { 19 | if (response.success == true) { 20 | window.location.href = '/' 21 | } else { 22 | self.setState({isLoading: false}); 23 | alert('Something went wrong') 24 | } 25 | }).complete(function() { 26 | // Problem! 27 | }); 28 | }, 29 | 30 | render: function() { 31 | return ( 32 |
33 | 34 | 35 | 36 |
37 |

Edit booking

38 | 39 |
40 | 41 |
42 |
43 |
44 | ) 45 | } 46 | }); 47 | -------------------------------------------------------------------------------- /app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Concierge 5 | 6 | 7 | <%= csrf_meta_tags %> 8 | <%= stylesheet_link_tag 'application', media: 'all' %> 9 | 10 | <%= javascript_include_tag 'application' %> 11 | 21 | 22 | 23 | <%= yield %> 24 | 25 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/booking_pending_button.js.jsx: -------------------------------------------------------------------------------- 1 | global.BookingPendingButton = React.createClass({ 2 | mixins: [HasDropMenu], 3 | 4 | getInitialState: function() { 5 | return { 6 | isLoading: false 7 | } 8 | }, 9 | 10 | deleteBooking: function(e) { 11 | this.setState({isLoading: true}); 12 | this.props.onDelete(e); 13 | }, 14 | 15 | editBooking: function(e) { 16 | e.preventDefault() 17 | this.setState({isLoading: true}); 18 | window.location.href = "/bookings/" + this.props.bookingId + "/edit" 19 | }, 20 | 21 | render: function() { 22 | var self = this, 23 | cx = React.addons.classSet, 24 | editLink, 25 | optionsListClasses = cx({'link-options-list': true, 26 | 'active': this.state.menuIsOpen}); 27 | 28 | if (this.props.stage == "upcoming") { 29 | editLink = ( 30 | Edit this 31 | ) 32 | } 33 | 34 | return ( 35 |
36 | 37 | 39 | {this.props.stage == 'upcoming' ? "Pending" : "Failed"} 40 | 41 | 42 | 43 | 44 | 45 |
46 | {editLink} 47 | Delete this 48 |
49 |
50 | ) 51 | } 52 | }); 53 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/9-cube-grid.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | *
9 | *
10 | *
11 | *
12 | *
13 | *
14 | *
15 | * 16 | */ 17 | @import "../variables"; 18 | 19 | .sk-spinner-cube-grid { 20 | &.sk-spinner { 21 | width: 30px; 22 | height: 30px; 23 | margin: $spinkit-spinner-margin; 24 | } 25 | 26 | .sk-cube { 27 | width: 33%; 28 | height: 33%; 29 | background-color: $spinkit-spinner-color; 30 | float: left; 31 | animation: sk-cubeGridScaleDelay 1.3s infinite ease-in-out; 32 | } 33 | 34 | /* 35 | * Spinner positions 36 | * 1 2 3 37 | * 4 5 6 38 | * 7 8 9 39 | */ 40 | 41 | .sk-cube:nth-child(1) { animation-delay: 0.2s } 42 | .sk-cube:nth-child(2) { animation-delay: 0.3s } 43 | .sk-cube:nth-child(3) { animation-delay: 0.4s } 44 | .sk-cube:nth-child(4) { animation-delay: 0.1s } 45 | .sk-cube:nth-child(5) { animation-delay: 0.2s } 46 | .sk-cube:nth-child(6) { animation-delay: 0.3s } 47 | .sk-cube:nth-child(7) { animation-delay: 0.0s } 48 | .sk-cube:nth-child(8) { animation-delay: 0.1s } 49 | .sk-cube:nth-child(9) { animation-delay: 0.2s } 50 | } 51 | 52 | @keyframes sk-cubeGridScaleDelay { 53 | 0%, 70%, 100% { transform:scale3D(1.0, 1.0, 1.0) } 54 | 35% { transform:scale3D(0.0, 0.0, 1.0) } 55 | } 56 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/booking_complete_button.js.jsx: -------------------------------------------------------------------------------- 1 | global.BookingCompleteButton = React.createClass({ 2 | mixins: [HasDropMenu], 3 | 4 | getInitialState: function() { 5 | return { 6 | isLoading: false 7 | } 8 | }, 9 | 10 | cancelReservation: function() { 11 | this.setState({isLoading: true}); 12 | 13 | $.post('/bookings/' + this.props.bookingId + '/cancel', {}).complete(function() { 14 | window.location.reload(); 15 | }) 16 | }, 17 | 18 | deleteBooking: function(e) { 19 | this.setState({isLoading: true}); 20 | this.props.onDelete(e); 21 | }, 22 | 23 | render: function() { 24 | var self = this, 25 | cx = React.addons.classSet, 26 | action, 27 | optionsListClasses = cx({'link-options-list': true, 28 | 'active': this.state.menuIsOpen}); 29 | 30 | if (this.props.stage == 'upcoming') { 31 | action = ( 32 | Cancel reservation 33 | ) 34 | } else { 35 | action = ( 36 | Done, delete this 37 | ) 38 | } 39 | 40 | return ( 41 |
42 | 43 | 45 | Booked! 46 | 47 | 48 | 49 | 50 | 51 |
52 | {action} 53 |
54 |
55 | ) 56 | } 57 | }); 58 | -------------------------------------------------------------------------------- /public/500.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | We're sorry, but something went wrong (500) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

We're sorry, but something went wrong.

62 |
63 |

If you are the application owner check the logs for more information.

64 |
65 | 66 | 67 | -------------------------------------------------------------------------------- /config/environments/development.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # In the development environment your application's code is reloaded on 5 | # every request. This slows down response time but is perfect for development 6 | # since you don't have to restart the web server when you make code changes. 7 | config.cache_classes = false 8 | 9 | # Do not eager load code on boot. 10 | config.eager_load = false 11 | 12 | # Show full error reports and disable caching. 13 | config.consider_all_requests_local = true 14 | config.action_controller.perform_caching = false 15 | 16 | # Don't care if the mailer can't send. 17 | config.action_mailer.raise_delivery_errors = false 18 | 19 | # Print deprecation notices to the Rails logger. 20 | config.active_support.deprecation = :log 21 | 22 | # Raise an error on page load if there are pending migrations. 23 | config.active_record.migration_error = :page_load 24 | 25 | # Debug mode disables concatenation and preprocessing of assets. 26 | # This option may cause significant delays in view rendering with a large 27 | # number of complex assets. 28 | config.assets.debug = true 29 | 30 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 31 | # yet still be able to expire them through the digest params. 32 | config.assets.digest = true 33 | 34 | # Adds additional error checking when serving assets at runtime. 35 | # Checks for improperly declared sprockets dependencies. 36 | # Raises helpful error messages. 37 | config.assets.raise_runtime_errors = true 38 | 39 | # Raises error for missing translations 40 | # config.action_view.raise_on_missing_translations = true 41 | end 42 | -------------------------------------------------------------------------------- /app/models/user.rb: -------------------------------------------------------------------------------- 1 | class User < ActiveRecord::Base 2 | has_secure_password 3 | has_many :bookings 4 | validates :email, uniqueness: true 5 | 6 | def rentals 7 | @rentals ||= api_client.rentals.map { |rental| 8 | Rental.new(rental) 9 | } 10 | end 11 | 12 | def accounts 13 | @accounts ||= api_client.accounts 14 | end 15 | 16 | def account_id 17 | @account_id ||= accounts.first['accountId'] 18 | end 19 | 20 | def create_booking(vin) 21 | if response = api_client.create_booking(vin, account_id) 22 | response 23 | else 24 | nil 25 | end 26 | end 27 | 28 | def cancel_booking(id) 29 | api_client.cancel_booking(id) 30 | end 31 | 32 | private 33 | 34 | def api_client 35 | @api_client ||= ApiClient.new(oauth_token_secret, oauth_token) 36 | end 37 | 38 | class Rental 39 | def initialize(raw_data) 40 | @raw_data = raw_data 41 | end 42 | 43 | def to_s 44 | "#{cost} - #{start_time}" 45 | end 46 | 47 | def inspect 48 | to_s 49 | end 50 | 51 | def cost 52 | "$#{@raw_data['driveAmount']['amountGross']} #{@raw_data['driveAmount']['currency']}" 53 | end 54 | 55 | def duration 56 | @raw_data['driveDurationInMinutes'] 57 | end 58 | 59 | def start_location 60 | @raw_data['usageStartAddress'] 61 | end 62 | 63 | def end_location 64 | @raw_data['usageEndAddress'] 65 | end 66 | 67 | def start_time 68 | time = @raw_data['usageStartTime']['timeInMillis'] 69 | if time.present? 70 | Time.at(time / 1000) 71 | end 72 | end 73 | 74 | def end_time 75 | time = @raw_data['usageEndTime']['timeInMillis'] 76 | if time.present? 77 | Time.at(time / 1000) 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /config/application.rb: -------------------------------------------------------------------------------- 1 | require File.expand_path('../boot', __FILE__) 2 | 3 | # Pick the frameworks you want: 4 | require "active_model/railtie" 5 | require "active_job/railtie" 6 | require "active_record/railtie" 7 | require "action_controller/railtie" 8 | require "action_mailer/railtie" 9 | require "action_view/railtie" 10 | require "sprockets/railtie" 11 | # require "rails/test_unit/railtie" 12 | 13 | # Require the gems listed in Gemfile, including any gems 14 | # you've limited to :test, :development, or :production. 15 | Bundler.require(*Rails.groups) 16 | 17 | module Concierge 18 | class Application < Rails::Application 19 | # Settings in config/environments/* take precedence over those specified here. 20 | # Application configuration should go into files in config/initializers 21 | # -- all .rb files in that directory are automatically loaded. 22 | 23 | # Set Time.zone default to the specified zone and make Active Record auto-convert to this zone. 24 | # Run "rake -D time" for a list of tasks for finding time zone names. Default is UTC. 25 | # config.time_zone = 'Central Time (US & Canada)' 26 | config.time_zone = 'Pacific Time (US & Canada)' 27 | Time.zone = 'Pacific Time (US & Canada)' 28 | Chronic.time_class = Time.zone 29 | 30 | # The default locale is :en and all translations from config/locales/*.rb,yml are auto loaded. 31 | # config.i18n.load_path += Dir[Rails.root.join('my', 'locales', '*.{rb,yml}').to_s] 32 | # config.i18n.default_locale = :de 33 | config.autoload_paths += %W(#{config.root}/app/serializers) 34 | config.autoload_paths += %W(#{config.root}/app/services) 35 | config.react.addons = true 36 | 37 | # Do not swallow errors in after_commit/after_rollback callbacks. 38 | config.active_record.raise_in_transactional_callbacks = true 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /public/422.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The change you wanted was rejected (422) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The change you wanted was rejected.

62 |

Maybe you tried to change something you didn't have access to.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /public/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | The page you were looking for doesn't exist (404) 5 | 6 | 55 | 56 | 57 | 58 | 59 |
60 |
61 |

The page you were looking for doesn't exist.

62 |

You may have mistyped the address or the page may have moved.

63 |
64 |

If you are the application owner check the logs for more information.

65 |
66 | 67 | 68 | -------------------------------------------------------------------------------- /app/services/perform_bookings.rb: -------------------------------------------------------------------------------- 1 | class PerformBookings 2 | 3 | def self.execute(bookings) 4 | new(bookings).execute 5 | end 6 | 7 | def initialize(bookings) 8 | @bookings = Array.wrap(bookings) 9 | end 10 | 11 | def execute 12 | @bookings.each do |booking| 13 | booking.update_attributes(in_progress: true) unless booking.in_progress? 14 | 15 | available_cars_for(booking).each do |car| 16 | begin 17 | puts "attempting to book #{car['vin']}" 18 | break if booking.perform!(car['vin']) 19 | rescue Exception => e 20 | Rails.logger.info(e.backtrace) 21 | end 22 | end 23 | 24 | if booking.complete? 25 | after_complete(booking) 26 | else 27 | puts "Nothing close enough to #{booking.id}" 28 | end 29 | end 30 | end 31 | 32 | private 33 | 34 | def after_complete(booking) 35 | begin 36 | twilio.messages.create( 37 | from: '+16042278434', 38 | to: booking.user.phone, 39 | body: "Car booked: #{booking.car_license_plate} at #{booking.car_address}" 40 | ) 41 | rescue Exception => e 42 | puts "Error while sending text message:" 43 | Rails.logger.info(e.backtrace) 44 | end 45 | end 46 | 47 | def available_cars_for(booking) 48 | available_cars. 49 | map { |car| car['distance'] = booking.distance_to(car['location']); car }. 50 | find_all { |car| car['distance'] <= 500 }. 51 | sort_by { |car| car['distance'] } 52 | end 53 | 54 | def available_cars 55 | @available_cars ||= ApiClient.available_cars.map { |car| 56 | lng = car['coordinates'].first 57 | lat = car['coordinates'].second 58 | car['location'] = Geokit::GeoLoc.new(lat: lat, lng: lng) 59 | car 60 | } 61 | end 62 | 63 | def twilio 64 | @twilio ||= Twilio::REST::Client.new 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /config/environments/test.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # The test environment is used exclusively to run your application's 5 | # test suite. You never need to work with it otherwise. Remember that 6 | # your test database is "scratch space" for the test suite and is wiped 7 | # and recreated between test runs. Don't rely on the data there! 8 | config.cache_classes = true 9 | 10 | # Do not eager load code on boot. This avoids loading your whole application 11 | # just for the purpose of running a single test. If you are using a tool that 12 | # preloads Rails for running tests, you may have to set it to true. 13 | config.eager_load = false 14 | 15 | # Configure static file server for tests with Cache-Control for performance. 16 | config.serve_static_files = true 17 | config.static_cache_control = 'public, max-age=3600' 18 | 19 | # Show full error reports and disable caching. 20 | config.consider_all_requests_local = true 21 | config.action_controller.perform_caching = false 22 | 23 | # Raise exceptions instead of rendering exception templates. 24 | config.action_dispatch.show_exceptions = false 25 | 26 | # Disable request forgery protection in test environment. 27 | config.action_controller.allow_forgery_protection = false 28 | 29 | # Tell Action Mailer not to deliver emails to the real world. 30 | # The :test delivery method accumulates sent emails in the 31 | # ActionMailer::Base.deliveries array. 32 | config.action_mailer.delivery_method = :test 33 | 34 | # Randomize the order test cases are executed. 35 | config.active_support.test_order = :random 36 | 37 | # Print deprecation notices to the stderr. 38 | config.active_support.deprecation = :stderr 39 | 40 | # Raises error for missing translations 41 | # config.action_view.raise_on_missing_translations = true 42 | end 43 | -------------------------------------------------------------------------------- /db/schema.rb: -------------------------------------------------------------------------------- 1 | # encoding: UTF-8 2 | # This file is auto-generated from the current state of the database. Instead 3 | # of editing this file, please use the migrations feature of Active Record to 4 | # incrementally modify your database, and then regenerate this schema definition. 5 | # 6 | # Note that this schema.rb definition is the authoritative source for your 7 | # database schema. If you need to create the application database on another 8 | # system, you should be using db:schema:load, not running all the migrations 9 | # from scratch. The latter is a flawed and unsustainable approach (the more migrations 10 | # you'll amass, the slower it'll run and the greater likelihood for issues). 11 | # 12 | # It's strongly recommended that you check this file into your version control system. 13 | 14 | ActiveRecord::Schema.define(version: 20150325155804) do 15 | 16 | # These are extensions that must be enabled in order to support this database 17 | enable_extension "plpgsql" 18 | 19 | create_table "bookings", force: :cascade do |t| 20 | t.string "title" 21 | t.datetime "time" 22 | t.string "address" 23 | t.float "lat" 24 | t.float "lon" 25 | t.boolean "complete", default: false, null: false 26 | t.integer "user_id" 27 | t.datetime "created_at" 28 | t.datetime "updated_at" 29 | t.string "car_address" 30 | t.string "car_distance" 31 | t.string "car_license_plate" 32 | t.boolean "asap", default: false, null: false 33 | t.boolean "in_progress", default: false, null: false 34 | t.string "reservation_id" 35 | t.text "reservation_response" 36 | t.datetime "car_booked_time" 37 | end 38 | 39 | create_table "users", force: :cascade do |t| 40 | t.string "oauth_token_secret" 41 | t.string "oauth_token" 42 | t.string "email" 43 | t.string "phone" 44 | t.string "name" 45 | t.datetime "created_at" 46 | t.datetime "updated_at" 47 | t.string "password_digest" 48 | end 49 | 50 | end 51 | -------------------------------------------------------------------------------- /config/routes.rb: -------------------------------------------------------------------------------- 1 | Rails.application.routes.draw do 2 | root 'pages#home' 3 | get '/auth/new' => 'authentication#new' 4 | post '/auth' => 'authentication#create' 5 | resources :users 6 | resources :sessions 7 | 8 | get '/new-booking' => 'bookings#new' 9 | post '/book-now' => 'bookings#now' 10 | resources :bookings do 11 | member do 12 | post :cancel 13 | end 14 | collection do 15 | get :upcoming 16 | get :past 17 | end 18 | end 19 | 20 | get '/locations/address-for-coords' => 'locations#address_for_coords' 21 | 22 | 23 | # Example of regular route: 24 | # get 'products/:id' => 'catalog#view' 25 | 26 | # Example of named route that can be invoked with purchase_url(id: product.id) 27 | # get 'products/:id/purchase' => 'catalog#purchase', as: :purchase 28 | 29 | # Example resource route (maps HTTP verbs to controller actions automatically): 30 | # resources :products 31 | 32 | # Example resource route with options: 33 | # resources :products do 34 | # member do 35 | # get 'short' 36 | # post 'toggle' 37 | # end 38 | # 39 | # collection do 40 | # get 'sold' 41 | # end 42 | # end 43 | 44 | # Example resource route with sub-resources: 45 | # resources :products do 46 | # resources :comments, :sales 47 | # resource :seller 48 | # end 49 | 50 | # Example resource route with more complex sub-resources: 51 | # resources :products do 52 | # resources :comments 53 | # resources :sales do 54 | # get 'recent', on: :collection 55 | # end 56 | # end 57 | 58 | # Example resource route with concerns: 59 | # concern :toggleable do 60 | # post 'toggle' 61 | # end 62 | # resources :posts, concerns: :toggleable 63 | # resources :photos, concerns: :toggleable 64 | 65 | # Example resource route within a namespace: 66 | # namespace :admin do 67 | # # Directs /admin/products/* to Admin::ProductsController 68 | # # (app/controllers/admin/products_controller.rb) 69 | # resources :products 70 | # end 71 | end 72 | -------------------------------------------------------------------------------- /app/controllers/bookings_controller.rb: -------------------------------------------------------------------------------- 1 | class BookingsController < ApplicationController 2 | def new 3 | end 4 | 5 | def now 6 | @booking = current_user.bookings.create( 7 | title: 'Get me a car now!', 8 | location: {lat: params[:lat], lon: params[:lon]}, 9 | time: Time.now, 10 | asap: true 11 | ) 12 | 13 | PerformBookings.execute(@booking) 14 | 15 | render json: {success: true} 16 | end 17 | 18 | def edit 19 | @booking = Booking.find(params[:id]) 20 | end 21 | 22 | def cancel 23 | @booking = Booking.find(params[:id]) 24 | if @booking.cancel! 25 | @booking.destroy 26 | render json: {success: true} 27 | else 28 | render json: {success: false} 29 | end 30 | end 31 | 32 | def destroy 33 | @booking = Booking.find(params[:id]) 34 | render json: {success: !!@booking.destroy} 35 | end 36 | 37 | def create 38 | time = Chronic.parse("#{booking_params[:date]} at #{booking_params[:time]}") 39 | 40 | @booking = current_user.bookings.create( 41 | title: booking_params[:title], 42 | address: booking_params[:address], 43 | time: time 44 | ) 45 | 46 | if @booking.persisted? 47 | render json: {success: true, id: @booking.id} 48 | else 49 | render json: {success: false} 50 | end 51 | end 52 | 53 | def update 54 | time = Chronic.parse("#{booking_params[:date]} at #{booking_params[:time]}") 55 | @booking = Booking.find(params[:id]) 56 | @booking.update_attributes( 57 | title: booking_params[:title], 58 | address: booking_params[:address], 59 | time: time 60 | ) 61 | 62 | if @booking.valid? 63 | render json: {success: true} 64 | else 65 | render json: {success: false} 66 | end 67 | end 68 | 69 | def upcoming 70 | @bookings = current_user.bookings.upcoming_or_active.order('time ASC') 71 | @bookings.to_a.map! { |b| BookingSerializer.new(b, root: false) } 72 | render json: @bookings 73 | end 74 | 75 | def past 76 | @bookings = current_user.bookings.past.order('time DESC') 77 | @bookings = @bookings.to_a - current_user.bookings.upcoming_or_active.order('time ASC').to_a 78 | @bookings.map! { |b| BookingSerializer.new(b, root: false) } 79 | render json: @bookings 80 | end 81 | 82 | private 83 | 84 | def booking_params 85 | params.require(:booking).permit(:title, :date, :time, :address) 86 | end 87 | 88 | end 89 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/8-circle.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | *
9 | *
10 | *
11 | *
12 | *
13 | *
14 | *
15 | *
16 | *
17 | *
18 | * 19 | */ 20 | @import "../variables"; 21 | 22 | .sk-spinner-circle { 23 | &.sk-spinner { 24 | margin: $spinkit-spinner-margin; 25 | width: 22px; 26 | height: 22px; 27 | position: relative; 28 | } 29 | 30 | .sk-circle { 31 | width: 100%; 32 | height: 100%; 33 | position: absolute; 34 | left: 0; 35 | top: 0; 36 | } 37 | 38 | .sk-circle:before { 39 | content: ''; 40 | display: block; 41 | margin: 0 auto; 42 | width: 20%; 43 | height: 20%; 44 | background-color: $spinkit-spinner-color; 45 | 46 | border-radius: 100%; 47 | animation: sk-circleBounceDelay 1.2s infinite ease-in-out; 48 | /* Prevent first frame from flickering when animation starts */ 49 | animation-fill-mode: both; 50 | } 51 | 52 | .sk-circle2 { transform: rotate(30deg) } 53 | .sk-circle3 { transform: rotate(60deg) } 54 | .sk-circle4 { transform: rotate(90deg) } 55 | .sk-circle5 { transform: rotate(120deg) } 56 | .sk-circle6 { transform: rotate(150deg) } 57 | .sk-circle7 { transform: rotate(180deg) } 58 | .sk-circle8 { transform: rotate(210deg) } 59 | .sk-circle9 { transform: rotate(240deg) } 60 | .sk-circle10 { transform: rotate(270deg) } 61 | .sk-circle11 { transform: rotate(300deg) } 62 | .sk-circle12 { transform: rotate(330deg) } 63 | 64 | .sk-circle2:before { animation-delay: -1.1s } 65 | .sk-circle3:before { animation-delay: -1.0s } 66 | .sk-circle4:before { animation-delay: -0.9s } 67 | .sk-circle5:before { animation-delay: -0.8s } 68 | .sk-circle6:before { animation-delay: -0.7s } 69 | .sk-circle7:before { animation-delay: -0.6s } 70 | .sk-circle8:before { animation-delay: -0.5s } 71 | .sk-circle9:before { animation-delay: -0.4s } 72 | .sk-circle10:before { animation-delay: -0.3s } 73 | .sk-circle11:before { animation-delay: -0.2s } 74 | .sk-circle12:before { animation-delay: -0.1s } 75 | } 76 | 77 | @keyframes sk-circleBounceDelay { 78 | 0%, 80%, 100% { transform: scale(0.0) } 79 | 40% { transform: scale(1.0) } 80 | } 81 | -------------------------------------------------------------------------------- /app/assets/stylesheets/spinkit/spinners/10-fading-circle.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Usage: 3 | * 4 | *
5 | *
6 | *
7 | *
8 | *
9 | *
10 | *
11 | *
12 | *
13 | *
14 | *
15 | *
16 | *
17 | *
18 | * 19 | */ 20 | @import "../variables"; 21 | 22 | .sk-spinner-fading-circle { 23 | 24 | &.sk-spinner { 25 | margin: $spinkit-spinner-margin; 26 | width: 22px; 27 | height: 22px; 28 | position: relative; 29 | } 30 | 31 | .sk-circle { 32 | width: 100%; 33 | height: 100%; 34 | position: absolute; 35 | left: 0; 36 | top: 0; 37 | } 38 | 39 | .sk-circle:before { 40 | content: ''; 41 | display: block; 42 | margin: 0 auto; 43 | width: 18%; 44 | height: 18%; 45 | background-color: $spinkit-spinner-color; 46 | 47 | border-radius: 100%; 48 | animation: sk-circleFadeDelay 1.2s infinite ease-in-out; 49 | /* Prevent first frame from flickering when animation starts */ 50 | animation-fill-mode: both; 51 | } 52 | 53 | .sk-circle2 { transform: rotate(30deg) } 54 | .sk-circle3 { transform: rotate(60deg) } 55 | .sk-circle4 { transform: rotate(90deg) } 56 | .sk-circle5 { transform: rotate(120deg) } 57 | .sk-circle6 { transform: rotate(150deg) } 58 | .sk-circle7 { transform: rotate(180deg) } 59 | .sk-circle8 { transform: rotate(210deg) } 60 | .sk-circle9 { transform: rotate(240deg) } 61 | .sk-circle10 { transform: rotate(270deg) } 62 | .sk-circle11 { transform: rotate(300deg) } 63 | .sk-circle12 { transform: rotate(330deg) } 64 | 65 | .sk-circle2:before { animation-delay: -1.1s } 66 | .sk-circle3:before { animation-delay: -1.0s } 67 | .sk-circle4:before { animation-delay: -0.9s } 68 | .sk-circle5:before { animation-delay: -0.8s } 69 | .sk-circle6:before { animation-delay: -0.7s } 70 | .sk-circle7:before { animation-delay: -0.6s } 71 | .sk-circle8:before { animation-delay: -0.5s } 72 | .sk-circle9:before { animation-delay: -0.4s } 73 | .sk-circle10:before { animation-delay: -0.3s } 74 | .sk-circle11:before { animation-delay: -0.2s } 75 | .sk-circle12:before { animation-delay: -0.1s } 76 | 77 | } 78 | 79 | @keyframes sk-circleFadeDelay { 80 | 0%, 39%, 100% { opacity: 0 } 81 | 40% { opacity: 1 } 82 | } 83 | -------------------------------------------------------------------------------- /app/models/booking.rb: -------------------------------------------------------------------------------- 1 | class Booking < ActiveRecord::Base 2 | belongs_to :user 3 | validates :address, :lat, :lon, :time, :title, presence: true 4 | 5 | def self.upcoming 6 | where('time > ?', Time.now) 7 | end 8 | 9 | def self.incomplete 10 | where('complete = ?', false) 11 | end 12 | 13 | def self.upcoming_or_active 14 | where('time > ? or 15 | (complete = ? and car_booked_time > ?) or 16 | (asap = ? and complete = ?)', 17 | Time.now, 18 | true, Time.now - 30.minutes, 19 | true, false) 20 | end 21 | 22 | def self.within_booking_window 23 | where('(time >= ? and time <= ?) or (asap = ?)', 24 | Time.now, Time.now + 25.minutes, true) 25 | end 26 | 27 | def self.complete 28 | where('time < ? and complete = ?', Time.now, true) 29 | end 30 | 31 | def self.past 32 | where('time < ?', Time.now) 33 | end 34 | 35 | def self.past_and_incomplete 36 | where('time < ? and complete = ?', Time.now, false) 37 | end 38 | 39 | def cancel! 40 | return false if complete? == false || reservation_id.blank? 41 | 42 | if user.cancel_booking(reservation_id) 43 | clear_booking_attributes! 44 | true 45 | end 46 | end 47 | 48 | def clear_booking_attributes! 49 | update_attributes(complete: false, 50 | in_progress: false, 51 | car_address: nil, 52 | car_booked_time: nil, 53 | car_license_plate: nil, 54 | reservation_id: nil, 55 | reservation_response: nil) 56 | end 57 | 58 | def perform!(vin) 59 | if car = user.create_booking(vin) 60 | update_attributes(complete: true, 61 | in_progress: false, 62 | car_address: car[:address], 63 | car_booked_time: car[:time], 64 | car_license_plate: car[:license_plate], 65 | reservation_id: car[:reservation_id], 66 | reservation_response: car[:full_response]) 67 | end 68 | end 69 | 70 | def address=(new_address) 71 | if new_address != address 72 | write_attribute(:address, new_address) 73 | update_lat_lon_from_address 74 | end 75 | end 76 | 77 | def location=(coords) 78 | write_attribute(:lat, coords[:lat]) 79 | write_attribute(:lon, coords[:lon]) 80 | write_attribute(:address, 'your current location') 81 | end 82 | 83 | def distance_to(other_location) 84 | location.distance_to(other_location) 85 | end 86 | 87 | def location 88 | Geokit::GeoLoc.new(lat: lat, lng: lon) 89 | end 90 | 91 | def update_lat_lon_from_address 92 | result = Geokit::Geocoders::GoogleGeocoder.geocode(address) 93 | if result.success? 94 | self.lat = result.lat 95 | self.lon = result.lng 96 | else 97 | self.lat = 0 98 | self.lon = 0 99 | end 100 | end 101 | 102 | end 103 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/booking_form.js.jsx: -------------------------------------------------------------------------------- 1 | global.BookingForm = React.createClass({ 2 | mixins: [React.addons.LinkedStateMixin], 3 | 4 | getDefaultProps: function() { 5 | return { booking: {}, isLoading: false } 6 | }, 7 | 8 | getInitialState: function() { 9 | var booking = this.props.booking; 10 | 11 | return { 12 | title: booking.title || '', 13 | date: booking.date || '', 14 | time: booking.time || '', 15 | address: booking.address || '' 16 | } 17 | }, 18 | 19 | submit: function(e) { 20 | e.preventDefault(); 21 | 22 | var booking = { 23 | title: this.state.title, date: this.state.date, 24 | time: this.state.time, address: this.state.address 25 | }; 26 | 27 | this.props.onSubmit(booking); 28 | }, 29 | 30 | getCurrentLocation: function(e) { 31 | e.preventDefault(); 32 | this.setState({isLoading: true}); 33 | var self = this; 34 | 35 | var failedToFindLocation = function() { 36 | alert("Sorry, your location could not be found"); 37 | }; 38 | 39 | var setAddressToCurrent = function(position) { 40 | var data = {lat: position.coords.latitude, lon: position.coords.longitude} 41 | 42 | ApiClient.addressForCoords(data, function(result) { 43 | self.setState({isLoading: false}); 44 | 45 | if (result.error) { 46 | failedToFindLocation(); 47 | } else { 48 | self.setState({address: result.address}); 49 | } 50 | }); 51 | }; 52 | 53 | navigator.geolocation.getCurrentPosition(setAddressToCurrent, failedToFindLocation, { 54 | maximumAge: 60000, enableHighAccuracy: true 55 | }); 56 | }, 57 | 58 | render: function() { 59 | return ( 60 |
61 | 64 | 67 | 70 |
71 | 74 | 75 | 76 | 77 |
78 | 79 |
80 | 81 | Cancel 82 |
83 | 84 | 85 | 86 | ) 87 | } 88 | }); 89 | -------------------------------------------------------------------------------- /config/database.yml: -------------------------------------------------------------------------------- 1 | # PostgreSQL. Versions 8.2 and up are supported. 2 | # 3 | # Install the pg driver: 4 | # gem install pg 5 | # On OS X with Homebrew: 6 | # gem install pg -- --with-pg-config=/usr/local/bin/pg_config 7 | # On OS X with MacPorts: 8 | # gem install pg -- --with-pg-config=/opt/local/lib/postgresql84/bin/pg_config 9 | # On Windows: 10 | # gem install pg 11 | # Choose the win32 build. 12 | # Install PostgreSQL and put its /bin directory on your path. 13 | # 14 | # Configure Using Gemfile 15 | # gem 'pg' 16 | # 17 | default: &default 18 | adapter: postgresql 19 | encoding: unicode 20 | # For details on connection pooling, see rails configuration guide 21 | # http://guides.rubyonrails.org/configuring.html#database-pooling 22 | pool: 5 23 | 24 | development: 25 | <<: *default 26 | database: concierge_development 27 | 28 | # The specified database role being used to connect to postgres. 29 | # To create additional roles in postgres see `$ createuser --help`. 30 | # When left blank, postgres will use the default role. This is 31 | # the same name as the operating system user that initialized the database. 32 | #username: concierge 33 | 34 | # The password associated with the postgres role (username). 35 | #password: 36 | 37 | # Connect on a TCP socket. Omitted by default since the client uses a 38 | # domain socket that doesn't need configuration. Windows does not have 39 | # domain sockets, so uncomment these lines. 40 | #host: localhost 41 | 42 | # The TCP port the server listens on. Defaults to 5432. 43 | # If your server runs on a different port number, change accordingly. 44 | #port: 5432 45 | 46 | # Schema search path. The server defaults to $user,public 47 | #schema_search_path: myapp,sharedapp,public 48 | 49 | # Minimum log levels, in increasing order: 50 | # debug5, debug4, debug3, debug2, debug1, 51 | # log, notice, warning, error, fatal, and panic 52 | # Defaults to warning. 53 | #min_messages: notice 54 | 55 | # Warning: The database defined as "test" will be erased and 56 | # re-generated from your development database when you run "rake". 57 | # Do not set this db to the same as development or production. 58 | test: 59 | <<: *default 60 | database: concierge_test 61 | 62 | # As with config/secrets.yml, you never want to store sensitive information, 63 | # like your database password, in your source code. If your source code is 64 | # ever seen by anyone, they now have access to your database. 65 | # 66 | # Instead, provide the password as a unix environment variable when you boot 67 | # the app. Read http://guides.rubyonrails.org/configuring.html#configuring-a-database 68 | # for a full rundown on how to provide these environment variables in a 69 | # production deployment. 70 | # 71 | # On Heroku and other platform providers, you may have a full connection URL 72 | # available as an environment variable. For example: 73 | # 74 | # DATABASE_URL="postgres://myuser:mypass@localhost/somedatabase" 75 | # 76 | # You can use this database configuration with: 77 | # 78 | # production: 79 | # url: <%= ENV['DATABASE_URL'] %> 80 | # 81 | production: 82 | <<: *default 83 | database: concierge_production 84 | username: concierge 85 | password: <%= ENV['CONCIERGE_DATABASE_PASSWORD'] %> 86 | -------------------------------------------------------------------------------- /config/environments/production.rb: -------------------------------------------------------------------------------- 1 | Rails.application.configure do 2 | # Settings specified here will take precedence over those in config/application.rb. 3 | 4 | # Code is not reloaded between requests. 5 | config.cache_classes = true 6 | 7 | # Eager load code on boot. This eager loads most of Rails and 8 | # your application in memory, allowing both threaded web servers 9 | # and those relying on copy on write to perform better. 10 | # Rake tasks automatically ignore this option for performance. 11 | config.eager_load = true 12 | 13 | # Full error reports are disabled and caching is turned on. 14 | config.consider_all_requests_local = false 15 | config.action_controller.perform_caching = true 16 | 17 | # Enable Rack::Cache to put a simple HTTP cache in front of your application 18 | # Add `rack-cache` to your Gemfile before enabling this. 19 | # For large-scale production use, consider using a caching reverse proxy like 20 | # NGINX, varnish or squid. 21 | # config.action_dispatch.rack_cache = true 22 | 23 | # Disable serving static files from the `/public` folder by default since 24 | # Apache or NGINX already handles this. 25 | config.serve_static_files = ENV['RAILS_SERVE_STATIC_FILES'].present? 26 | 27 | # Compress JavaScripts and CSS. 28 | config.assets.js_compressor = :uglifier 29 | # config.assets.css_compressor = :sass 30 | 31 | # Do not fallback to assets pipeline if a precompiled asset is missed. 32 | config.assets.compile = false 33 | 34 | # Asset digests allow you to set far-future HTTP expiration dates on all assets, 35 | # yet still be able to expire them through the digest params. 36 | config.assets.digest = true 37 | 38 | # `config.assets.precompile` and `config.assets.version` have moved to config/initializers/assets.rb 39 | 40 | # Specifies the header that your server uses for sending files. 41 | # config.action_dispatch.x_sendfile_header = 'X-Sendfile' # for Apache 42 | # config.action_dispatch.x_sendfile_header = 'X-Accel-Redirect' # for NGINX 43 | 44 | # Force all access to the app over SSL, use Strict-Transport-Security, and use secure cookies. 45 | # config.force_ssl = true 46 | 47 | # Use the lowest log level to ensure availability of diagnostic information 48 | # when problems arise. 49 | config.log_level = :debug 50 | 51 | # Prepend all log lines with the following tags. 52 | # config.log_tags = [ :subdomain, :uuid ] 53 | 54 | # Use a different logger for distributed setups. 55 | # config.logger = ActiveSupport::TaggedLogging.new(SyslogLogger.new) 56 | 57 | # Use a different cache store in production. 58 | config.cache_store = :dalli_store, 59 | (ENV["MEMCACHIER_SERVERS"] || "").split(","), 60 | {username: ENV["MEMCACHIER_USERNAME"], 61 | password: ENV["MEMCACHIER_PASSWORD"], 62 | failover: true, 63 | socket_timeout: 1.5, 64 | socket_failure_delay: 0.2 } 65 | 66 | # Enable serving of images, stylesheets, and JavaScripts from an asset server. 67 | # config.action_controller.asset_host = 'http://assets.example.com' 68 | 69 | # Ignore bad email addresses and do not raise email delivery errors. 70 | # Set this to true and configure the email server for immediate delivery to raise delivery errors. 71 | # config.action_mailer.raise_delivery_errors = false 72 | 73 | # Enable locale fallbacks for I18n (makes lookups for any locale fall back to 74 | # the I18n.default_locale when a translation cannot be found). 75 | config.i18n.fallbacks = true 76 | 77 | # Send deprecation notices to registered listeners. 78 | config.active_support.deprecation = :notify 79 | 80 | # Use default logging formatter so that PID and timestamp are not suppressed. 81 | config.log_formatter = ::Logger::Formatter.new 82 | 83 | # Do not dump schema after migrations. 84 | config.active_record.dump_schema_after_migration = false 85 | end 86 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/list_page.js.jsx: -------------------------------------------------------------------------------- 1 | global.ListPage = React.createClass({ 2 | mixins: [HasTimer], 3 | 4 | getInitialState: function() { 5 | return { 6 | upcomingBookings: this.props.upcomingBookings, 7 | pastBookings: [], 8 | isLoading: false, 9 | view: 'upcoming' 10 | } 11 | }, 12 | 13 | componentDidMount: function() { 14 | this.loadBookings(); 15 | this.setInterval(this.loadBookings, 45000); 16 | }, 17 | 18 | showPastBookings: function() { 19 | this.setState({view: 'past'}); 20 | }, 21 | 22 | showUpcomingBookings: function() { 23 | this.setState({view: 'upcoming'}); 24 | }, 25 | 26 | cancelBookingFn: function(booking) { 27 | var self = this; 28 | 29 | return function(e) { 30 | e.preventDefault() 31 | self.setState({isLoading: true}); 32 | 33 | $.ajax({ 34 | url: '/bookings/' + booking.id, 35 | type: 'DELETE', 36 | success: function(result) { 37 | // Lazy 38 | window.location.reload(); 39 | }, 40 | complete: function() { 41 | // self.setState({isLoading: false}); 42 | } 43 | }); 44 | } 45 | }, 46 | 47 | loadBookings: function() { 48 | var self = this; 49 | 50 | $.get('/bookings/upcoming', function(response) { 51 | self.setState({upcomingBookings: response}) 52 | }) 53 | 54 | $.get('/bookings/past', function(response) { 55 | self.setState({pastBookings: response}) 56 | }) 57 | }, 58 | 59 | renderBookings: function(bookings) { 60 | var list = [], self = this; 61 | 62 | if (bookings == 0) { 63 | return (

No {this.state.view} bookings!

) 64 | } 65 | 66 | bookings.forEach(function(b) { 67 | var actions, 68 | location = (Near {b.address}), 69 | badge; 70 | 71 | if (b.inProgress == true && b.complete == false) { 72 | badge = ( 73 | Searching... 74 | ) 75 | } 76 | 77 | if (b.complete == true) { 78 | actions = ( 79 | 80 | ) 81 | 82 | location = ( 83 | 84 | 85 | {b.carLicensePlate} at 86 | 88 | {b.carAddress} 89 | 90 | 91 | ) 92 | } else if (b.complete == false) { 93 | actions = ( 94 | 95 | ) 96 | } 97 | 98 | list.push( 99 |
100 |
101 |

{b.title}{badge}

102 |

103 | 104 | {b.time} 105 | 106 | 107 | , 108 | 109 | 110 | {b.day}, {b.date} 111 | 112 |

113 | 114 |

115 | {location} 116 |

117 |
118 | 119 |
120 | {actions} 121 |
122 |
123 | ) 124 | }); 125 | 126 | return list; 127 | }, 128 | 129 | render: function() { 130 | var cx = React.addons.classSet, bookings, 131 | upcomingClasses = cx({'page-subtitle--link': true, 132 | 'active': this.state.view == 'upcoming'}), 133 | pastClasses = cx({'page-subtitle--link': true, 134 | 'active': this.state.view == 'past'}); 135 | 136 | if (this.state.view == 'upcoming') { 137 | bookings = this.state.upcomingBookings; 138 | } else { 139 | bookings = this.state.pastBookings; 140 | } 141 | 142 | return ( 143 |
144 | 145 | 146 | 147 |
148 |

149 | Upcoming 151 | 152 | Past 154 |

155 |
156 | {this.renderBookings(bookings)} 157 |
158 |
159 |
160 | ) 161 | } 162 | }); 163 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GIT 2 | remote: git://github.com/reactjs/react-rails.git 3 | revision: abd5171863bef2b426b0d6ca1354ec55679ed15a 4 | specs: 5 | react-rails (1.0.0.pre) 6 | coffee-script-source (~> 1.9) 7 | connection_pool 8 | execjs 9 | rails (>= 3.1) 10 | react-source (~> 0.13) 11 | 12 | GEM 13 | remote: https://rubygems.org/ 14 | specs: 15 | actionmailer (4.2.0) 16 | actionpack (= 4.2.0) 17 | actionview (= 4.2.0) 18 | activejob (= 4.2.0) 19 | mail (~> 2.5, >= 2.5.4) 20 | rails-dom-testing (~> 1.0, >= 1.0.5) 21 | actionpack (4.2.0) 22 | actionview (= 4.2.0) 23 | activesupport (= 4.2.0) 24 | rack (~> 1.6.0) 25 | rack-test (~> 0.6.2) 26 | rails-dom-testing (~> 1.0, >= 1.0.5) 27 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 28 | actionview (4.2.0) 29 | activesupport (= 4.2.0) 30 | builder (~> 3.1) 31 | erubis (~> 2.7.0) 32 | rails-dom-testing (~> 1.0, >= 1.0.5) 33 | rails-html-sanitizer (~> 1.0, >= 1.0.1) 34 | active_model_serializers (0.9.2) 35 | activemodel (>= 3.2) 36 | activejob (4.2.0) 37 | activesupport (= 4.2.0) 38 | globalid (>= 0.3.0) 39 | activemodel (4.2.0) 40 | activesupport (= 4.2.0) 41 | builder (~> 3.1) 42 | activerecord (4.2.0) 43 | activemodel (= 4.2.0) 44 | activesupport (= 4.2.0) 45 | arel (~> 6.0) 46 | activesupport (4.2.0) 47 | i18n (~> 0.7) 48 | json (~> 1.7, >= 1.7.7) 49 | minitest (~> 5.1) 50 | thread_safe (~> 0.3, >= 0.3.4) 51 | tzinfo (~> 1.1) 52 | arel (6.0.0) 53 | autoprefixer-rails (5.1.0) 54 | execjs 55 | json 56 | bcrypt (3.1.9) 57 | binding_of_caller (0.7.2) 58 | debug_inspector (>= 0.0.1) 59 | builder (3.2.2) 60 | byebug (4.0.3) 61 | columnize (= 0.9.0) 62 | chronic (0.10.2) 63 | clockwork (1.1.0) 64 | activesupport 65 | tzinfo 66 | coffee-rails (4.1.0) 67 | coffee-script (>= 2.2.0) 68 | railties (>= 4.0.0, < 5.0) 69 | coffee-script (2.3.0) 70 | coffee-script-source 71 | execjs 72 | coffee-script-source (1.9.1) 73 | columnize (0.9.0) 74 | connection_pool (2.1.3) 75 | dalli (2.7.4) 76 | debug_inspector (0.0.2) 77 | dotenv (1.0.2) 78 | dotenv-rails (1.0.2) 79 | dotenv (= 1.0.2) 80 | erubis (2.7.0) 81 | execjs (2.4.0) 82 | geokit (1.9.0) 83 | multi_json (>= 1.3.2) 84 | globalid (0.3.3) 85 | activesupport (>= 4.1.0) 86 | hike (1.2.3) 87 | httparty (0.13.3) 88 | json (~> 1.8) 89 | multi_xml (>= 0.5.2) 90 | i18n (0.7.0) 91 | jbuilder (2.2.12) 92 | activesupport (>= 3.0.0, < 5) 93 | multi_json (~> 1.2) 94 | jquery-rails (4.0.3) 95 | rails-dom-testing (~> 1.0) 96 | railties (>= 4.2.0) 97 | thor (>= 0.14, < 2.0) 98 | json (1.8.2) 99 | jwt (1.4.1) 100 | kgio (2.9.3) 101 | loofah (2.0.1) 102 | nokogiri (>= 1.5.9) 103 | mail (2.6.3) 104 | mime-types (>= 1.16, < 3) 105 | mime-types (2.4.3) 106 | mini_portile (0.6.2) 107 | minitest (5.5.1) 108 | multi_json (1.11.0) 109 | multi_xml (0.5.5) 110 | nokogiri (1.6.6.2) 111 | mini_portile (~> 0.6.0) 112 | pg (0.18.1) 113 | rack (1.6.0) 114 | rack-test (0.6.3) 115 | rack (>= 1.0) 116 | rails (4.2.0) 117 | actionmailer (= 4.2.0) 118 | actionpack (= 4.2.0) 119 | actionview (= 4.2.0) 120 | activejob (= 4.2.0) 121 | activemodel (= 4.2.0) 122 | activerecord (= 4.2.0) 123 | activesupport (= 4.2.0) 124 | bundler (>= 1.3.0, < 2.0) 125 | railties (= 4.2.0) 126 | sprockets-rails 127 | rails-deprecated_sanitizer (1.0.3) 128 | activesupport (>= 4.2.0.alpha) 129 | rails-dom-testing (1.0.6) 130 | activesupport (>= 4.2.0.beta, < 5.0) 131 | nokogiri (~> 1.6.0) 132 | rails-deprecated_sanitizer (>= 1.0.1) 133 | rails-html-sanitizer (1.0.2) 134 | loofah (~> 2.0) 135 | rails_12factor (0.0.3) 136 | rails_serve_static_assets 137 | rails_stdout_logging 138 | rails_serve_static_assets (0.0.2) 139 | rails_stdout_logging (0.0.3) 140 | railties (4.2.0) 141 | actionpack (= 4.2.0) 142 | activesupport (= 4.2.0) 143 | rake (>= 0.8.7) 144 | thor (>= 0.18.1, < 2.0) 145 | raindrops (0.13.0) 146 | rake (10.4.2) 147 | react-source (0.13.1) 148 | sass (3.4.13) 149 | sass-rails (5.0.1) 150 | railties (>= 4.0.0, < 5.0) 151 | sass (~> 3.1) 152 | sprockets (>= 2.8, < 4.0) 153 | sprockets-rails (>= 2.0, < 4.0) 154 | tilt (~> 1.1) 155 | sprockets (2.12.3) 156 | hike (~> 1.2) 157 | multi_json (~> 1.0) 158 | rack (~> 1.0) 159 | tilt (~> 1.1, != 1.3.0) 160 | sprockets-rails (2.2.4) 161 | actionpack (>= 3.0) 162 | activesupport (>= 3.0) 163 | sprockets (>= 2.8, < 4.0) 164 | thor (0.19.1) 165 | thread_safe (0.3.5) 166 | tilt (1.4.1) 167 | twilio-ruby (3.15.2) 168 | builder (>= 2.1.2) 169 | jwt (~> 1.0) 170 | multi_json (>= 1.3.0) 171 | tzinfo (1.2.2) 172 | thread_safe (~> 0.1) 173 | uglifier (2.7.1) 174 | execjs (>= 0.3.0) 175 | json (>= 1.8.0) 176 | unicorn (4.8.3) 177 | kgio (~> 2.6) 178 | rack 179 | raindrops (~> 0.7) 180 | web-console (2.1.2) 181 | activemodel (>= 4.0) 182 | binding_of_caller (>= 0.7.2) 183 | railties (>= 4.0) 184 | sprockets-rails (>= 2.0, < 4.0) 185 | 186 | PLATFORMS 187 | ruby 188 | 189 | DEPENDENCIES 190 | active_model_serializers 191 | autoprefixer-rails 192 | bcrypt (~> 3.1.7) 193 | byebug 194 | chronic 195 | clockwork 196 | coffee-rails (~> 4.1.0) 197 | dalli 198 | dotenv-rails 199 | geokit 200 | httparty 201 | jbuilder (~> 2.0) 202 | jquery-rails 203 | pg 204 | rails (= 4.2.0) 205 | rails_12factor 206 | react-rails (~> 1.0.0.pre)! 207 | sass-rails (~> 5.0) 208 | twilio-ruby 209 | uglifier (>= 1.3.0) 210 | unicorn 211 | web-console (~> 2.0) 212 | -------------------------------------------------------------------------------- /app/assets/javascripts/components/login_page.js.jsx: -------------------------------------------------------------------------------- 1 | global.LoginPage = React.createClass({ 2 | getInitialState: function() { 3 | return { 4 | waitingForVerifier: false, 5 | waitingForUserInfo: false, 6 | showExistingAccountForm: false, 7 | isLoading: false 8 | } 9 | }, 10 | 11 | toggleVerifierInput: function(e) { 12 | this.setState({waitingForVerifier: true}); 13 | }, 14 | 15 | toggleExistingAccountForm: function(e) { 16 | e.preventDefault(); 17 | this.setState({showExistingAccountForm: !this.state.showExistingAccountForm}); 18 | requestAnimationFrame(function() { 19 | $(window).scrollTop(0); 20 | }); 21 | }, 22 | 23 | submitExistingAccountForm: function(e) { 24 | var email = this.refs.emailInput.getDOMNode().value, 25 | password = this.refs.passwordInput.getDOMNode().value, 26 | self = this; 27 | 28 | e.preventDefault(); 29 | 30 | this.setState({isLoading: true}); 31 | 32 | $.post('/sessions', {email: email, password: password}). 33 | done(function(response) { 34 | if (response.success == true) { 35 | window.location.href = '/'; 36 | } else { 37 | alert('Invalid username or password'); 38 | } 39 | }). 40 | complete(function() { 41 | self.setState({isLoading: false}); 42 | }); 43 | }, 44 | 45 | renderExistingAccountForm: function() { 46 | return ( 47 |
48 |

Sign in to your Concierge account

49 | 50 | 51 | 52 |
53 | 54 | 57 | Cancel 58 | 59 |
60 |
61 | ) 62 | }, 63 | 64 | submitVerificationCode: function(e) { 65 | e.preventDefault(); 66 | var verificationCode = this.refs.verifierInput.getDOMNode().value, 67 | self = this; 68 | 69 | this.setState({isLoading: true}); 70 | 71 | $.post('/auth', {verificationCode: verificationCode}). 72 | done(function(response) { 73 | if (response.success == true) { 74 | self.setState({waitingForVerifier: false, waitingForUserInfo: true}); 75 | } else { 76 | alert('Hmm, that did not seem to work. Did you copy the code properly?') 77 | } 78 | self.setState({isLoading: false}); 79 | }); 80 | }, 81 | 82 | submitUserInfo: function(e) { 83 | e.preventDefault() 84 | 85 | var name = this.refs.nameInput.getDOMNode().value, 86 | email = this.refs.emailInput.getDOMNode().value, 87 | phone = this.refs.phoneInput.getDOMNode().value, 88 | password = this.refs.passwordInput.getDOMNode().value, 89 | self = this; 90 | 91 | self.setState({isLoading: true}); 92 | 93 | $.post('/users', {user: {name: name, email: email, phone: phone, password: password}}). 94 | done(function(response) { 95 | if (response.success == true) { 96 | window.location.reload(); 97 | } else { 98 | alert('Oops, no luck. Did you fill everything in? All fields are required!') 99 | self.setState({isLoading: false}); 100 | } 101 | }); 102 | }, 103 | 104 | render: function() { 105 | var action; 106 | 107 | if (this.state.waitingForVerifier) { 108 | action = ( 109 |
111 | 113 | 116 |
117 | ) 118 | } else if (this.state.waitingForUserInfo) { 119 | action = ( 120 |
122 | 123 |

Great, we were able to connect to your Car2Go account!
124 | Once you fill out all of the fields below and click Finish, 125 | we will be done!

126 | 127 | 128 | 129 | 130 | 131 | 132 | 135 |
136 | ) 137 | } else if (this.state.showExistingAccountForm) { 138 | action = this.renderExistingAccountForm(); 139 | } else { 140 | action = ( 141 |
142 |

143 | Schedule Car2Go reservations as 144 | far in advance as you like, so you 145 | know that a car will be ready for you 146 | when you need it. Currently only available for Vancouver, Canada 147 |

148 | 152 | Sign up through Car2Go 153 | 154 |

or

155 | 158 | Sign in to your account 159 | 160 |
161 | ) 162 | } 163 | return ( 164 |
165 | 166 |
167 |
168 |

Car2Go Concierge

169 | 170 | {action} 171 |
172 |
173 |
174 | ) 175 | }, 176 | 177 | componentDidUpdate: function(prevProps, prevState) { 178 | if (prevState.waitingForVerifier === false && 179 | this.state.waitingForVerifier === true) { 180 | $(this.refs.verifierInput.getDOMNode()).focus(); 181 | } 182 | 183 | if (prevState.waitingForUserInfo === false && 184 | this.state.waitingForUserInfo === true) { 185 | $(this.refs.nameInput.getDOMNode()).focus(); 186 | } 187 | 188 | if (prevState.showExistingAccountForm === false && 189 | this.state.showExistingAccountForm === true) { 190 | $(this.refs.emailInput.getDOMNode()).focus(); 191 | } 192 | } 193 | }) 194 | -------------------------------------------------------------------------------- /app/models/api_client.rb: -------------------------------------------------------------------------------- 1 | # TODO: Should have retries 2 | class ApiClient 3 | OAUTH_CONSUMER_KEY = ENV['CAR2GO_CONSUMER_KEY'] 4 | OAUTH_SECRET_KEY = ENV['CAR2GO_SECRET_KEY'] 5 | OAUTH_VERSION = '1.0' 6 | OAUTH_SIGNATURE_METHOD = 'HMAC-SHA1' 7 | 8 | def self.available_cars(loc = 'vancouver') 9 | url = "http://www.car2go.com/api/v2.1/vehicles?loc=#{loc}&oauth_consumer_key=#{OAUTH_CONSUMER_KEY}&format=json" 10 | HTTParty.get(url).parsed_response['placemarks'] 11 | end 12 | 13 | def self.authorization_url(store = {}) 14 | new.authorization_url(store) 15 | end 16 | 17 | def initialize(token_secret = nil, token = nil) 18 | @token_secret = token_secret 19 | @token = token 20 | end 21 | 22 | # Returns {oauth_token: '...', oauth_token_secret: '...'} 23 | # which can be used to generate an authorization URL 24 | # 25 | def get_request_tokens 26 | response = post('https://www.car2go.com/api/reqtoken').parsed_response 27 | Rack::Utils.parse_nested_query(response).symbolize_keys! 28 | end 29 | 30 | # Returns {oauth_token: '...', oauth_token_secret: '...'} 31 | # which can be used for user-specific API requests 32 | # 33 | def get_access_tokens(verification_code) 34 | @verifier = verification_code 35 | url = 'https://www.car2go.com/api/accesstoken' 36 | headers = {'Authorization' => auth_headers(url, :for_authentication)} 37 | response = HTTParty.post(url, headers: headers).parsed_response 38 | Rack::Utils.parse_nested_query(response).symbolize_keys! 39 | end 40 | 41 | def authorization_url(store = {}) 42 | tokens = get_request_tokens 43 | store[:oauth_token] = tokens[:oauth_token] 44 | store[:oauth_token_secret] = tokens[:oauth_token_secret] 45 | "https://www.car2go.com/api/authorize?oauth_token=#{tokens[:oauth_token]}" 46 | end 47 | 48 | # TODO: Need to handle error cases! 49 | # http://url.brentvatne.ca/12FjM 50 | # 51 | def create_booking(vin, account) 52 | url = 'https://www.car2go.com/api/v2.1/bookings' 53 | response = HTTParty.post( 54 | url + "?format=json&loc=vancouver&vin=#{vin}&account=#{account}", 55 | headers: {'Authorization' => auth_headers(url, :create_booking, vin: vin, account: account)} 56 | ) 57 | 58 | 59 | response = response.parsed_response['booking'].first 60 | 61 | {address: response['bookingposition']['address'], 62 | time: Time.at(response['reservationTime']['timeInMillis'] / 1000), 63 | license_plate: response['vehicle']['numberPlate'], 64 | reservation_id: response['bookingId'], 65 | full_response: response } 66 | end 67 | 68 | # Returns true if successful, false otherwise 69 | def cancel_booking(id) 70 | url = "https://www.car2go.com/api/v2.1/booking/#{id}" 71 | response = HTTParty.delete(url, 72 | headers: {'Authorization' => auth_headers(url, :cancel_booking, booking_id: id)}) 73 | 74 | response.parsed_response['cancelBookingResponse']['returnValue']['code'] == '0' 75 | end 76 | 77 | def rentals 78 | get('https://www.car2go.com/api/v2.1/rentals')['rentals'] 79 | end 80 | 81 | def accounts 82 | get('https://www.car2go.com/api/v2.1/accounts').parsed_response['account'] 83 | end 84 | 85 | private 86 | 87 | def get(url) 88 | HTTParty.get(url + "?format=json&loc=vancouver", 89 | headers: {'Authorization' => auth_headers(url)}) 90 | end 91 | 92 | def post(url) 93 | HTTParty.get(url + "?format=json&loc=vancouver", 94 | headers: {'Authorization' => auth_headers(url)}) 95 | end 96 | 97 | def oauth_timestamp 98 | @oauth_timestamp ||= Time.now.to_i.to_s 99 | end 100 | 101 | def oauth_nonce 102 | @oauth_nonce ||= Random.rand(100000).to_s 103 | end 104 | 105 | def oauth_token 106 | @token || '' 107 | end 108 | 109 | def oauth_token_secret 110 | @token_secret || '' 111 | end 112 | 113 | def base_auth_headers 114 | 'OAuth oauth_signature_method="HMAC-SHA1", ' + 115 | 'oauth_consumer_key="' + OAUTH_CONSUMER_KEY + '", ' + 116 | 'oauth_version="1.0", ' + 117 | 'oauth_timestamp="' + oauth_timestamp + '", ' + 118 | 'oauth_nonce="' + oauth_nonce + '", ' + 119 | 'oauth_callback="oob", ' + 120 | 'oauth_token="' + oauth_token + '"' 121 | end 122 | 123 | def base_auth_headers_with_verifier 124 | base_auth_headers + ', ' + 125 | 'oauth_verifier="' + @verifier + '"' 126 | end 127 | 128 | def parameters 129 | 'format=json' + 130 | '&loc=vancouver' + 131 | '&oauth_callback=' + 132 | 'oob' + 133 | '&oauth_consumer_key=' + 134 | OAUTH_CONSUMER_KEY + 135 | '&oauth_nonce=' + 136 | oauth_nonce + 137 | '&oauth_signature_method=' + 138 | OAUTH_SIGNATURE_METHOD + 139 | '&oauth_timestamp=' + 140 | oauth_timestamp + 141 | '&oauth_token=' + 142 | oauth_token + 143 | '&oauth_version=' + 144 | OAUTH_VERSION 145 | end 146 | 147 | def parameters_for_cancel(booking_id) 148 | # 'bookingId=' + booking_id.to_s + 149 | 'oauth_callback=' + 150 | 'oob' + 151 | '&oauth_consumer_key=' + 152 | OAUTH_CONSUMER_KEY + 153 | '&oauth_nonce=' + 154 | oauth_nonce + 155 | '&oauth_signature_method=' + 156 | OAUTH_SIGNATURE_METHOD + 157 | '&oauth_timestamp=' + 158 | oauth_timestamp + 159 | '&oauth_token=' + 160 | oauth_token + 161 | '&oauth_version=' + 162 | OAUTH_VERSION 163 | end 164 | 165 | def parameters_for_booking(vin, account) 166 | 'account=' + account.to_s + 167 | '&format=json' + 168 | '&loc=vancouver' + 169 | '&oauth_callback=' + 170 | 'oob' + 171 | '&oauth_consumer_key=' + 172 | OAUTH_CONSUMER_KEY + 173 | '&oauth_nonce=' + 174 | oauth_nonce + 175 | '&oauth_signature_method=' + 176 | OAUTH_SIGNATURE_METHOD + 177 | '&oauth_timestamp=' + 178 | oauth_timestamp + 179 | '&oauth_token=' + 180 | oauth_token + 181 | '&oauth_version=' + 182 | OAUTH_VERSION + 183 | '&vin=' + 184 | vin 185 | end 186 | 187 | def auth_parameters 188 | 'oauth_callback=' + 189 | 'oob' + 190 | '&oauth_consumer_key=' + 191 | OAUTH_CONSUMER_KEY + 192 | '&oauth_nonce=' + 193 | oauth_nonce + 194 | '&oauth_signature_method=' + 195 | OAUTH_SIGNATURE_METHOD + 196 | '&oauth_timestamp=' + 197 | oauth_timestamp + 198 | '&oauth_token=' + 199 | oauth_token + 200 | '&oauth_verifier=' + 201 | @verifier + 202 | '&oauth_version=' + 203 | OAUTH_VERSION 204 | end 205 | 206 | def auth_headers(url, type = :normal, options = {}) 207 | signature = nil 208 | headers = nil 209 | 210 | if type == :for_authentication 211 | signature = oauth_signature(url, 'POST', auth_parameters) 212 | headers = base_auth_headers_with_verifier 213 | elsif type == :create_booking 214 | signature = oauth_signature(url, 'POST', 215 | parameters_for_booking(options[:vin], options[:account])) 216 | headers = base_auth_headers 217 | elsif type == :cancel_booking 218 | signature = oauth_signature(url, 'DELETE', parameters_for_cancel(options[:booking_id])) 219 | headers = base_auth_headers 220 | else 221 | signature = oauth_signature(url) 222 | headers = base_auth_headers 223 | end 224 | 225 | headers + ', oauth_signature="' + signature + '"' 226 | end 227 | 228 | def base_string(url, request_type="GET", params = parameters) 229 | request_type + '&' + CGI.escape(url) + '&' + CGI.escape(params) 230 | end 231 | 232 | def oauth_signature(url, request_type="GET", params = parameters) 233 | CGI.escape(Base64.encode64( 234 | "#{OpenSSL::HMAC.digest('sha1', OAUTH_SECRET_KEY + "&" + 235 | oauth_token_secret, base_string(url, request_type, params))}").chomp) 236 | end 237 | end 238 | -------------------------------------------------------------------------------- /app/assets/stylesheets/application.css.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * This is a manifest file that'll be compiled into application.css, which will include all the files 3 | * listed below. 4 | * 5 | * Any CSS and SCSS file within this directory, lib/assets/stylesheets, vendor/assets/stylesheets, 6 | * or any plugin's vendor/assets/stylesheets directory can be referenced here using a relative path. 7 | * 8 | * You're free to add application-wide styles to this file and they'll appear at the bottom of the 9 | * compiled file so the styles you add here take precedence over styles defined in any styles 10 | * defined in the other CSS/SCSS files in this directory. It is generally better to create a new 11 | * file per style scope. 12 | * 13 | *= require_tree . 14 | *= require_self 15 | *= require spinkit/spinkit 16 | */ 17 | 18 | $mobile-bp: 500px; 19 | 20 | @mixin mobile { 21 | @media (max-width: $mobile-bp) { 22 | @content; 23 | } 24 | } 25 | 26 | @mixin desktop { 27 | @media (min-width: $mobile-bp) { 28 | @content; 29 | } 30 | } 31 | 32 | html { 33 | box-sizing: border-box; 34 | margin: 0; 35 | padding: 0; 36 | } 37 | 38 | *, *:before, *:after { 39 | box-sizing: inherit; 40 | } 41 | 42 | body { 43 | background: #F5F2F2; 44 | margin: 0; 45 | padding: 0; 46 | } 47 | 48 | h1,h2,h3,h4,h5,h6,p,span,div,input { 49 | font-family: 'Open Sans', sans-serif; 50 | } 51 | 52 | .container { 53 | position: relative; 54 | max-width: 1140px; 55 | margin: 0 auto; 56 | } 57 | 58 | .card { 59 | padding: 20px; 60 | background: #fff; 61 | box-shadow: 0px 2px 8px 0px rgba(0,0,0,0.14); 62 | margin: 0 auto; 63 | text-align: center; 64 | position: relative; 65 | 66 | p { 67 | @include desktop { 68 | max-width: 75%; 69 | } 70 | margin: 0 auto 30px auto; 71 | } 72 | 73 | h1 { 74 | margin-bottom: 30px; 75 | } 76 | } 77 | 78 | .login-box { 79 | @include desktop { 80 | margin-top: 30px; 81 | } 82 | 83 | @include mobile { 84 | margin-top: 0; 85 | } 86 | } 87 | 88 | $blue: #4A90E2; 89 | 90 | .big-button { 91 | border: none; 92 | display: inline-block; 93 | width: 100%; 94 | background: $blue; 95 | padding: 20px; 96 | color: #fff; 97 | text-decoration: none; 98 | font-weight: bold; 99 | 100 | @include desktop { 101 | font-size: 18px; 102 | } 103 | 104 | @include mobile { 105 | font-size: 16px; 106 | } 107 | 108 | &:hover { 109 | cursor: pointer; 110 | } 111 | } 112 | 113 | .alternate-button { 114 | background: rgb(98, 105, 117); 115 | } 116 | 117 | .mb20 { 118 | margin-bottom: 20px; 119 | } 120 | 121 | .medium-button { 122 | border: none; 123 | display: inline-block; 124 | background: $blue; 125 | padding: 15px 40px; 126 | color: #fff; 127 | font-size: 17px; 128 | text-decoration: none; 129 | font-weight: bold; 130 | margin-right: 20px; 131 | 132 | &:hover { 133 | cursor: pointer; 134 | } 135 | } 136 | 137 | .cancel-button { 138 | background: #BFBFBF; 139 | } 140 | 141 | .footer { 142 | margin-top: 20px; 143 | color: rgba(0,0,0,0.35); 144 | text-align: center; 145 | font-weight: 300; 146 | margin-bottom: 15px; 147 | 148 | @include mobile { 149 | font-size: 14px; 150 | } 151 | } 152 | 153 | .login-box { 154 | max-width: 650px; 155 | } 156 | 157 | .login-box, .new-booking-form { 158 | input[type=text], input[type=email], input[type=password] { 159 | width: 100%; 160 | font-size: 16px; 161 | padding: 10px 15px 15px 15px; 162 | margin-bottom: 15px; 163 | border: 1px solid #eee; 164 | 165 | @include mobile { 166 | font-size: 14px; 167 | padding: 10px; 168 | margin-bottom: 10px; 169 | } 170 | } 171 | } 172 | 173 | .new-booking-form { 174 | padding: 20px 30px 30px 30px; 175 | padding-bottom: 0px; 176 | 177 | @include mobile { 178 | padding: 15px; 179 | padding-bottom: 0; 180 | } 181 | 182 | input[type=text] { 183 | padding: 20px 20px; 184 | background: #FEFEFE; 185 | border: 1px solid #F1F1F1; 186 | font-weight: 300; 187 | 188 | @include mobile { 189 | padding: 15px 10px; 190 | } 191 | } 192 | } 193 | 194 | .verifier-input-wrapper { 195 | img { 196 | max-width: 100%; 197 | } 198 | 199 | input[type=text] { 200 | box-shadow: 0px 0px 10px 3px rgba(224, 231, 14, 0.3); 201 | border: 1px solid #DAD10B; 202 | text-align: center; 203 | 204 | &:focus { 205 | outline: none; 206 | } 207 | } 208 | } 209 | 210 | .primary-navigation { 211 | background: #4A90E2; 212 | border: 1px solid #979797; 213 | width: 100%; 214 | height: 70px; 215 | margin-bottom: 20px; 216 | position: relative; 217 | padding-top: 13px; 218 | 219 | @include mobile { 220 | padding: 16px 5px 0px 8px; 221 | margin-bottom: 18px; 222 | } 223 | 224 | h1 { 225 | color: #fff; 226 | margin: 0; 227 | font-size: 30px; 228 | 229 | @include mobile { 230 | font-size: 20px; 231 | padding-top: 6px; 232 | display: none; 233 | } 234 | } 235 | 236 | .primary-navigation--actions { 237 | position: absolute; 238 | right: 0; 239 | top: 2px; 240 | 241 | @include mobile { 242 | position: static; 243 | } 244 | 245 | a { 246 | @include mobile { 247 | text-align: center; 248 | font-size: 12px; 249 | padding: 9px 7px; 250 | margin-left: 7px; 251 | color: #888; 252 | 253 | &.green { 254 | width: 47%; 255 | } 256 | 257 | &.blue { 258 | width: 47%; 259 | } 260 | } 261 | 262 | box-shadow: 2px 2px 2px rgba(0,0,0,0.2); 263 | display: inline-block; 264 | background: #F9F9F9; 265 | color: #918B8B; 266 | 267 | margin-left: 10px; 268 | padding: 9px 25px; 269 | text-transform: uppercase; 270 | text-decoration: none; 271 | font-weight: 600; 272 | font-size: 15px; 273 | 274 | &:hover { 275 | color: #ccc; 276 | } 277 | } 278 | } 279 | } 280 | 281 | .list-page-content { 282 | width: 100%; 283 | text-align: left; 284 | padding: 0; 285 | padding-bottom: 10px; 286 | padding-top: 10px; 287 | } 288 | 289 | .booking { 290 | padding: 20px; 291 | border-bottom: 1px solid #E8E6E6; 292 | position: relative; 293 | 294 | @include mobile { 295 | padding: 10px; 296 | 297 | &:last-child { 298 | padding-bottom: 0 !important; 299 | } 300 | } 301 | 302 | 303 | &:last-child { 304 | border-bottom: none; 305 | } 306 | 307 | 308 | .booking--info { 309 | h2 { 310 | font-size: 22px; 311 | margin: 0 0 5px 0; 312 | 313 | @include mobile { 314 | font-size: 18px; 315 | } 316 | } 317 | 318 | .booked-license-plate { 319 | color: rgba(0,0,0,0.8); 320 | } 321 | 322 | .booking--info--date-time { 323 | color: #666; 324 | margin: 5px 0; 325 | 326 | @include mobile { 327 | font-size: 15px; 328 | margin: 2px 0; 329 | } 330 | 331 | .time { 332 | // font-weight: bold; 333 | } 334 | 335 | .date { 336 | } 337 | } 338 | 339 | .booking--info--location { 340 | margin: 0; 341 | color: #666; 342 | 343 | @include mobile { 344 | margin-bottom: 10px; 345 | } 346 | } 347 | 348 | .booked-location--address { 349 | color: rgba(0,0,0,0.8); 350 | 351 | @include mobile { 352 | font-size: 15px; 353 | } 354 | } 355 | } 356 | 357 | .booking--actions { 358 | position: absolute; 359 | right: 40px; 360 | top: 30px; 361 | 362 | @include mobile { 363 | position: static; 364 | margin-bottom: 10px; 365 | } 366 | 367 | a { 368 | background: #eee; 369 | padding: 16px 40px; 370 | color: #888; 371 | text-decoration: none; 372 | display: inline-block; 373 | box-shadow: 2px 2px 2px rgba(0, 0, 0, 0.13); 374 | 375 | @include desktop { 376 | width: 150px; 377 | text-align: center; 378 | } 379 | 380 | @include mobile { 381 | width: 100%; 382 | text-align: center; 383 | padding: 10px; 384 | font-size: 15px; 385 | } 386 | } 387 | } 388 | } 389 | 390 | .auth-flow-example { 391 | max-width: 100%; 392 | box-shadow: 0px 2px 15px 5px rgba(0, 0, 0, 0.09); 393 | margin-bottom: 20px; 394 | } 395 | 396 | .loading-overlay { 397 | position: fixed; 398 | top: 0; 399 | left: 0; 400 | width: 100%; 401 | height: 100%; 402 | background: rgba(255, 255, 255, 0.8); 403 | z-index: 1000; 404 | display: none; 405 | 406 | &.loading-overlay--is-visible { 407 | display: block; 408 | } 409 | 410 | .loading-overlay--animation { 411 | position: absolute; 412 | top: 30%; 413 | left: 50%; 414 | transform: translateX(-50%); 415 | } 416 | 417 | .sk-dot1 { 418 | background: rgba(8, 78, 93, 1); 419 | } 420 | 421 | .sk-dot2 { 422 | background: #2A4480; 423 | } 424 | } 425 | 426 | .form--actions { 427 | margin-top: 10px; 428 | 429 | @include desktop { 430 | margin-bottom: 15px; 431 | } 432 | 433 | @include mobile { 434 | button, a { 435 | display: block !important; 436 | margin-bottom: 8px; 437 | font-weight: 500; 438 | text-align: center; 439 | width: 100%; 440 | font-size: 16px; 441 | } 442 | 443 | button:last-child, a:last-child { 444 | margin-bottom: 0; 445 | } 446 | } 447 | } 448 | 449 | .login-or { 450 | margin-top: 10px!important; 451 | margin-bottom: 10px!important; 452 | color: #888; 453 | font-style: italic; 454 | font-weight: 300; 455 | text-align: center; 456 | } 457 | 458 | .faded-link { 459 | color: #888; 460 | 461 | &:hover { 462 | color: #ccc !important; 463 | } 464 | 465 | &:visited, &:active { 466 | color: #888; 467 | } 468 | } 469 | 470 | .page-subtitle { 471 | font-size: 24px; 472 | color: #B2B1B1; 473 | line-height: 27px; 474 | margin-top: 30px; 475 | margin-bottom: 15px; 476 | font-weight: 300; 477 | padding-left: 5px; 478 | font-weight: 500; 479 | 480 | @include mobile { 481 | margin-top: 10px; 482 | margin-bottom: 7px; 483 | font-size: 17px; 484 | padding-left: 10px; 485 | } 486 | 487 | .page-subtitle--link { 488 | color: #ccc; 489 | text-decoration: none; 490 | margin-right: 15px; 491 | letter-spacing: -1px; 492 | 493 | &.active { 494 | color: #B2B1B1; 495 | font-weight: 500; 496 | } 497 | 498 | &:hover, &.active:hover { 499 | color: #888; 500 | } 501 | } 502 | } 503 | 504 | .empty-list-notification { 505 | margin: 0 !important; 506 | text-align: center; 507 | padding: 30px; 508 | padding-bottom: 40px; 509 | width: 100%; 510 | max-width: 100% !important; 511 | padding-top: 50px; 512 | color: #B6B6B6; 513 | font-style: italic; 514 | } 515 | 516 | .booking-complete-button { 517 | background: #FFA500 !important; 518 | color: #FFFFFF !important; 519 | } 520 | 521 | .highlight-header { 522 | background: rgb(249, 249, 130); 523 | padding: 5px; 524 | width: 50%; 525 | margin: 20px auto; 526 | color: rgb(51, 51, 8); 527 | } 528 | 529 | .desktop-only { 530 | @include mobile { 531 | display: none !important; 532 | } 533 | } 534 | 535 | .mobile-only { 536 | @include desktop { 537 | display: none !important; 538 | } 539 | } 540 | 541 | .go-home-link { 542 | @include mobile { 543 | width: 97%; 544 | } 545 | } 546 | 547 | .in-progress-badge { 548 | font-size: 14px; 549 | vertical-align: middle; 550 | display: inline-block; 551 | background: yellow; 552 | margin-left: 10px; 553 | margin-bottom: 2px; 554 | } 555 | 556 | .link-with-options { 557 | position: relative; 558 | @include desktop { 559 | padding-right: 130px !important; 560 | } 561 | @include mobile { 562 | padding-right: 20px !important; 563 | } 564 | } 565 | 566 | $caret-size: 8px; 567 | $caret-size-mobile: 6px; 568 | 569 | .caret { 570 | width: 0; 571 | height: 0; 572 | display: inline-block; 573 | border-left: $caret-size solid transparent; 574 | border-right: $caret-size solid transparent; 575 | border-top: $caret-size solid #888; 576 | 577 | @include mobile { 578 | border-left: $caret-size-mobile solid transparent; 579 | border-right: $caret-size-mobile solid transparent; 580 | border-top: $caret-size-mobile solid #888; 581 | } 582 | } 583 | 584 | .link-options { 585 | width: 40px; 586 | height: 100%; 587 | position: absolute; 588 | right: 0; 589 | top: 0; 590 | 591 | &.grey { 592 | background: #E4E4E4; 593 | 594 | .caret { 595 | border-top: $caret-size solid #868686; 596 | } 597 | 598 | @include mobile { 599 | .caret { 600 | border-top: $caret-size-mobile solid #868686; 601 | } 602 | } 603 | } 604 | 605 | &.orange { 606 | background: #F0B758; 607 | 608 | .caret { 609 | border-top: $caret-size solid #A2690A; 610 | } 611 | 612 | @include mobile { 613 | .caret { 614 | border-top: $caret-size solid #A2690A; 615 | } 616 | } 617 | } 618 | 619 | .caret { 620 | position: absolute; 621 | top: 50%; 622 | left: 50%; 623 | transform: translate(-50%, -50%); 624 | } 625 | } 626 | 627 | .link-options-list { 628 | position: absolute; 629 | display: none; 630 | z-index: 999; 631 | 632 | @include desktop { 633 | // bottom: -53px; 634 | } 635 | 636 | @include mobile { 637 | // bottom: -20px; 638 | padding: 0px 10px; 639 | } 640 | 641 | left: 0; 642 | min-height: 20px; 643 | width: 100%; 644 | background: #fff; 645 | 646 | a:first-child { 647 | border-top: 1px solid #D6D6D6; 648 | } 649 | 650 | a { 651 | width: 100% !important; 652 | padding: 15px 10px !important; 653 | background: #F9F9F9 !important; 654 | } 655 | 656 | &.active { 657 | display: block; 658 | } 659 | } 660 | 661 | .address-input-wrapper { 662 | position: relative; 663 | 664 | input { 665 | padding-right: 35px !important; 666 | } 667 | } 668 | 669 | .get-current-location-button { 670 | position: absolute; 671 | 672 | &:hover { 673 | .ion-pinpoint { 674 | color: $blue; 675 | cursor: pointer; 676 | } 677 | } 678 | 679 | .ion-pinpoint { 680 | font-size: 22px; 681 | color: rgba(0,0,0,0.7); 682 | } 683 | 684 | @include mobile { 685 | right: 10px; 686 | top: 11px; 687 | } 688 | 689 | @include desktop { 690 | right: 15px; 691 | top: 17px; 692 | } 693 | } 694 | --------------------------------------------------------------------------------