├── app ├── Resources │ └── views │ │ ├── default │ │ ├── payment_callback.html.twig │ │ ├── index.html.twig │ │ ├── order_pay.html.twig │ │ └── checkout.html.twig │ │ ├── components │ │ ├── products-back.twig │ │ └── product-detail.twig │ │ ├── shoporder │ │ ├── new.html.twig │ │ ├── index.html.twig │ │ ├── show.html.twig │ │ └── edit.html.twig │ │ ├── product │ │ ├── show.html.twig │ │ ├── new.html.twig │ │ ├── edit.html.twig │ │ └── index.html.twig │ │ └── base.html.twig ├── AppCache.php ├── .htaccess ├── config │ ├── routing.yml │ ├── config_test.yml │ ├── routing_dev.yml │ ├── parameters.yml.dist │ ├── config_prod.yml │ ├── security.yml │ ├── config_dev.yml │ ├── services.yml │ └── config.yml └── AppKernel.php ├── web ├── favicon.ico ├── apple-touch-icon.png ├── assets │ ├── img │ │ ├── favicon.ico │ │ ├── bitcoin-icon.png │ │ └── demo-checkout.jpg │ ├── audio │ │ └── smw_coin.wav │ ├── css │ │ └── style.css │ └── js │ │ ├── material.min.js │ │ ├── material-dashboard.js │ │ ├── bootstrap-notify.js │ │ └── bootstrap.min.js ├── robots.txt ├── app.php ├── app_dev.php ├── .htaccess └── config.php ├── src ├── .htaccess └── AppBundle │ ├── AppBundle.php │ ├── Repository │ ├── ProductRepository.php │ ├── ShopOrderRepository.php │ └── PriceOptionRepository.php │ ├── Service │ └── BlockchainDotInfoService.php │ ├── Form │ ├── ProductType.php │ └── ShopOrderType.php │ ├── Entity │ ├── PriceOption.php │ ├── Product.php │ └── ShopOrder.php │ ├── Tests │ └── Controller │ │ ├── ProductControllerTest.php │ │ └── ShopOrderControllerTest.php │ └── Controller │ ├── ProductController.php │ ├── ShopOrderController.php │ └── DefaultController.php ├── .gitignore ├── tests └── AppBundle │ └── Controller │ └── DefaultControllerTest.php ├── bin ├── console └── symfony_requirements ├── phpunit.xml.dist ├── LICENSE ├── composer.json └── README.md /app/Resources/views/default/payment_callback.html.twig: -------------------------------------------------------------------------------- 1 | *ok* -------------------------------------------------------------------------------- /web/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elvismdev/Bitcoin-Simple-Shop/HEAD/web/favicon.ico -------------------------------------------------------------------------------- /web/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elvismdev/Bitcoin-Simple-Shop/HEAD/web/apple-touch-icon.png -------------------------------------------------------------------------------- /web/assets/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elvismdev/Bitcoin-Simple-Shop/HEAD/web/assets/img/favicon.ico -------------------------------------------------------------------------------- /web/assets/audio/smw_coin.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elvismdev/Bitcoin-Simple-Shop/HEAD/web/assets/audio/smw_coin.wav -------------------------------------------------------------------------------- /web/assets/img/bitcoin-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elvismdev/Bitcoin-Simple-Shop/HEAD/web/assets/img/bitcoin-icon.png -------------------------------------------------------------------------------- /web/assets/img/demo-checkout.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elvismdev/Bitcoin-Simple-Shop/HEAD/web/assets/img/demo-checkout.jpg -------------------------------------------------------------------------------- /app/AppCache.php: -------------------------------------------------------------------------------- 1 | keyboard_arrow_leftBack to the products list -------------------------------------------------------------------------------- /app/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /src/.htaccess: -------------------------------------------------------------------------------- 1 | 2 | Require all denied 3 | 4 | 5 | Order deny,allow 6 | Deny from all 7 | 8 | -------------------------------------------------------------------------------- /src/AppBundle/AppBundle.php: -------------------------------------------------------------------------------- 1 | Shoporder creation 5 | 6 | {{ form_start(form) }} 7 | {{ form_widget(form) }} 8 | 9 | {{ form_end(form) }} 10 | 11 | 16 | {% endblock %} 17 | -------------------------------------------------------------------------------- /app/config/parameters.yml.dist: -------------------------------------------------------------------------------- 1 | # This file is a "template" of what your parameters.yml file should look like 2 | parameters: 3 | database_host: 127.0.0.1 4 | database_port: ~ 5 | database_name: symfony 6 | database_user: root 7 | database_password: ~ 8 | 9 | # A secret key that's used to generate certain security-related tokens 10 | secret: ThisTokenIsNotSoSecretChangeIt 11 | 12 | # Blockchain.info details 13 | blockchain_api_key: 'API_KEY_HERE' 14 | blockchain_xpub: 'XPUB_HERE' -------------------------------------------------------------------------------- /tests/AppBundle/Controller/DefaultControllerTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/'); 14 | 15 | $this->assertEquals(200, $client->getResponse()->getStatusCode()); 16 | $this->assertContains('Welcome to Symfony', $crawler->filter('#container h1')->text()); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/config/config_prod.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config.yml } 3 | 4 | #doctrine: 5 | # orm: 6 | # metadata_cache_driver: apc 7 | # result_cache_driver: apc 8 | # query_cache_driver: apc 9 | 10 | monolog: 11 | handlers: 12 | main: 13 | type: fingers_crossed 14 | action_level: error 15 | handler: nested 16 | nested: 17 | type: stream 18 | path: '%kernel.logs_dir%/%kernel.environment%.log' 19 | level: debug 20 | console: 21 | type: console 22 | process_psr_3_messages: false 23 | -------------------------------------------------------------------------------- /web/app.php: -------------------------------------------------------------------------------- 1 | loadClassCache(); 13 | } 14 | //$kernel = new AppCache($kernel); 15 | 16 | // When using the HttpCache, you need to call the method in your front controller instead of relying on the configuration parameter 17 | //Request::enableHttpMethodParameterOverride(); 18 | $request = Request::createFromGlobals(); 19 | $response = $kernel->handle($request); 20 | $response->send(); 21 | $kernel->terminate($request, $response); 22 | -------------------------------------------------------------------------------- /src/AppBundle/Service/BlockchainDotInfoService.php: -------------------------------------------------------------------------------- 1 | container = $container; 13 | } 14 | 15 | /** 16 | * Converts from USD to BTC. 17 | * 18 | */ 19 | public function toBTC( $usd_price ) { 20 | $endpointToBTC = $this->container->getParameter('tobtc_endpoint'); 21 | $endpointToBTC .= $usd_price; 22 | $response = \Requests::get( $endpointToBTC ); 23 | // Return current Bitcoin price for Product. 24 | return $response->body; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /app/Resources/views/product/show.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 | 5 | {% include 'components/product-detail.twig' %} 6 | 7 |
keyboard_arrow_leftBack to store homepage
8 | 9 | {% endblock %} 10 | 11 | {% block javascripts %} 12 | {{ parent() }} 13 | 14 | 25 | 26 | {% endblock %} -------------------------------------------------------------------------------- /app/config/security.yml: -------------------------------------------------------------------------------- 1 | security: 2 | 3 | providers: 4 | in_memory: 5 | memory: 6 | users: 7 | admin: 8 | password: $2y$12$AiRc2T2sIXfst6gGqcC45el88sDtWra7RmiGP0EPINK.P3HKA3F2m 9 | roles: 'ROLE_ADMIN' 10 | 11 | firewalls: 12 | dev: 13 | pattern: ^/(_(profiler|wdt)|css|images|js)/ 14 | security: false 15 | 16 | main: 17 | anonymous: ~ 18 | http_basic: ~ 19 | logout: 20 | path: /logout 21 | target: / 22 | 23 | access_control: 24 | - { path: ^/admin, roles: ROLE_ADMIN } 25 | 26 | encoders: 27 | Symfony\Component\Security\Core\User\User: 28 | algorithm: bcrypt 29 | cost: 12 30 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getParameterOption(['--env', '-e'], getenv('SYMFONY_ENV') ?: 'dev'); 19 | $debug = getenv('SYMFONY_DEBUG') !== '0' && !$input->hasParameterOption(['--no-debug', '']) && $env !== 'prod'; 20 | 21 | if ($debug) { 22 | Debug::enable(); 23 | } 24 | 25 | $kernel = new AppKernel($env, $debug); 26 | $application = new Application($kernel); 27 | $application->run($input); 28 | -------------------------------------------------------------------------------- /src/AppBundle/Form/ProductType.php: -------------------------------------------------------------------------------- 1 | add('title')->add('body')->add('updatedAt')->add('createdAt')->add('slug')->add('priceOptions'); 18 | } 19 | 20 | /** 21 | * {@inheritdoc} 22 | */ 23 | public function configureOptions(OptionsResolver $resolver) 24 | { 25 | $resolver->setDefaults(array( 26 | 'data_class' => 'AppBundle\Entity\Product' 27 | )); 28 | } 29 | 30 | /** 31 | * {@inheritdoc} 32 | */ 33 | public function getBlockPrefix() 34 | { 35 | return 'appbundle_product'; 36 | } 37 | 38 | 39 | } 40 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | tests 18 | 19 | 20 | 21 | 22 | 23 | src 24 | 25 | src/*Bundle/Resources 26 | src/*/*Bundle/Resources 27 | src/*/Bundle/*Bundle/Resources 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Elvis Morales 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/AppBundle/Form/ShopOrderType.php: -------------------------------------------------------------------------------- 1 | add('email')->add('name')->add('lastName')->add('address')->add('address2')->add('city')->add('state')->add('zip')->add('country')->add('orderTotalBtc')->add('orderTotalUsd')->add('createdAt')->add('updatedAt')->add('orderPaid')->add('orderStatus')->add('amountPaid')->add('difference')->add('transactionHash')->add('btcAddressId')->add('products'); 17 | } 18 | 19 | /** 20 | * {@inheritdoc} 21 | */ 22 | public function configureOptions(OptionsResolver $resolver) 23 | { 24 | $resolver->setDefaults(array( 25 | 'data_class' => 'AppBundle\Entity\ShopOrder' 26 | )); 27 | } 28 | 29 | /** 30 | * {@inheritdoc} 31 | */ 32 | public function getBlockPrefix() 33 | { 34 | return 'appbundle_shoporder'; 35 | } 36 | 37 | 38 | } 39 | -------------------------------------------------------------------------------- /web/app_dev.php: -------------------------------------------------------------------------------- 1 | loadClassCache(); 27 | } 28 | $request = Request::createFromGlobals(); 29 | $response = $kernel->handle($request); 30 | $response->send(); 31 | $kernel->terminate($request, $response); 32 | -------------------------------------------------------------------------------- /app/config/config_dev.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: config.yml } 3 | 4 | framework: 5 | router: 6 | resource: '%kernel.project_dir%/app/config/routing_dev.yml' 7 | strict_requirements: true 8 | profiler: { only_exceptions: false } 9 | 10 | web_profiler: 11 | toolbar: true 12 | intercept_redirects: false 13 | 14 | monolog: 15 | handlers: 16 | main: 17 | type: stream 18 | path: '%kernel.logs_dir%/%kernel.environment%.log' 19 | level: debug 20 | channels: ['!event'] 21 | console: 22 | type: console 23 | process_psr_3_messages: false 24 | channels: ['!event', '!doctrine', '!console'] 25 | # To follow logs in real time, execute the following command: 26 | # `bin/console server:log -vv` 27 | server_log: 28 | type: server_log 29 | process_psr_3_messages: false 30 | host: 127.0.0.1:9911 31 | # uncomment to get logging in your browser 32 | # you may have to allow bigger header sizes in your Web server configuration 33 | #firephp: 34 | # type: firephp 35 | # level: info 36 | #chromephp: 37 | # type: chromephp 38 | # level: info 39 | 40 | #swiftmailer: 41 | # delivery_addresses: ['me@example.com'] 42 | -------------------------------------------------------------------------------- /app/config/services.yml: -------------------------------------------------------------------------------- 1 | # Learn more about services, parameters and containers at 2 | # https://symfony.com/doc/current/service_container.html 3 | parameters: 4 | #parameter_name: value 5 | 6 | services: 7 | # default configuration for services in *this* file 8 | _defaults: 9 | # automatically injects dependencies in your services 10 | autowire: true 11 | # automatically registers your services as commands, event subscribers, etc. 12 | autoconfigure: true 13 | # this means you cannot fetch services directly from the container via $container->get() 14 | # if you need to do this, you can override this setting on individual services 15 | public: false 16 | 17 | # makes classes in src/AppBundle available to be used as services 18 | # this creates a service per class whose id is the fully-qualified class name 19 | AppBundle\: 20 | resource: '../../src/AppBundle/*' 21 | # you can exclude directories or files 22 | # but if a service is unused, it's removed anyway 23 | exclude: '../../src/AppBundle/{Entity,Repository,Tests}' 24 | 25 | # controllers are imported separately to make sure they're public 26 | # and have a tag that allows actions to type-hint services 27 | AppBundle\Controller\: 28 | resource: '../../src/AppBundle/Controller' 29 | public: true 30 | tags: ['controller.service_arguments'] 31 | 32 | # add more services, or override services that need manual wiring 33 | # AppBundle\Service\ExampleService: 34 | # arguments: 35 | # $someArgument: 'some_value' 36 | -------------------------------------------------------------------------------- /app/Resources/views/default/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 | {% for product in products %} 6 |
7 |
8 |
9 | 10 |
11 | camera_alt 12 |
13 |
14 |
15 |

{{ product.title }}

16 |
    17 | {% for price in product.priceOptions %} 18 |
  • 19 |
    20 | {# {{ dump(price) }} #} 21 | {{ price }} Buy Now add_shopping_cart 22 |
    23 |
  • 24 | {% endfor %} 25 |
26 |
27 |
28 |
29 |
30 | {% endfor %} 31 |
32 | {% endblock %} 33 | 34 | -------------------------------------------------------------------------------- /web/assets/css/style.css: -------------------------------------------------------------------------------- 1 | .logo img { 2 | display: block; 3 | margin: 0 auto; 4 | } 5 | 6 | .btn.btn-primary a { 7 | color: #fff; 8 | } 9 | 10 | td.actions ul { 11 | list-style-type: none; 12 | padding-left: 0; 13 | } 14 | 15 | .green-text { 16 | color: #4caf50; 17 | } 18 | 19 | .grey-text { 20 | color: #9e9e9e; 21 | } 22 | 23 | #productDetails h4 { 24 | font-size: 2em; 25 | } 26 | 27 | #productDetails .card-body { 28 | padding: 15px; 29 | } 30 | 31 | .order-id small { 32 | font-size: 40%; 33 | } 34 | 35 | .qr-code img { 36 | width: inherit; 37 | } 38 | 39 | .websocket-color-blue { 40 | color: #337ab7; 41 | } 42 | 43 | .websocket-color-green { 44 | color: #459648 45 | } 46 | 47 | .price-options .material-icons { 48 | position: relative; 49 | top: 4px; 50 | font-size: 16px; 51 | } 52 | 53 | .md-radio input[type="radio"] { 54 | display: none; 55 | } 56 | 57 | .md-radio input[type="radio"]:checked + label:before { 58 | border-color: #039ade; 59 | animation: ripple 0.2s linear forwards; 60 | } 61 | 62 | .md-radio label:before { 63 | left: 0; 64 | top: 0; 65 | width: 20px; 66 | height: 20px; 67 | border: 2px solid rgba(0, 0, 0, 0.54); 68 | } 69 | 70 | .md-radio label:before, .md-radio label:after { 71 | position: absolute; 72 | content: ''; 73 | border-radius: 50%; 74 | transition: all .3s ease; 75 | transition-property: transform, border-color; 76 | } 77 | 78 | *, *:before, *:after { 79 | box-sizing: border-box; 80 | } 81 | 82 | .md-radio label { 83 | display: inline-block; 84 | height: 20px; 85 | position: relative; 86 | padding: 0 30px; 87 | margin-bottom: 0; 88 | cursor: pointer; 89 | vertical-align: bottom; 90 | } 91 | 92 | .md-radio { 93 | margin: 16px 0; 94 | } 95 | 96 | .md-radio input[type="radio"]:checked + label:after { 97 | transform: scale(1); 98 | } 99 | 100 | .md-radio label:after { 101 | top: 5px; 102 | left: 5px; 103 | width: 10px; 104 | height: 10px; 105 | transform: scale(0); 106 | background: #039ade; 107 | } 108 | 109 | .md-radio label { 110 | font-size: 15px; 111 | } -------------------------------------------------------------------------------- /app/AppKernel.php: -------------------------------------------------------------------------------- 1 | getEnvironment(), ['dev', 'test'], true)) { 24 | $bundles[] = new Symfony\Bundle\DebugBundle\DebugBundle(); 25 | $bundles[] = new Symfony\Bundle\WebProfilerBundle\WebProfilerBundle(); 26 | $bundles[] = new Sensio\Bundle\DistributionBundle\SensioDistributionBundle(); 27 | 28 | if ('dev' === $this->getEnvironment()) { 29 | $bundles[] = new Sensio\Bundle\GeneratorBundle\SensioGeneratorBundle(); 30 | $bundles[] = new Symfony\Bundle\WebServerBundle\WebServerBundle(); 31 | } 32 | } 33 | 34 | return $bundles; 35 | } 36 | 37 | public function getRootDir() 38 | { 39 | return __DIR__; 40 | } 41 | 42 | public function getCacheDir() 43 | { 44 | return dirname(__DIR__).'/var/cache/'.$this->getEnvironment(); 45 | } 46 | 47 | public function getLogDir() 48 | { 49 | return dirname(__DIR__).'/var/logs'; 50 | } 51 | 52 | public function registerContainerConfiguration(LoaderInterface $loader) 53 | { 54 | $loader->load($this->getRootDir().'/config/config_'.$this->getEnvironment().'.yml'); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/AppBundle/Entity/PriceOption.php: -------------------------------------------------------------------------------- 1 | id; 48 | } 49 | 50 | /** 51 | * Set days 52 | * 53 | * @param integer $days 54 | * 55 | * @return PriceOption 56 | */ 57 | public function setDays($days) 58 | { 59 | $this->days = $days; 60 | 61 | return $this; 62 | } 63 | 64 | /** 65 | * Get days 66 | * 67 | * @return int 68 | */ 69 | public function getDays() 70 | { 71 | return $this->days; 72 | } 73 | 74 | 75 | /** 76 | * Set price 77 | * 78 | * @param float $price 79 | * 80 | * @return PriceOption 81 | */ 82 | public function setPrice($price) 83 | { 84 | $this->price = $price; 85 | 86 | return $this; 87 | } 88 | 89 | /** 90 | * Get price 91 | * 92 | * @return float 93 | */ 94 | public function getPrice() 95 | { 96 | return $this->price; 97 | } 98 | 99 | 100 | 101 | /** 102 | * Return value in string for this entity. 103 | */ 104 | public function __toString() { 105 | return "$this->days days - $this->price €"; 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /src/AppBundle/Tests/Controller/ProductControllerTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/product/'); 17 | $this->assertEquals(200, $client->getResponse()->getStatusCode(), "Unexpected HTTP status code for GET /product/"); 18 | $crawler = $client->click($crawler->selectLink('Create a new entry')->link()); 19 | 20 | // Fill in the form and submit it 21 | $form = $crawler->selectButton('Create')->form(array( 22 | 'appbundle_product[field_name]' => 'Test', 23 | // ... other fields to fill 24 | )); 25 | 26 | $client->submit($form); 27 | $crawler = $client->followRedirect(); 28 | 29 | // Check data in the show view 30 | $this->assertGreaterThan(0, $crawler->filter('td:contains("Test")')->count(), 'Missing element td:contains("Test")'); 31 | 32 | // Edit the entity 33 | $crawler = $client->click($crawler->selectLink('Edit')->link()); 34 | 35 | $form = $crawler->selectButton('Update')->form(array( 36 | 'appbundle_product[field_name]' => 'Foo', 37 | // ... other fields to fill 38 | )); 39 | 40 | $client->submit($form); 41 | $crawler = $client->followRedirect(); 42 | 43 | // Check the element contains an attribute with value equals "Foo" 44 | $this->assertGreaterThan(0, $crawler->filter('[value="Foo"]')->count(), 'Missing element [value="Foo"]'); 45 | 46 | // Delete the entity 47 | $client->submit($crawler->selectButton('Delete')->form()); 48 | $crawler = $client->followRedirect(); 49 | 50 | // Check the entity has been delete on the list 51 | $this->assertNotRegExp('/Foo/', $client->getResponse()->getContent()); 52 | } 53 | 54 | */ 55 | } 56 | -------------------------------------------------------------------------------- /src/AppBundle/Tests/Controller/ShopOrderControllerTest.php: -------------------------------------------------------------------------------- 1 | request('GET', '/shoporder/'); 17 | $this->assertEquals(200, $client->getResponse()->getStatusCode(), "Unexpected HTTP status code for GET /shoporder/"); 18 | $crawler = $client->click($crawler->selectLink('Create a new entry')->link()); 19 | 20 | // Fill in the form and submit it 21 | $form = $crawler->selectButton('Create')->form(array( 22 | 'appbundle_shoporder[field_name]' => 'Test', 23 | // ... other fields to fill 24 | )); 25 | 26 | $client->submit($form); 27 | $crawler = $client->followRedirect(); 28 | 29 | // Check data in the show view 30 | $this->assertGreaterThan(0, $crawler->filter('td:contains("Test")')->count(), 'Missing element td:contains("Test")'); 31 | 32 | // Edit the entity 33 | $crawler = $client->click($crawler->selectLink('Edit')->link()); 34 | 35 | $form = $crawler->selectButton('Update')->form(array( 36 | 'appbundle_shoporder[field_name]' => 'Foo', 37 | // ... other fields to fill 38 | )); 39 | 40 | $client->submit($form); 41 | $crawler = $client->followRedirect(); 42 | 43 | // Check the element contains an attribute with value equals "Foo" 44 | $this->assertGreaterThan(0, $crawler->filter('[value="Foo"]')->count(), 'Missing element [value="Foo"]'); 45 | 46 | // Delete the entity 47 | $client->submit($crawler->selectButton('Delete')->form()); 48 | $crawler = $client->followRedirect(); 49 | 50 | // Check the entity has been delete on the list 51 | $this->assertNotRegExp('/Foo/', $client->getResponse()->getContent()); 52 | } 53 | 54 | */ 55 | } 56 | -------------------------------------------------------------------------------- /app/Resources/views/components/product-detail.twig: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

{{ product.title }}

5 | 6 |
7 |
8 |
9 | Description 10 |
11 |
12 |
13 | {{ product.body }} 14 |
15 |
16 | 17 | 18 |
19 |
20 |
21 | {# Loop all available prices #} 22 |
23 | {% for opt in product.priceOptions %} 24 |
25 | 26 | 32 |
33 | {% endfor %} 34 |
35 | 36 |
37 | 38 | 39 |
40 |
41 |
42 | 43 |
44 |
45 |
-------------------------------------------------------------------------------- /app/Resources/views/product/new.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 | 7 |
8 |
9 |

Product creation

10 |
11 |
12 | {{ form_start(form) }} 13 |
14 |
15 |
16 | {{ form_label( form.title, null, {'label_attr': {'class': 'control-label'}} ) }} 17 | {{ form_errors( form.title ) }} 18 | {{ form_widget( form.title, {'attr': {'class': 'form-control'}} ) }} 19 |
20 |
21 |
22 |
23 | {{ form_label( form.price, null, {'label_attr': {'class': 'control-label'}} ) }} 24 | {{ form_errors( form.price ) }} 25 | {{ form_widget( form.price, {'attr': {'class': 'form-control'}} ) }} 26 |
27 |
28 |
29 |
30 |
31 |
32 | {{ form_label( form.body, 'Description', {'label_attr': {'class': 'control-label'}} ) }} 33 | {{ form_errors( form.body ) }} 34 | {{ form_widget( form.body, {'attr': {'class': 'form-control', 'rows': 5}} ) }} 35 |
36 |
37 |
38 | 39 |
40 | {# Token CSRF #} 41 | {{ form_widget( form._token ) }} 42 | {{ form_end(form, {'render_rest': false}) }} 43 |
44 |
45 | 46 | {% include 'components/products-back.twig' %} 47 | 48 |
49 |
50 | {% endblock %} 51 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "elvismdev/bitcoin.store", 3 | "license": "proprietary", 4 | "type": "project", 5 | "autoload": { 6 | "psr-4": { 7 | "AppBundle\\": "src/AppBundle" 8 | }, 9 | "classmap": [ 10 | "app/AppKernel.php", 11 | "app/AppCache.php" 12 | ] 13 | }, 14 | "autoload-dev": { 15 | "psr-4": { 16 | "Tests\\": "tests/" 17 | }, 18 | "files": [ 19 | "vendor/symfony/symfony/src/Symfony/Component/VarDumper/Resources/functions/dump.php" 20 | ] 21 | }, 22 | "require": { 23 | "php": ">=5.5.9", 24 | "doctrine/doctrine-bundle": "^1.6", 25 | "doctrine/orm": "^2.5", 26 | "incenteev/composer-parameter-handler": "^2.0", 27 | "javiereguiluz/easyadmin-bundle": "^1.17", 28 | "rmccue/requests": "^1.7", 29 | "sensio/distribution-bundle": "^5.0.19", 30 | "sensio/framework-extra-bundle": "^3.0.2", 31 | "stof/doctrine-extensions-bundle": "^1.2", 32 | "symfony/monolog-bundle": "^3.1.0", 33 | "symfony/polyfill-apcu": "^1.0", 34 | "symfony/swiftmailer-bundle": "^2.3.10", 35 | "symfony/symfony": "3.3.*", 36 | "twig/twig": "^1.0||^2.0" 37 | }, 38 | "require-dev": { 39 | "sensio/generator-bundle": "^3.0", 40 | "symfony/phpunit-bridge": "^3.0" 41 | }, 42 | "scripts": { 43 | "symfony-scripts": [ 44 | "Incenteev\\ParameterHandler\\ScriptHandler::buildParameters", 45 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::buildBootstrap", 46 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::clearCache", 47 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installAssets", 48 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::installRequirementsFile", 49 | "Sensio\\Bundle\\DistributionBundle\\Composer\\ScriptHandler::prepareDeploymentTarget" 50 | ], 51 | "post-install-cmd": [ 52 | "@symfony-scripts" 53 | ], 54 | "post-update-cmd": [ 55 | "@symfony-scripts" 56 | ] 57 | }, 58 | "config": { 59 | "sort-packages": true 60 | }, 61 | "extra": { 62 | "symfony-app-dir": "app", 63 | "symfony-bin-dir": "bin", 64 | "symfony-var-dir": "var", 65 | "symfony-web-dir": "web", 66 | "symfony-tests-dir": "tests", 67 | "symfony-assets-install": "relative", 68 | "incenteev-parameters": { 69 | "file": "app/config/parameters.yml" 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /app/Resources/views/product/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 | 7 |
8 |
9 |

Product Edit

10 |
11 |
12 | {{ form_start(edit_form) }} 13 |
14 |
15 |
16 | {{ form_label( edit_form.title, null, {'label_attr': {'class': 'control-label'}} ) }} 17 | {{ form_errors( edit_form.title ) }} 18 | {{ form_widget( edit_form.title, {'attr': {'class': 'form-control'}} ) }} 19 |
20 |
21 |
22 |
23 | {{ form_label( edit_form.price, null, {'label_attr': {'class': 'control-label'}} ) }} 24 | {{ form_errors( edit_form.price ) }} 25 | {{ form_widget( edit_form.price, {'attr': {'class': 'form-control'}} ) }} 26 |
27 |
28 |
29 |
30 |
31 |
32 | {{ form_label( edit_form.body, 'Description', {'label_attr': {'class': 'control-label'}} ) }} 33 | {{ form_errors( edit_form.body ) }} 34 | {{ form_widget( edit_form.body, {'attr': {'class': 'form-control', 'rows': 5}} ) }} 35 |
36 |
37 |
38 | 39 |
40 | {# Token CSRF #} 41 | {{ form_widget( edit_form._token ) }} 42 | {{ form_end(edit_form, {'render_rest': false}) }} 43 |
44 |
45 | 46 | {% include 'components/products-back.twig' %} 47 | 48 |
49 | {{ form_start(delete_form) }} 50 | 51 | {{ form_end(delete_form) }} 52 |
53 | 54 |
55 |
56 | {% endblock %} 57 | -------------------------------------------------------------------------------- /app/Resources/views/product/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 |

Products list

9 | {#

Here is a subtitle for this table

#} 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | {% for product in products %} 25 | 26 | 27 | 28 | 29 | 30 | 31 | 41 | 42 | {% endfor %} 43 | 44 |
IDTitlePriceUpdated AtCreated AtActions
{{ product.id }}{{ product.title }}{{ product.price }}{% if product.updatedAt %}{{ product.updatedAt|date('Y-m-d H:i:s') }}{% endif %}{% if product.createdAt %}{{ product.createdAt|date('Y-m-d H:i:s') }}{% endif %} 32 | 40 |
45 | 46 | 47 |
48 |
49 |
50 |
51 | {% endblock %} 52 | -------------------------------------------------------------------------------- /app/Resources/views/shoporder/index.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 |
7 |
8 |

Shop Orders list

9 | {#

Here is a subtitle for this table

#} 10 |
11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | {% for shopOrder in shopOrders %} 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 48 | 49 | {% endfor %} 50 | 51 |
Order IDStatusEmailClient NameProductTotal BTCTotal USDPaidUpdated AtCreated AtActions
{{ shopOrder.id }}{{ shopOrder.orderStatus == 'pending_payment' ? 'Pending Payment' : 'Completed' }}{{ shopOrder.email }}{{ shopOrder.name }} {{ shopOrder.lastName }}{{ shopOrder.product.title }}{{ shopOrder.orderTotalBtc }}{{ shopOrder.orderTotalUsd }}{% if shopOrder.orderPaid %}Yes{% else %}No{% endif %}{% if shopOrder.createdAt %}{{ shopOrder.createdAt|date('Y-m-d H:i:s') }}{% endif %}{% if shopOrder.updatedAt %}{{ shopOrder.updatedAt|date('Y-m-d H:i:s') }}{% endif %} 42 | 47 |
52 | 53 | 54 |
55 |
56 |
57 |
58 | 59 | {% endblock %} 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Bitcoin Simple Shop 2 | 3 | *Proof of concept for a Bitcoin only e-commerce site using [Blockchain.info Wallet](https://blockchain.info/wallet). This project is intended for small shops with few products (but please don't use it for real business).* 4 | 5 | ## Requirements 6 | - Composer 7 | - PHP 7.0+ 8 | - Apache or NGINX server. 9 | - MySQL server 10 | - Bitcoin wallet on [Blockchain.info](https://blockchain.info/wallet) 11 | - API key for [Blockchain Receive Payments API V2](https://blockchain.info/api/api_receive) 12 | 13 | ## Installation 14 | 15 | Clone this repo to the server root directory. 16 | 17 | ``` 18 | $ git clone https://github.com/elvismdev/Bitcoin-E-Commerce-Store.git /srv/public_html/. 19 | ``` 20 | 21 | CD into the server root and install the application dependencies. 22 | 23 | ``` 24 | $ cd /srv/public_html/ && composer install 25 | ``` 26 | > Composer install will also ask for the application parameters such as DB connection details, application secret token and Blockchain.info API key and xPub for the wallet account. Then it will auto-generate the `app/config/parameters.yml` file. 27 | 28 | Run the commands below to generate an empty database schema for the shop: 29 | 30 | ``` 31 | $ php bin/console doctrine:database:create 32 | ``` 33 | 34 | ``` 35 | $ php bin/console doctrine:schema:update --force 36 | ``` 37 | 38 | Point the server virtual host to `/srv/public_html/web/` 39 | 40 | Load the storefront in your browser `http://myshopdomain.com/` 41 | 42 | > Note that you could test drive this simple shop from your own `localhost` or run it using the [Symfony built-in web server](https://symfony.com/doc/current/setup/built_in_web_server.html#starting-the-web-server), but the paywall page *( /pay/{order_id} )* would fail to load, since the application has to be reachable from the Internet to receive the unique BTC address from Blockchain.info API to submit the order payment. 43 | 44 | ## Store backend 45 | 46 | Go to `/admin` and you'll see an HTTP basic auth prompt, use the default credentials: 47 | - User: **admin** 48 | - Pass: **admin** 49 | 50 | To change the default password run the command below: 51 | 52 | ``` 53 | php bin/console security:encode-password 54 | ``` 55 | 56 | Copy the generated password hash and replace the default at [this line here](app/config/security.yml#L8). Also you could change the *admin* username [here](app/config/security.yml#L7) for whatever you like to. 57 | 58 | > Learn more about Symfony security and how to extend this in the [official docs](https://symfony.com/doc/current/security.html#a-configuring-how-your-users-will-authenticate). 59 | 60 | Finally add some products, and have fun (contribute). 61 | 62 | ![Demo checkout](web/assets/img/demo-checkout.jpg) 63 | 64 | #### *Talk Nerdy To Me* 65 | - Built on [Symfony Framework](https://symfony.com/) 66 | - Admin backend by [EasyAdmin](https://github.com/javiereguiluz/EasyAdminBundle) 67 | - Uses [Blockchain.info API](https://blockchain.info/api) for exchange rates and [BTC wallet](https://blockchain.info/wallet) payments 68 | - Frontend with [Material Dashboard](https://www.creative-tim.com/product/material-dashboard) components (and some custom tweaks) 69 | - Icons by [Font Awesome](http://fontawesome.io/) 70 | - [Issues](https://github.com/elvismdev/Bitcoin-E-Commerce-Store/issues) && [PR's](https://github.com/elvismdev/Bitcoin-E-Commerce-Store/pulls) && [Donations](https://blockchain.info/address/18EJr8bG8StbQbtcqZcXwHF87kqLMxZ4rC) are welcome :thumbsup: 71 | -------------------------------------------------------------------------------- /web/.htaccess: -------------------------------------------------------------------------------- 1 | # Use the front controller as index file. It serves as a fallback solution when 2 | # every other rewrite/redirect fails (e.g. in an aliased environment without 3 | # mod_rewrite). Additionally, this reduces the matching process for the 4 | # start page (path "/") because otherwise Apache will apply the rewriting rules 5 | # to each configured DirectoryIndex file (e.g. index.php, index.html, index.pl). 6 | DirectoryIndex app.php 7 | 8 | # By default, Apache does not evaluate symbolic links if you did not enable this 9 | # feature in your server configuration. Uncomment the following line if you 10 | # install assets as symlinks or if you experience problems related to symlinks 11 | # when compiling LESS/Sass/CoffeScript assets. 12 | # Options FollowSymlinks 13 | 14 | # Disabling MultiViews prevents unwanted negotiation, e.g. "/app" should not resolve 15 | # to the front controller "/app.php" but be rewritten to "/app.php/app". 16 | 17 | Options -MultiViews 18 | 19 | 20 | 21 | RewriteEngine On 22 | 23 | # Determine the RewriteBase automatically and set it as environment variable. 24 | # If you are using Apache aliases to do mass virtual hosting or installed the 25 | # project in a subdirectory, the base path will be prepended to allow proper 26 | # resolution of the app.php file and to redirect to the correct URI. It will 27 | # work in environments without path prefix as well, providing a safe, one-size 28 | # fits all solution. But as you do not need it in this case, you can comment 29 | # the following 2 lines to eliminate the overhead. 30 | RewriteCond %{REQUEST_URI}::$1 ^(/.+)/(.*)::\2$ 31 | RewriteRule ^(.*) - [E=BASE:%1] 32 | 33 | # Sets the HTTP_AUTHORIZATION header removed by Apache 34 | RewriteCond %{HTTP:Authorization} . 35 | RewriteRule ^ - [E=HTTP_AUTHORIZATION:%{HTTP:Authorization}] 36 | 37 | # Redirect to URI without front controller to prevent duplicate content 38 | # (with and without `/app.php`). Only do this redirect on the initial 39 | # rewrite by Apache and not on subsequent cycles. Otherwise we would get an 40 | # endless redirect loop (request -> rewrite to front controller -> 41 | # redirect -> request -> ...). 42 | # So in case you get a "too many redirects" error or you always get redirected 43 | # to the start page because your Apache does not expose the REDIRECT_STATUS 44 | # environment variable, you have 2 choices: 45 | # - disable this feature by commenting the following 2 lines or 46 | # - use Apache >= 2.3.9 and replace all L flags by END flags and remove the 47 | # following RewriteCond (best solution) 48 | RewriteCond %{ENV:REDIRECT_STATUS} ^$ 49 | RewriteRule ^app\.php(?:/(.*)|$) %{ENV:BASE}/$1 [R=301,L] 50 | 51 | # If the requested filename exists, simply serve it. 52 | # We only want to let Apache serve files and not directories. 53 | RewriteCond %{REQUEST_FILENAME} -f 54 | RewriteRule ^ - [L] 55 | 56 | # Rewrite all other queries to the front controller. 57 | RewriteRule ^ %{ENV:BASE}/app.php [L] 58 | 59 | 60 | 61 | 62 | # When mod_rewrite is not available, we instruct a temporary redirect of 63 | # the start page to the front controller explicitly so that the website 64 | # and the generated links can still be used. 65 | RedirectMatch 302 ^/$ /app.php/ 66 | # RedirectTemp cannot be used instead 67 | 68 | 69 | -------------------------------------------------------------------------------- /app/config/config.yml: -------------------------------------------------------------------------------- 1 | imports: 2 | - { resource: parameters.yml } 3 | - { resource: security.yml } 4 | - { resource: services.yml } 5 | 6 | # Put parameters here that don't need to change on each machine where the app is deployed 7 | # https://symfony.com/doc/current/best_practices/configuration.html#application-related-configuration 8 | parameters: 9 | locale: en 10 | tobtc_endpoint: https://blockchain.info/tobtc?currency=USD&value= 11 | 12 | framework: 13 | #esi: ~ 14 | translator: { fallbacks: ['%locale%'] } 15 | secret: '%secret%' 16 | router: 17 | resource: '%kernel.project_dir%/app/config/routing.yml' 18 | strict_requirements: ~ 19 | form: ~ 20 | csrf_protection: ~ 21 | validation: { enable_annotations: true } 22 | #serializer: { enable_annotations: true } 23 | templating: 24 | engines: ['twig'] 25 | default_locale: '%locale%' 26 | trusted_hosts: ~ 27 | session: 28 | # https://symfony.com/doc/current/reference/configuration/framework.html#handler-id 29 | handler_id: session.handler.native_file 30 | save_path: '%kernel.project_dir%/var/sessions/%kernel.environment%' 31 | fragments: ~ 32 | http_method_override: true 33 | assets: ~ 34 | php_errors: 35 | log: true 36 | 37 | # Twig Configuration 38 | twig: 39 | debug: '%kernel.debug%' 40 | strict_variables: '%kernel.debug%' 41 | 42 | # Doctrine Configuration 43 | doctrine: 44 | dbal: 45 | driver: pdo_mysql 46 | host: '%database_host%' 47 | port: '%database_port%' 48 | dbname: '%database_name%' 49 | user: '%database_user%' 50 | password: '%database_password%' 51 | charset: UTF8 52 | # if using pdo_sqlite as your database driver: 53 | # 1. add the path in parameters.yml 54 | # e.g. database_path: '%kernel.project_dir%/var/data/data.sqlite' 55 | # 2. Uncomment database_path in parameters.yml.dist 56 | # 3. Uncomment next line: 57 | #path: '%database_path%' 58 | 59 | orm: 60 | auto_generate_proxy_classes: '%kernel.debug%' 61 | naming_strategy: doctrine.orm.naming_strategy.underscore 62 | auto_mapping: true 63 | 64 | # Swiftmailer Configuration 65 | # swiftmailer: 66 | # transport: '%mailer_transport%' 67 | # host: '%mailer_host%' 68 | # username: '%mailer_user%' 69 | # password: '%mailer_password%' 70 | # spool: { type: memory } 71 | 72 | # Doctrine Extensions 73 | stof_doctrine_extensions: 74 | orm: 75 | default: 76 | sluggable: true 77 | timestampable: true 78 | 79 | # EasyAdmin 80 | easy_admin: 81 | disabled_actions: ['search'] 82 | site_name: 'Bitcoin Simple Shop Admin' 83 | design: 84 | brand_color: '#9c27b0' 85 | entities: 86 | ShopOrder: 87 | class: AppBundle\Entity\ShopOrder 88 | label: 'Product Orders' 89 | list: 90 | title: "Orders" 91 | actions: ['show'] 92 | Product: 93 | class: AppBundle\Entity\Product 94 | form: 95 | fields: 96 | - 'title' 97 | - 'body' 98 | - 'priceOptions' 99 | label: 'Products' 100 | list: 101 | title: "Product" 102 | PriceOption: 103 | class: AppBundle\Entity\PriceOption 104 | label: 'Pricing Options' 105 | list: 106 | title: "Price Options" -------------------------------------------------------------------------------- /src/AppBundle/Controller/ProductController.php: -------------------------------------------------------------------------------- 1 | createForm('AppBundle\Form\ProductType', $product); 29 | $form->handleRequest($request); 30 | 31 | if ($form->isSubmitted() && $form->isValid()) { 32 | $em = $this->getDoctrine()->getManager(); 33 | $em->persist($product); 34 | $em->flush(); 35 | 36 | return $this->redirectToRoute('product_show', array('slug' => $product->getSlug())); 37 | } 38 | 39 | return $this->render('product/new.html.twig', array( 40 | 'product' => $product, 41 | 'form' => $form->createView(), 42 | )); 43 | } 44 | 45 | /** 46 | * Displays a form to edit an existing product entity. 47 | * 48 | * @Route("/{id}/edit", name="product_edit") 49 | * @Method({"GET", "POST"}) 50 | */ 51 | public function editAction(Request $request, Product $product) 52 | { 53 | $deleteForm = $this->createDeleteForm($product); 54 | $editForm = $this->createForm('AppBundle\Form\ProductType', $product); 55 | $editForm->handleRequest($request); 56 | 57 | if ($editForm->isSubmitted() && $editForm->isValid()) { 58 | $this->getDoctrine()->getManager()->flush(); 59 | 60 | return $this->redirectToRoute('product_edit', array('id' => $product->getId())); 61 | } 62 | 63 | return $this->render('product/edit.html.twig', array( 64 | 'product' => $product, 65 | 'edit_form' => $editForm->createView(), 66 | 'delete_form' => $deleteForm->createView(), 67 | )); 68 | } 69 | 70 | /** 71 | * Finds and displays a product entity. 72 | * 73 | * @Route("/{slug}/{quick_checkout}/{price_id}", name="product_show", defaults={"quick_checkout" = 0, "price_id" = 0}) 74 | * @Method("GET") 75 | */ 76 | public function showAction(Product $product, Request $request) 77 | { 78 | 79 | // Save Product in session in case of checkout. 80 | $session = $request->getSession(); 81 | $session->set('product', $product); 82 | // Get price ID if any. 83 | $priceId = $request->get('price_id'); 84 | 85 | // If quick checkout, redirect to checkout. 86 | if ( $request->get( 'quick_checkout' ) == 1 && !is_null( $priceId ) ) return $this->redirectToRoute( 'checkout', array( 'price_id' => $priceId ) ); 87 | 88 | // Or redirect to product detail page. 89 | return $this->render('product/show.html.twig', array( 90 | 'product' => $product, 91 | 'tobtc_endpoint' => $this->container->getParameter('tobtc_endpoint') 92 | )); 93 | } 94 | 95 | /** 96 | * Deletes a product entity. 97 | * 98 | * @Route("/{id}", name="product_delete") 99 | * @Method("DELETE") 100 | */ 101 | public function deleteAction(Request $request, Product $product) 102 | { 103 | $form = $this->createDeleteForm($product); 104 | $form->handleRequest($request); 105 | 106 | if ($form->isSubmitted() && $form->isValid()) { 107 | $em = $this->getDoctrine()->getManager(); 108 | $em->remove($product); 109 | $em->flush(); 110 | } 111 | 112 | return $this->redirectToRoute('product_index'); 113 | } 114 | 115 | /** 116 | * Creates a form to delete a product entity. 117 | * 118 | * @param Product $product The product entity 119 | * 120 | * @return \Symfony\Component\Form\Form The form 121 | */ 122 | private function createDeleteForm(Product $product) 123 | { 124 | return $this->createFormBuilder() 125 | ->setAction($this->generateUrl('product_delete', array('id' => $product->getId()))) 126 | ->setMethod('DELETE') 127 | ->getForm() 128 | ; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /bin/symfony_requirements: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | getPhpIniConfigPath(); 9 | 10 | echo_title('Symfony Requirements Checker'); 11 | 12 | echo '> PHP is using the following php.ini file:'.PHP_EOL; 13 | if ($iniPath) { 14 | echo_style('green', ' '.$iniPath); 15 | } else { 16 | echo_style('yellow', ' WARNING: No configuration file (php.ini) used by PHP!'); 17 | } 18 | 19 | echo PHP_EOL.PHP_EOL; 20 | 21 | echo '> Checking Symfony requirements:'.PHP_EOL.' '; 22 | 23 | $messages = array(); 24 | foreach ($symfonyRequirements->getRequirements() as $req) { 25 | if ($helpText = get_error_message($req, $lineSize)) { 26 | echo_style('red', 'E'); 27 | $messages['error'][] = $helpText; 28 | } else { 29 | echo_style('green', '.'); 30 | } 31 | } 32 | 33 | $checkPassed = empty($messages['error']); 34 | 35 | foreach ($symfonyRequirements->getRecommendations() as $req) { 36 | if ($helpText = get_error_message($req, $lineSize)) { 37 | echo_style('yellow', 'W'); 38 | $messages['warning'][] = $helpText; 39 | } else { 40 | echo_style('green', '.'); 41 | } 42 | } 43 | 44 | if ($checkPassed) { 45 | echo_block('success', 'OK', 'Your system is ready to run Symfony projects'); 46 | } else { 47 | echo_block('error', 'ERROR', 'Your system is not ready to run Symfony projects'); 48 | 49 | echo_title('Fix the following mandatory requirements', 'red'); 50 | 51 | foreach ($messages['error'] as $helpText) { 52 | echo ' * '.$helpText.PHP_EOL; 53 | } 54 | } 55 | 56 | if (!empty($messages['warning'])) { 57 | echo_title('Optional recommendations to improve your setup', 'yellow'); 58 | 59 | foreach ($messages['warning'] as $helpText) { 60 | echo ' * '.$helpText.PHP_EOL; 61 | } 62 | } 63 | 64 | echo PHP_EOL; 65 | echo_style('title', 'Note'); 66 | echo ' The command console could use a different php.ini file'.PHP_EOL; 67 | echo_style('title', '~~~~'); 68 | echo ' than the one used with your web server. To be on the'.PHP_EOL; 69 | echo ' safe side, please check the requirements from your web'.PHP_EOL; 70 | echo ' server using the '; 71 | echo_style('yellow', 'web/config.php'); 72 | echo ' script.'.PHP_EOL; 73 | echo PHP_EOL; 74 | 75 | exit($checkPassed ? 0 : 1); 76 | 77 | function get_error_message(Requirement $requirement, $lineSize) 78 | { 79 | if ($requirement->isFulfilled()) { 80 | return; 81 | } 82 | 83 | $errorMessage = wordwrap($requirement->getTestMessage(), $lineSize - 3, PHP_EOL.' ').PHP_EOL; 84 | $errorMessage .= ' > '.wordwrap($requirement->getHelpText(), $lineSize - 5, PHP_EOL.' > ').PHP_EOL; 85 | 86 | return $errorMessage; 87 | } 88 | 89 | function echo_title($title, $style = null) 90 | { 91 | $style = $style ?: 'title'; 92 | 93 | echo PHP_EOL; 94 | echo_style($style, $title.PHP_EOL); 95 | echo_style($style, str_repeat('~', strlen($title)).PHP_EOL); 96 | echo PHP_EOL; 97 | } 98 | 99 | function echo_style($style, $message) 100 | { 101 | // ANSI color codes 102 | $styles = array( 103 | 'reset' => "\033[0m", 104 | 'red' => "\033[31m", 105 | 'green' => "\033[32m", 106 | 'yellow' => "\033[33m", 107 | 'error' => "\033[37;41m", 108 | 'success' => "\033[37;42m", 109 | 'title' => "\033[34m", 110 | ); 111 | $supports = has_color_support(); 112 | 113 | echo($supports ? $styles[$style] : '').$message.($supports ? $styles['reset'] : ''); 114 | } 115 | 116 | function echo_block($style, $title, $message) 117 | { 118 | $message = ' '.trim($message).' '; 119 | $width = strlen($message); 120 | 121 | echo PHP_EOL.PHP_EOL; 122 | 123 | echo_style($style, str_repeat(' ', $width)); 124 | echo PHP_EOL; 125 | echo_style($style, str_pad(' ['.$title.']', $width, ' ', STR_PAD_RIGHT)); 126 | echo PHP_EOL; 127 | echo_style($style, $message); 128 | echo PHP_EOL; 129 | echo_style($style, str_repeat(' ', $width)); 130 | echo PHP_EOL; 131 | } 132 | 133 | function has_color_support() 134 | { 135 | static $support; 136 | 137 | if (null === $support) { 138 | if (DIRECTORY_SEPARATOR == '\\') { 139 | $support = false !== getenv('ANSICON') || 'ON' === getenv('ConEmuANSI'); 140 | } else { 141 | $support = function_exists('posix_isatty') && @posix_isatty(STDOUT); 142 | } 143 | } 144 | 145 | return $support; 146 | } 147 | -------------------------------------------------------------------------------- /app/Resources/views/default/order_pay.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 | 7 | {% for product in products %} 8 |

9 | {{ product.title }} 10 |

11 | {% endfor %} 12 | 13 |
14 |
15 |

Order Paywall

16 |
17 |
18 | 19 |
20 |
21 | Order ID: {{ shopOrder.id }} 22 | 23 |

Ship To:

24 | {{ shopOrder.name }} {{ shopOrder.lastName }}
25 | {{ shopOrder.address }}
26 | {% if shopOrder.address2 %} 27 | {{ shopOrder.address2 }}
28 | {% endif %} 29 | {{ shopOrder.city }}, {{ shopOrder.state }}, {{ shopOrder.zip }}
30 | {{ shopOrder.country }}
31 | {{ shopOrder.email }}
32 | 33 | Edit 34 | 35 |
36 |
37 |

Amount Due: {{ shopOrder.orderTotalBtc|number_format(4) }}

38 |

Send payment to the below address to complete your order.

39 | 40 |
41 | 42 | 45 |
46 | 47 |
48 |
49 |

Or scan QR Code with your mobile device.

50 | 51 |
52 | 53 |
Awaiting {{ shopOrder.orderTotalBtc|number_format(4) }} BTC payment...
54 |
55 |
56 | 57 |
58 |
59 | 60 |
61 |
62 | {% endblock %} 63 | 64 | {# Includig Clipboard.js to copy BTC address with 90 |
91 | {# Token CSRF #} 92 | {{ form_widget( checkout_form._token ) }} 93 | {{ form_end( checkout_form, {'render_rest': false} ) }} 94 | 95 | 96 | 97 | 98 | 99 | 100 |
keyboard_arrow_leftBack to product detail page
101 | 102 | {% endblock %} 103 | 104 | 105 | {% block javascripts %} 106 | {{ parent() }} 107 | 108 | 120 | 121 | {% endblock %} 122 | -------------------------------------------------------------------------------- /src/AppBundle/Entity/Product.php: -------------------------------------------------------------------------------- 1 | priceOptions = new ArrayCollection(); 76 | } 77 | 78 | 79 | /** 80 | * Get id 81 | * 82 | * @return int 83 | */ 84 | public function getId() 85 | { 86 | return $this->id; 87 | } 88 | 89 | /** 90 | * Set title 91 | * 92 | * @param string $title 93 | * 94 | * @return Product 95 | */ 96 | public function setTitle($title) 97 | { 98 | $this->title = $title; 99 | 100 | return $this; 101 | } 102 | 103 | /** 104 | * Get title 105 | * 106 | * @return string 107 | */ 108 | public function getTitle() 109 | { 110 | return $this->title; 111 | } 112 | 113 | /** 114 | * Set body 115 | * 116 | * @param string $body 117 | * 118 | * @return Product 119 | */ 120 | public function setBody($body) 121 | { 122 | $this->body = $body; 123 | 124 | return $this; 125 | } 126 | 127 | /** 128 | * Get body 129 | * 130 | * @return string 131 | */ 132 | public function getBody() 133 | { 134 | return $this->body; 135 | } 136 | 137 | 138 | /** 139 | * Set updatedAt 140 | * 141 | * @param \DateTime $updatedAt 142 | * 143 | * @return Product 144 | */ 145 | public function setUpdatedAt($updatedAt) 146 | { 147 | $this->updatedAt = $updatedAt; 148 | 149 | return $this; 150 | } 151 | 152 | /** 153 | * Get updatedAt 154 | * 155 | * @return \DateTime 156 | */ 157 | public function getUpdatedAt() 158 | { 159 | return $this->updatedAt; 160 | } 161 | 162 | /** 163 | * Set createdAt 164 | * 165 | * @param \DateTime $createdAt 166 | * 167 | * @return Product 168 | */ 169 | public function setCreatedAt($createdAt) 170 | { 171 | $this->createdAt = $createdAt; 172 | 173 | return $this; 174 | } 175 | 176 | /** 177 | * Get createdAt 178 | * 179 | * @return \DateTime 180 | */ 181 | public function getCreatedAt() 182 | { 183 | return $this->createdAt; 184 | } 185 | 186 | /** 187 | * @return mixed 188 | */ 189 | public function getSlug() 190 | { 191 | return $this->slug; 192 | } 193 | 194 | /** 195 | * @param mixed $slug 196 | * 197 | * @return self 198 | */ 199 | public function setSlug($slug) 200 | { 201 | $this->slug = $slug; 202 | 203 | return $this; 204 | } 205 | 206 | /** 207 | * To string method for this Entity. 208 | */ 209 | public function __toString() { 210 | return $this->title; 211 | } 212 | 213 | 214 | 215 | 216 | /** 217 | * Add priceOption 218 | * 219 | * @param \AppBundle\Entity\PriceOption $priceOption 220 | * 221 | * @return Product 222 | */ 223 | public function addPriceOption(\AppBundle\Entity\PriceOption $priceOption) 224 | { 225 | $this->priceOptions[] = $priceOption; 226 | 227 | return $this; 228 | } 229 | 230 | /** 231 | * Remove priceOption 232 | * 233 | * @param \AppBundle\Entity\PriceOption $priceOption 234 | */ 235 | public function removePriceOption(\AppBundle\Entity\PriceOption $priceOption) 236 | { 237 | $this->priceOptions->removeElement($priceOption); 238 | } 239 | 240 | /** 241 | * Get priceOptions 242 | * 243 | * @return \Doctrine\Common\Collections\Collection 244 | */ 245 | public function getPriceOptions() 246 | { 247 | return $this->priceOptions; 248 | } 249 | 250 | 251 | } 252 | -------------------------------------------------------------------------------- /app/Resources/views/shoporder/show.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 | 5 |
6 |
7 |
8 |
9 |

Shop Order

10 | {#

Here is a subtitle for this table

#} 11 |
12 |
13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
Order ID{{ shopOrder.id }}
Email{{ shopOrder.email }}
Name{{ shopOrder.name }}
Last Name{{ shopOrder.lastName }}
Address{{ shopOrder.address }}
Address 2{{ shopOrder.address2 }}
City{{ shopOrder.city }}
State{{ shopOrder.state }}
Zip{{ shopOrder.zip }}
Country{{ shopOrder.country }}
Product{{ shopOrder.product.title }}
Order Total BTC{{ shopOrder.orderTotalBtc }}
Order Total USD{{ shopOrder.orderTotalUsd }}
Created At{% if shopOrder.createdAt %}{{ shopOrder.createdAt|date('Y-m-d H:i:s') }}{% endif %}
Updated At{% if shopOrder.updatedAt %}{{ shopOrder.updatedAt|date('Y-m-d H:i:s') }}{% endif %}
Order Paid{% if shopOrder.orderPaid %}Yes{% else %}No{% endif %}
Order Status{{ shopOrder.orderStatus == 'pending_payment' ? 'Pending Payment' : 'Completed' }}
Amount Paid{{ shopOrder.amountPaid }}
Difference{{ shopOrder.difference }}
Transaction Hash{{ shopOrder.transactionHash }}
BTC Address ID{{ shopOrder.btcAddressId }}
107 | 108 | 109 |
110 |
111 | 112 |
keyboard_arrow_leftBack to the products list
113 | 114 |
115 | {{ form_start(delete_form) }} 116 | 117 | {{ form_end(delete_form) }} 118 |
119 | 120 |
121 |
122 | 123 | {% endblock %} 124 | -------------------------------------------------------------------------------- /app/Resources/views/shoporder/edit.html.twig: -------------------------------------------------------------------------------- 1 | {% extends 'base.html.twig' %} 2 | 3 | {% block body %} 4 |
5 |
6 | 7 |

8 | {{ shopOrder.product.title }} - Loading... ${{ shopOrder.product.price }} 9 |

10 | 11 |
12 |
13 |

Edit Order

14 |
15 |
16 | {{ form_start( edit_form ) }} 17 |
18 |
19 |
20 | {{ form_label( edit_form.email, null, {'label_attr': {'class': 'control-label'}} ) }} 21 | {{ form_errors( edit_form.email ) }} 22 | {{ form_widget( edit_form.email, {'attr': {'class': 'form-control'}} ) }} 23 |
24 |
25 |
26 |
27 |
28 |
29 | {{ form_label( edit_form.name, null, {'label_attr': {'class': 'control-label'}} ) }} 30 | {{ form_errors( edit_form.name ) }} 31 | {{ form_widget( edit_form.name, {'attr': {'class': 'form-control'}} ) }} 32 |
33 |
34 |
35 |
36 | {{ form_label( edit_form.lastName, null, {'label_attr': {'class': 'control-label'}} ) }} 37 | {{ form_errors( edit_form.lastName ) }} 38 | {{ form_widget( edit_form.lastName, {'attr': {'class': 'form-control'}} ) }} 39 |
40 |
41 |
42 |
43 |
44 |
45 | {{ form_label( edit_form.address, null, {'label_attr': {'class': 'control-label'}} ) }} 46 | {{ form_errors( edit_form.address ) }} 47 | {{ form_widget( edit_form.address, {'attr': {'class': 'form-control'}} ) }} 48 |
49 |
50 |
51 |
52 | {{ form_label( edit_form.address2, 'Apt/Suite (optional)', {'label_attr': {'class': 'control-label'}} ) }} 53 | {{ form_errors( edit_form.address2 ) }} 54 | {{ form_widget( edit_form.address2, {'attr': {'class': 'form-control'}} ) }} 55 |
56 |
57 |
58 |
59 |
60 |
61 | {{ form_label( edit_form.city, null, {'label_attr': {'class': 'control-label'}} ) }} 62 | {{ form_errors( edit_form.city ) }} 63 | {{ form_widget( edit_form.city, {'attr': {'class': 'form-control'}} ) }} 64 |
65 |
66 |
67 |
68 | {{ form_label( edit_form.state, null, {'label_attr': {'class': 'control-label'}} ) }} 69 | {{ form_errors( edit_form.state ) }} 70 | {{ form_widget( edit_form.state, {'attr': {'class': 'form-control'}} ) }} 71 |
72 |
73 |
74 |
75 | {{ form_label( edit_form.zip, null, {'label_attr': {'class': 'control-label'}} ) }} 76 | {{ form_errors( edit_form.zip ) }} 77 | {{ form_widget( edit_form.zip, {'attr': {'class': 'form-control'}} ) }} 78 |
79 |
80 |
81 |
82 | {{ form_label( edit_form.country, null, {'label_attr': {'class': 'control-label'}} ) }} 83 | {{ form_errors( edit_form.country ) }} 84 | {{ form_widget( edit_form.country, {'attr': {'class': 'form-control'}} ) }} 85 |
86 |
87 |
88 | 89 |
90 | {# Token CSRF #} 91 | {{ form_widget( edit_form._token ) }} 92 | {{ form_end( edit_form ) }} 93 |
94 |
95 | 96 | {# If order hasn't been paid yet, show retire order button for users #} 97 | {% if shopOrder.orderPaid == false %} 98 |
99 | {{ form_start(delete_form) }} 100 | 101 | {{ form_end(delete_form) }} 102 |
103 | {% endif %} 104 | 105 |
106 |
107 | {% endblock %} 108 | -------------------------------------------------------------------------------- /src/AppBundle/Controller/DefaultController.php: -------------------------------------------------------------------------------- 1 | getDoctrine()->getManager(); 20 | 21 | $products = $em->getRepository('AppBundle:Product')->findAll(); 22 | 23 | return $this->render('default/index.html.twig', array( 24 | 'products' => $products, 25 | )); 26 | } 27 | 28 | 29 | /** 30 | * Do the checkout, sets an order. 31 | * 32 | * @Route("/checkout/{price_id}", name="checkout") 33 | * @Method({"GET", "POST"}) 34 | */ 35 | public function checkoutAction(Request $request, BlockchainDotInfoService $blockchainInfo) 36 | { 37 | // Get product in session, if any. 38 | $session = $request->getSession(); 39 | $product = $session->get( 'product' ); 40 | 41 | // If no product in session. 42 | if ( !$product ) { 43 | $this->addFlash( 44 | 'notice', 45 | 'No product chosen to buy yet!' 46 | ); 47 | 48 | return $this->redirectToRoute( 'homepage' ); 49 | } 50 | 51 | 52 | // Get price ID if any. 53 | $priceId = $request->get('price_id'); 54 | // Get the price option from DB. 55 | $em = $this->getDoctrine()->getManager(); 56 | $productObj = $em->getRepository('AppBundle:Product')->find( $product->getId() ); 57 | $priceOpt = $em->getRepository('AppBundle:PriceOption')->find( $priceId ); 58 | 59 | 60 | // Create a new shop order form. 61 | $shopOrder = new Shoporder(); 62 | $form = $this->createForm('AppBundle\Form\ShopOrderType', $shopOrder); 63 | $form->handleRequest($request); 64 | 65 | if ($form->isSubmitted() && $form->isValid()) { 66 | 67 | // Set some other order info. 68 | $shopOrder->addProduct( $productObj ); 69 | $shopOrder->setOrderPaid( false ); 70 | $shopOrder->setOrderStatus( 'pending_payment' ); 71 | $shopOrder->setOrderTotalUsd( $priceOpt->getPrice() ); 72 | // Set final total in BTC for this order. 73 | $totalBTC = $blockchainInfo->toBTC( $priceOpt->getPrice() ); 74 | $shopOrder->setOrderTotalBtc( $totalBTC ); 75 | 76 | $em->persist($shopOrder); 77 | $em->flush(); 78 | 79 | return $this->redirectToRoute('order_pay', array('id' => $shopOrder->getId())); 80 | } 81 | 82 | return $this->render('default/checkout.html.twig', array( 83 | 'product_price' => $priceOpt->getPrice(), 84 | 'tobtc_endpoint' => $this->container->getParameter('tobtc_endpoint'), 85 | 'checkout_form' => $form->createView(), 86 | )); 87 | } 88 | 89 | 90 | /** 91 | * Pay for the order. 92 | * 93 | * @Route("/pay/{id}", name="order_pay") 94 | * @Method("GET") 95 | */ 96 | public function payAction( ShopOrder $shopOrder, Request $request ) { 97 | 98 | // Check if order was already paid, redirect home if true. 99 | if ( $shopOrder->getOrderPaid() === true ) { 100 | $this->addFlash( 101 | 'notice', 102 | 'This order was already paid!' 103 | ); 104 | 105 | return $this->redirectToRoute( 'homepage' ); 106 | } 107 | 108 | $products = $shopOrder->getProducts(); 109 | 110 | // Create callback url. 111 | $callback_url = $request->getSchemeAndHttpHost() . '/callback/' . $shopOrder->getId() . '/' . $this->container->getParameter('secret'); 112 | // Set parameters for Blockchain.info address request. 113 | $params = 'xpub=' . $this->container->getParameter('blockchain_xpub') . '&callback=' . urlencode( $callback_url ) . '&key=' . $this->container->getParameter('blockchain_api_key'); 114 | // Get address to pay from Blockchain.info 115 | $response = \Requests::get( 'https://api.blockchain.info/v2/receive?' . $params ); 116 | $response = json_decode( $response->body ); 117 | 118 | 119 | return $this->render('default/order_pay.html.twig', array( 120 | 'products' => $products, 121 | 'shopOrder' => $shopOrder, 122 | 'pay_to' => $response->address 123 | )); 124 | } 125 | 126 | 127 | /** 128 | * Callback URL for Blockchain.info 129 | * 130 | * @Route("/callback/{id}/{secret}", name="payment_callback") 131 | * @Method("GET") 132 | */ 133 | public function callbackAction( ShopOrder $shopOrder, Request $request ) { 134 | 135 | // Verify the secret word. 136 | if ( $request->get('secret') != $this->container->getParameter('secret') ) { 137 | die( 'AYE WHATCHA DOIN THERE!?!?!' ); 138 | } else { 139 | // Get and process response data from Blockchain.info 140 | $transactionHash = $request->get('transaction_hash'); 141 | $address = $request->get('address'); 142 | $valueInSatoshi = $request->get('value'); 143 | $valueInBTC = $valueInSatoshi / 100000000; 144 | 145 | // Update order in DB. 146 | $shopOrder->setAmountPaid( $valueInBTC ); 147 | if ( $valueInBTC == $shopOrder->getOrderTotalBtc() ) { 148 | $shopOrder->setDifference( 'No Difference' ); 149 | } else if ( $valueInBTC < $shopOrder->getOrderTotalBtc() ) { 150 | $shopOrder->setDifference( 'Underpaid' ); 151 | } else { 152 | $shopOrder->setDifference( 'Overpaid' ); 153 | } 154 | $shopOrder->setTransactionHash( $transactionHash ); 155 | $shopOrder->setBtcAddressId( $address ); 156 | $shopOrder->setOrderStatus( 'completed' ); 157 | $shopOrder->setOrderPaid( true ); 158 | 159 | // Call Doctrine Entity Manager to update/persist data in Order. 160 | $em = $this->getDoctrine()->getManager(); 161 | $em->persist( $shopOrder ); 162 | $em->flush(); 163 | 164 | 165 | // Return *ok* for Blockchain.info 166 | return $this->render( 'default/payment_callback.html.twig' ); 167 | } 168 | 169 | 170 | } 171 | 172 | 173 | /** 174 | * @Route("/thankyou/{id}", name="thank_you") 175 | * @Method("GET") 176 | */ 177 | public function thankYouAction( ShopOrder $shopOrder, Request $request ) 178 | { 179 | // Set flashbag success message if we come from the order payment page. 180 | if ( $shopOrder->getOrderPaid() === true ) { 181 | $this->addFlash( 182 | 'notice', 183 | 'Your order has been placed!' 184 | ); 185 | } 186 | 187 | return $this->redirectToRoute( 'homepage' ); 188 | } 189 | 190 | } 191 | -------------------------------------------------------------------------------- /app/Resources/views/base.html.twig: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | {% block title %}Bitcoin Simple Shop{% endblock %} 9 | 10 | 11 | {% block stylesheets %} 12 | 13 | 14 | 15 | 16 | {# Custom Styles #} 17 | 18 | 19 | 20 | 21 | {% endblock %} 22 | 23 | 24 |
25 | 44 |
45 |
46 |
47 | {% block body %} 48 | {% endblock %} 49 |
50 |
51 | 69 |
70 |
71 | 72 | {# Fork me on Github ribbon #} 73 | 74 | 75 | 76 | {% block javascripts %} 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 140 | 141 | 142 | {% endblock %} 143 | 144 | -------------------------------------------------------------------------------- /web/assets/js/material.min.js: -------------------------------------------------------------------------------- 1 | !function(t){function o(t){return"undefined"==typeof t.which?!0:"number"==typeof t.which&&t.which>0?!t.ctrlKey&&!t.metaKey&&!t.altKey&&8!=t.which&&9!=t.which&&13!=t.which&&16!=t.which&&17!=t.which&&20!=t.which&&27!=t.which:!1}function i(o){var i=t(o);i.prop("disabled")||i.closest(".form-group").addClass("is-focused")}function n(o){o.closest("label").hover(function(){var o=t(this).find("input");o.prop("disabled")||i(o)},function(){e(t(this).find("input"))})}function e(o){t(o).closest(".form-group").removeClass("is-focused")}t.expr[":"].notmdproc=function(o){return t(o).data("mdproc")?!1:!0},t.material={options:{validate:!0,input:!0,ripples:!0,checkbox:!0,togglebutton:!0,radio:!0,arrive:!0,autofill:!1,withRipples:[".btn:not(.btn-link)",".card-image",".navbar a:not(.withoutripple)",".footer a:not(.withoutripple)",".dropdown-menu a",".nav-tabs a:not(.withoutripple)",".withripple",".pagination li:not(.active):not(.disabled) a:not(.withoutripple)"].join(","),inputElements:"input.form-control, textarea.form-control, select.form-control",checkboxElements:".checkbox > label > input[type=checkbox]",togglebuttonElements:".togglebutton > label > input[type=checkbox]",radioElements:".radio > label > input[type=radio]"},checkbox:function(o){var i=t(o?o:this.options.checkboxElements).filter(":notmdproc").data("mdproc",!0).after("");n(i)},togglebutton:function(o){var i=t(o?o:this.options.togglebuttonElements).filter(":notmdproc").data("mdproc",!0).after("");n(i)},radio:function(o){var i=t(o?o:this.options.radioElements).filter(":notmdproc").data("mdproc",!0).after("");n(i)},input:function(o){t(o?o:this.options.inputElements).filter(":notmdproc").data("mdproc",!0).each(function(){var o=t(this),i=o.closest(".form-group");0===i.length&&(o.wrap("
"),i=o.closest(".form-group")),o.attr("data-hint")&&(o.after("

"+o.attr("data-hint")+"

"),o.removeAttr("data-hint"));var n={"input-lg":"form-group-lg","input-sm":"form-group-sm"};if(t.each(n,function(t,n){o.hasClass(t)&&(o.removeClass(t),i.addClass(n))}),o.hasClass("floating-label")){var e=o.attr("placeholder");o.attr("placeholder",null).removeClass("floating-label");var a=o.attr("id"),r="";a&&(r="for='"+a+"'"),i.addClass("label-floating"),o.after("")}(null===o.val()||"undefined"==o.val()||""===o.val())&&i.addClass("is-empty"),i.append(""),i.find("input[type=file]").length>0&&i.addClass("is-fileinput")})},attachInputEventHandlers:function(){var n=this.options.validate;t(document).on("change",".checkbox input[type=checkbox]",function(){t(this).blur()}).on("keydown paste",".form-control",function(i){o(i)&&t(this).closest(".form-group").removeClass("is-empty")}).on("keyup change",".form-control",function(){var o=t(this),i=o.closest(".form-group"),e="undefined"==typeof o[0].checkValidity||o[0].checkValidity();""===o.val()?i.addClass("is-empty"):i.removeClass("is-empty"),n&&(e?i.removeClass("has-error"):i.addClass("has-error"))}).on("focus",".form-control, .form-group.is-fileinput",function(){i(this)}).on("blur",".form-control, .form-group.is-fileinput",function(){e(this)}).on("change",".form-group input",function(){var o=t(this);if("file"!=o.attr("type")){var i=o.closest(".form-group"),n=o.val();n?i.removeClass("is-empty"):i.addClass("is-empty")}}).on("change",".form-group.is-fileinput input[type='file']",function(){var o=t(this),i=o.closest(".form-group"),n="";t.each(this.files,function(t,o){n+=o.name+", "}),n=n.substring(0,n.length-2),n?i.removeClass("is-empty"):i.addClass("is-empty"),i.find("input.form-control[readonly]").val(n)})},ripples:function(o){t(o?o:this.options.withRipples).ripples()},autofill:function(){var o=setInterval(function(){t("input[type!=checkbox]").each(function(){var o=t(this);o.val()&&o.val()!==o.attr("value")&&o.trigger("change")})},100);setTimeout(function(){clearInterval(o)},1e4)},attachAutofillEventHandlers:function(){var o;t(document).on("focus","input",function(){var i=t(this).parents("form").find("input").not("[type=file]");o=setInterval(function(){i.each(function(){var o=t(this);o.val()!==o.attr("value")&&o.trigger("change")})},100)}).on("blur",".form-group input",function(){clearInterval(o)})},init:function(o){this.options=t.extend({},this.options,o);var i=t(document);t.fn.ripples&&this.options.ripples&&this.ripples(),this.options.input&&(this.input(),this.attachInputEventHandlers()),this.options.checkbox&&this.checkbox(),this.options.togglebutton&&this.togglebutton(),this.options.radio&&this.radio(),this.options.autofill&&(this.autofill(),this.attachAutofillEventHandlers()),document.arrive&&this.options.arrive&&(t.fn.ripples&&this.options.ripples&&i.arrive(this.options.withRipples,function(){t.material.ripples(t(this))}),this.options.input&&i.arrive(this.options.inputElements,function(){t.material.input(t(this))}),this.options.checkbox&&i.arrive(this.options.checkboxElements,function(){t.material.checkbox(t(this))}),this.options.radio&&i.arrive(this.options.radioElements,function(){t.material.radio(t(this))}),this.options.togglebutton&&i.arrive(this.options.togglebuttonElements,function(){t.material.togglebutton(t(this))}))}}}(jQuery),function(t,o,i,n){"use strict";function e(o,i){r=this,this.element=t(o),this.options=t.extend({},s,i),this._defaults=s,this._name=a,this.init()}var a="ripples",r=null,s={};e.prototype.init=function(){var i=this.element;i.on("mousedown touchstart",function(n){if(!r.isTouch()||"mousedown"!==n.type){i.find(".ripple-container").length||i.append('
');var e=i.children(".ripple-container"),a=r.getRelY(e,n),s=r.getRelX(e,n);if(a||s){var l=r.getRipplesColor(i),p=t("
");p.addClass("ripple").css({left:s,top:a,"background-color":l}),e.append(p),function(){return o.getComputedStyle(p[0]).opacity}(),r.rippleOn(i,p),setTimeout(function(){r.rippleEnd(p)},500),i.on("mouseup mouseleave touchend",function(){p.data("mousedown","off"),"off"===p.data("animating")&&r.rippleOut(p)})}}})},e.prototype.getNewSize=function(t,o){return Math.max(t.outerWidth(),t.outerHeight())/o.outerWidth()*2.5},e.prototype.getRelX=function(t,o){var i=t.offset();return r.isTouch()?(o=o.originalEvent,1===o.touches.length?o.touches[0].pageX-i.left:!1):o.pageX-i.left},e.prototype.getRelY=function(t,o){var i=t.offset();return r.isTouch()?(o=o.originalEvent,1===o.touches.length?o.touches[0].pageY-i.top:!1):o.pageY-i.top},e.prototype.getRipplesColor=function(t){var i=t.data("ripple-color")?t.data("ripple-color"):o.getComputedStyle(t[0]).color;return i},e.prototype.hasTransitionSupport=function(){var t=i.body||i.documentElement,o=t.style,e=o.transition!==n||o.WebkitTransition!==n||o.MozTransition!==n||o.MsTransition!==n||o.OTransition!==n;return e},e.prototype.isTouch=function(){return/Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)},e.prototype.rippleEnd=function(t){t.data("animating","off"),"off"===t.data("mousedown")&&r.rippleOut(t)},e.prototype.rippleOut=function(t){t.off(),r.hasTransitionSupport()?t.addClass("ripple-out"):t.animate({opacity:0},100,function(){t.trigger("transitionend")}),t.on("transitionend webkitTransitionEnd oTransitionEnd MSTransitionEnd",function(){t.remove()})},e.prototype.rippleOn=function(t,o){var i=r.getNewSize(t,o);r.hasTransitionSupport()?o.css({"-ms-transform":"scale("+i+")","-moz-transform":"scale("+i+")","-webkit-transform":"scale("+i+")",transform:"scale("+i+")"}).addClass("ripple-on").data("animating","on").data("mousedown","on"):o.animate({width:2*Math.max(t.outerWidth(),t.outerHeight()),height:2*Math.max(t.outerWidth(),t.outerHeight()),"margin-left":-1*Math.max(t.outerWidth(),t.outerHeight()),"margin-top":-1*Math.max(t.outerWidth(),t.outerHeight()),opacity:.2},500,function(){o.trigger("transitionend")})},t.fn.ripples=function(o){return this.each(function(){t.data(this,"plugin_"+a)||t.data(this,"plugin_"+a,new e(this,o))})}}(jQuery,window,document); 2 | -------------------------------------------------------------------------------- /web/assets/js/material-dashboard.js: -------------------------------------------------------------------------------- 1 | /*! 2 | 3 | ========================================================= 4 | * Material Dashboard - v1.2.0 5 | ========================================================= 6 | 7 | * Product Page: http://www.creative-tim.com/product/material-dashboard 8 | * Copyright 2017 Creative Tim (http://www.creative-tim.com) 9 | * Licensed under MIT (https://github.com/creativetimofficial/material-dashboard/blob/master/LICENSE.md) 10 | 11 | ========================================================= 12 | 13 | * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 14 | 15 | */ 16 | 17 | (function() { 18 | isWindows = navigator.platform.indexOf('Win') > -1 ? true : false; 19 | 20 | if (isWindows) { 21 | // if we are on windows OS we activate the perfectScrollbar function 22 | $('.sidebar .sidebar-wrapper, .main-panel').perfectScrollbar(); 23 | 24 | $('html').addClass('perfect-scrollbar-on'); 25 | } else { 26 | $('html').addClass('perfect-scrollbar-off'); 27 | } 28 | })(); 29 | 30 | 31 | var searchVisible = 0; 32 | var transparent = true; 33 | 34 | var transparentDemo = true; 35 | var fixedTop = false; 36 | 37 | var mobile_menu_visible = 0, 38 | mobile_menu_initialized = false, 39 | toggle_initialized = false, 40 | bootstrap_nav_initialized = false; 41 | 42 | var seq = 0, 43 | delays = 80, 44 | durations = 500; 45 | var seq2 = 0, 46 | delays2 = 80, 47 | durations2 = 500; 48 | 49 | 50 | $(document).ready(function() { 51 | 52 | $sidebar = $('.sidebar'); 53 | 54 | $.material.init(); 55 | 56 | window_width = $(window).width(); 57 | 58 | md.initSidebarsCheck(); 59 | 60 | // check if there is an image set for the sidebar's background 61 | md.checkSidebarImage(); 62 | 63 | // Activate the tooltips 64 | $('[rel="tooltip"]').tooltip(); 65 | 66 | $('.form-control').on("focus", function() { 67 | $(this).parent('.input-group').addClass("input-group-focus"); 68 | }).on("blur", function() { 69 | $(this).parent(".input-group").removeClass("input-group-focus"); 70 | }); 71 | 72 | }); 73 | 74 | $(document).on('click', '.navbar-toggle', function() { 75 | $toggle = $(this); 76 | 77 | if (mobile_menu_visible == 1) { 78 | $('html').removeClass('nav-open'); 79 | 80 | $('.close-layer').remove(); 81 | setTimeout(function() { 82 | $toggle.removeClass('toggled'); 83 | }, 400); 84 | 85 | mobile_menu_visible = 0; 86 | } else { 87 | setTimeout(function() { 88 | $toggle.addClass('toggled'); 89 | }, 430); 90 | 91 | div = '
'; 92 | $(div).appendTo('body').click(function() { 93 | $('html').removeClass('nav-open'); 94 | mobile_menu_visible = 0; 95 | setTimeout(function() { 96 | $toggle.removeClass('toggled'); 97 | $('#bodyClick').remove(); 98 | }, 550); 99 | }); 100 | 101 | $('html').addClass('nav-open'); 102 | mobile_menu_visible = 1; 103 | 104 | } 105 | }); 106 | 107 | // activate collapse right menu when the windows is resized 108 | $(window).resize(function() { 109 | md.initSidebarsCheck(); 110 | // reset the seq for charts drawing animations 111 | seq = seq2 = 0; 112 | }); 113 | 114 | md = { 115 | misc: { 116 | navbar_menu_visible: 0, 117 | active_collapse: true, 118 | disabled_collapse_init: 0, 119 | }, 120 | 121 | checkSidebarImage: function() { 122 | $sidebar = $('.sidebar'); 123 | image_src = $sidebar.data('image'); 124 | 125 | if (image_src !== undefined) { 126 | sidebar_container = '',trigger:"hover focus",title:"",delay:0,html:!1,container:!1,viewport:{selector:"body",padding:0}},c.prototype.init=function(b,c,d){if(this.enabled=!0,this.type=b,this.$element=a(c),this.options=this.getOptions(d),this.$viewport=this.options.viewport&&a(a.isFunction(this.options.viewport)?this.options.viewport.call(this,this.$element):this.options.viewport.selector||this.options.viewport),this.inState={click:!1,hover:!1,focus:!1},this.$element[0]instanceof document.constructor&&!this.options.selector)throw new Error("`selector` option must be specified when initializing "+this.type+" on the window.document object!");for(var e=this.options.trigger.split(" "),f=e.length;f--;){var g=e[f];if("click"==g)this.$element.on("click."+this.type,this.options.selector,a.proxy(this.toggle,this));else if("manual"!=g){var h="hover"==g?"mouseenter":"focusin",i="hover"==g?"mouseleave":"focusout";this.$element.on(h+"."+this.type,this.options.selector,a.proxy(this.enter,this)),this.$element.on(i+"."+this.type,this.options.selector,a.proxy(this.leave,this))}}this.options.selector?this._options=a.extend({},this.options,{trigger:"manual",selector:""}):this.fixTitle()},c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.getOptions=function(b){return b=a.extend({},this.getDefaults(),this.$element.data(),b),b.delay&&"number"==typeof b.delay&&(b.delay={show:b.delay,hide:b.delay}),b},c.prototype.getDelegateOptions=function(){var b={},c=this.getDefaults();return this._options&&a.each(this._options,function(a,d){c[a]!=d&&(b[a]=d)}),b},c.prototype.enter=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);return c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusin"==b.type?"focus":"hover"]=!0),c.tip().hasClass("in")||"in"==c.hoverState?void(c.hoverState="in"):(clearTimeout(c.timeout),c.hoverState="in",c.options.delay&&c.options.delay.show?void(c.timeout=setTimeout(function(){"in"==c.hoverState&&c.show()},c.options.delay.show)):c.show())},c.prototype.isInStateTrue=function(){for(var a in this.inState)if(this.inState[a])return!0;return!1},c.prototype.leave=function(b){var c=b instanceof this.constructor?b:a(b.currentTarget).data("bs."+this.type);if(c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c)),b instanceof a.Event&&(c.inState["focusout"==b.type?"focus":"hover"]=!1),!c.isInStateTrue())return clearTimeout(c.timeout),c.hoverState="out",c.options.delay&&c.options.delay.hide?void(c.timeout=setTimeout(function(){"out"==c.hoverState&&c.hide()},c.options.delay.hide)):c.hide()},c.prototype.show=function(){var b=a.Event("show.bs."+this.type);if(this.hasContent()&&this.enabled){this.$element.trigger(b);var d=a.contains(this.$element[0].ownerDocument.documentElement,this.$element[0]);if(b.isDefaultPrevented()||!d)return;var e=this,f=this.tip(),g=this.getUID(this.type);this.setContent(),f.attr("id",g),this.$element.attr("aria-describedby",g),this.options.animation&&f.addClass("fade");var h="function"==typeof this.options.placement?this.options.placement.call(this,f[0],this.$element[0]):this.options.placement,i=/\s?auto?\s?/i,j=i.test(h);j&&(h=h.replace(i,"")||"top"),f.detach().css({top:0,left:0,display:"block"}).addClass(h).data("bs."+this.type,this),this.options.container?f.appendTo(this.options.container):f.insertAfter(this.$element),this.$element.trigger("inserted.bs."+this.type);var k=this.getPosition(),l=f[0].offsetWidth,m=f[0].offsetHeight;if(j){var n=h,o=this.getPosition(this.$viewport);h="bottom"==h&&k.bottom+m>o.bottom?"top":"top"==h&&k.top-mo.width?"left":"left"==h&&k.left-lg.top+g.height&&(e.top=g.top+g.height-i)}else{var j=b.left-f,k=b.left+f+c;jg.right&&(e.left=g.left+g.width-k)}return e},c.prototype.getTitle=function(){var a,b=this.$element,c=this.options;return a=b.attr("data-original-title")||("function"==typeof c.title?c.title.call(b[0]):c.title)},c.prototype.getUID=function(a){do a+=~~(1e6*Math.random());while(document.getElementById(a));return a},c.prototype.tip=function(){if(!this.$tip&&(this.$tip=a(this.options.template),1!=this.$tip.length))throw new Error(this.type+" `template` option must consist of exactly 1 top-level element!");return this.$tip},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".tooltip-arrow")},c.prototype.enable=function(){this.enabled=!0},c.prototype.disable=function(){this.enabled=!1},c.prototype.toggleEnabled=function(){this.enabled=!this.enabled},c.prototype.toggle=function(b){var c=this;b&&(c=a(b.currentTarget).data("bs."+this.type),c||(c=new this.constructor(b.currentTarget,this.getDelegateOptions()),a(b.currentTarget).data("bs."+this.type,c))),b?(c.inState.click=!c.inState.click,c.isInStateTrue()?c.enter(c):c.leave(c)):c.tip().hasClass("in")?c.leave(c):c.enter(c)},c.prototype.destroy=function(){var a=this;clearTimeout(this.timeout),this.hide(function(){a.$element.off("."+a.type).removeData("bs."+a.type),a.$tip&&a.$tip.detach(),a.$tip=null,a.$arrow=null,a.$viewport=null,a.$element=null})};var d=a.fn.tooltip;a.fn.tooltip=b,a.fn.tooltip.Constructor=c,a.fn.tooltip.noConflict=function(){return a.fn.tooltip=d,this}}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.popover"),f="object"==typeof b&&b;!e&&/destroy|hide/.test(b)||(e||d.data("bs.popover",e=new c(this,f)),"string"==typeof b&&e[b]())})}var c=function(a,b){this.init("popover",a,b)};if(!a.fn.tooltip)throw new Error("Popover requires tooltip.js");c.VERSION="3.3.7",c.DEFAULTS=a.extend({},a.fn.tooltip.Constructor.DEFAULTS,{placement:"right",trigger:"click",content:"",template:''}),c.prototype=a.extend({},a.fn.tooltip.Constructor.prototype),c.prototype.constructor=c,c.prototype.getDefaults=function(){return c.DEFAULTS},c.prototype.setContent=function(){var a=this.tip(),b=this.getTitle(),c=this.getContent();a.find(".popover-title")[this.options.html?"html":"text"](b),a.find(".popover-content").children().detach().end()[this.options.html?"string"==typeof c?"html":"append":"text"](c),a.removeClass("fade top bottom left right in"),a.find(".popover-title").html()||a.find(".popover-title").hide()},c.prototype.hasContent=function(){return this.getTitle()||this.getContent()},c.prototype.getContent=function(){var a=this.$element,b=this.options;return a.attr("data-content")||("function"==typeof b.content?b.content.call(a[0]):b.content)},c.prototype.arrow=function(){return this.$arrow=this.$arrow||this.tip().find(".arrow")};var d=a.fn.popover;a.fn.popover=b,a.fn.popover.Constructor=c,a.fn.popover.noConflict=function(){return a.fn.popover=d,this}}(jQuery),+function(a){"use strict";function b(c,d){this.$body=a(document.body),this.$scrollElement=a(a(c).is(document.body)?window:c),this.options=a.extend({},b.DEFAULTS,d),this.selector=(this.options.target||"")+" .nav li > a",this.offsets=[],this.targets=[],this.activeTarget=null,this.scrollHeight=0,this.$scrollElement.on("scroll.bs.scrollspy",a.proxy(this.process,this)),this.refresh(),this.process()}function c(c){return this.each(function(){var d=a(this),e=d.data("bs.scrollspy"),f="object"==typeof c&&c;e||d.data("bs.scrollspy",e=new b(this,f)),"string"==typeof c&&e[c]()})}b.VERSION="3.3.7",b.DEFAULTS={offset:10},b.prototype.getScrollHeight=function(){return this.$scrollElement[0].scrollHeight||Math.max(this.$body[0].scrollHeight,document.documentElement.scrollHeight)},b.prototype.refresh=function(){var b=this,c="offset",d=0;this.offsets=[],this.targets=[],this.scrollHeight=this.getScrollHeight(),a.isWindow(this.$scrollElement[0])||(c="position",d=this.$scrollElement.scrollTop()),this.$body.find(this.selector).map(function(){var b=a(this),e=b.data("target")||b.attr("href"),f=/^#./.test(e)&&a(e);return f&&f.length&&f.is(":visible")&&[[f[c]().top+d,e]]||null}).sort(function(a,b){return a[0]-b[0]}).each(function(){b.offsets.push(this[0]),b.targets.push(this[1])})},b.prototype.process=function(){var a,b=this.$scrollElement.scrollTop()+this.options.offset,c=this.getScrollHeight(),d=this.options.offset+c-this.$scrollElement.height(),e=this.offsets,f=this.targets,g=this.activeTarget;if(this.scrollHeight!=c&&this.refresh(),b>=d)return g!=(a=f[f.length-1])&&this.activate(a);if(g&&b=e[a]&&(void 0===e[a+1]||b .dropdown-menu > .active").removeClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!1),b.addClass("active").find('[data-toggle="tab"]').attr("aria-expanded",!0),h?(b[0].offsetWidth,b.addClass("in")):b.removeClass("fade"),b.parent(".dropdown-menu").length&&b.closest("li.dropdown").addClass("active").end().find('[data-toggle="tab"]').attr("aria-expanded",!0),e&&e()}var g=d.find("> .active"),h=e&&a.support.transition&&(g.length&&g.hasClass("fade")||!!d.find("> .fade").length);g.length&&h?g.one("bsTransitionEnd",f).emulateTransitionEnd(c.TRANSITION_DURATION):f(),g.removeClass("in")};var d=a.fn.tab;a.fn.tab=b,a.fn.tab.Constructor=c,a.fn.tab.noConflict=function(){return a.fn.tab=d,this};var e=function(c){c.preventDefault(),b.call(a(this),"show")};a(document).on("click.bs.tab.data-api",'[data-toggle="tab"]',e).on("click.bs.tab.data-api",'[data-toggle="pill"]',e)}(jQuery),+function(a){"use strict";function b(b){return this.each(function(){var d=a(this),e=d.data("bs.affix"),f="object"==typeof b&&b;e||d.data("bs.affix",e=new c(this,f)),"string"==typeof b&&e[b]()})}var c=function(b,d){this.options=a.extend({},c.DEFAULTS,d),this.$target=a(this.options.target).on("scroll.bs.affix.data-api",a.proxy(this.checkPosition,this)).on("click.bs.affix.data-api",a.proxy(this.checkPositionWithEventLoop,this)),this.$element=a(b),this.affixed=null,this.unpin=null,this.pinnedOffset=null,this.checkPosition()};c.VERSION="3.3.7",c.RESET="affix affix-top affix-bottom",c.DEFAULTS={offset:0,target:window},c.prototype.getState=function(a,b,c,d){var e=this.$target.scrollTop(),f=this.$element.offset(),g=this.$target.height();if(null!=c&&"top"==this.affixed)return e=a-d&&"bottom"},c.prototype.getPinnedOffset=function(){if(this.pinnedOffset)return this.pinnedOffset;this.$element.removeClass(c.RESET).addClass("affix");var a=this.$target.scrollTop(),b=this.$element.offset();return this.pinnedOffset=b.top-a},c.prototype.checkPositionWithEventLoop=function(){setTimeout(a.proxy(this.checkPosition,this),1)},c.prototype.checkPosition=function(){if(this.$element.is(":visible")){var b=this.$element.height(),d=this.options.offset,e=d.top,f=d.bottom,g=Math.max(a(document).height(),a(document.body).height());"object"!=typeof d&&(f=e=d),"function"==typeof e&&(e=d.top(this.$element)),"function"==typeof f&&(f=d.bottom(this.$element));var h=this.getState(g,b,e,f);if(this.affixed!=h){null!=this.unpin&&this.$element.css("top","");var i="affix"+(h?"-"+h:""),j=a.Event(i+".bs.affix");if(this.$element.trigger(j),j.isDefaultPrevented())return;this.affixed=h,this.unpin="bottom"==h?this.getPinnedOffset():null,this.$element.removeClass(c.RESET).addClass(i).trigger(i.replace("affix","affixed")+".bs.affix")}"bottom"==h&&this.$element.offset({top:g-b-f})}};var d=a.fn.affix;a.fn.affix=b,a.fn.affix.Constructor=c,a.fn.affix.noConflict=function(){return a.fn.affix=d,this},a(window).on("load",function(){a('[data-spy="affix"]').each(function(){var c=a(this),d=c.data();d.offset=d.offset||{},null!=d.offsetBottom&&(d.offset.bottom=d.offsetBottom),null!=d.offsetTop&&(d.offset.top=d.offsetTop),b.call(c,d)})})}(jQuery); --------------------------------------------------------------------------------