├── .gitignore ├── .idea ├── laravel-early-access.iml └── php.xml ├── .scrutinizer.yml ├── .styleci.yml ├── .travis.yml ├── art └── logo.png ├── changelog.md ├── composer.json ├── config └── early-access.php ├── contributing.md ├── database └── migrations │ └── 2019_01_03_114743_create_early_access_table.php ├── license.md ├── mix-manifest.json ├── package.json ├── phpunit.xml.dist ├── public ├── css │ └── early-access.css └── svg │ └── placeholder.svg ├── readme.md ├── resources ├── lang │ └── en │ │ ├── common.php │ │ ├── mail.php │ │ └── messages.php ├── sass │ └── early-access.scss ├── svg │ └── placeholder.svg └── views │ ├── index.blade.php │ ├── layouts │ └── main.blade.php │ └── unsubscribe.blade.php ├── routes └── web.php ├── src ├── Console │ └── Commands │ │ └── EarlyAccess.php ├── Contracts │ └── Subscription │ │ ├── SubscriptionProvider.php │ │ └── SubscriptionRepository.php ├── EarlyAccess.php ├── EarlyAccessServiceProvider.php ├── Events │ ├── UserSubscribed.php │ └── UserUnsubscribed.php ├── Facades │ └── EarlyAccess.php ├── Http │ ├── Controllers │ │ └── EarlyAccessController.php │ └── Middleware │ │ └── CheckForEarlyAccessMode.php ├── Listeners │ ├── SendSubscriptionNotification.php │ └── SendUnsubscribeNotification.php ├── Notifications │ ├── UserSubscribed.php │ └── UserUnsubscribed.php ├── Subscriber.php ├── SubscriptionServices │ ├── DatabaseService.php │ └── Repositories │ │ └── Database │ │ └── EloquentRepository.php └── Traits │ └── InteractsWithEarlyAccess.php ├── tailwind.js ├── tests ├── Feature │ └── CommandsTest.php ├── TestCase.php └── Unit │ ├── EarlyAccessTest.php │ ├── SubscribeTest.php │ └── SubscriberTest.php └── webpack.mix.js /.gitignore: -------------------------------------------------------------------------------- 1 | /dist 2 | /node_modules 3 | /vendor 4 | phpunit.xml 5 | yarn-error.log 6 | yarn.lock 7 | -------------------------------------------------------------------------------- /.idea/laravel-early-access.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.idea/php.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: [tests/*] 3 | 4 | checks: 5 | php: 6 | code_rating: true 7 | remove_extra_empty_lines: true 8 | remove_php_closing_tag: true 9 | remove_trailing_whitespace: true 10 | fix_use_statements: 11 | remove_unused: true 12 | preserve_multiple: false 13 | preserve_blanklines: true 14 | order_alphabetically: true 15 | fix_php_opening_tag: true 16 | fix_linefeed: true 17 | fix_line_ending: true 18 | fix_identation_4spaces: true 19 | fix_doc_comments: true 20 | 21 | tools: 22 | php_analyzer: true 23 | php_code_coverage: true 24 | php_code_sniffer: 25 | config: 26 | standard: PSR4 27 | filter: 28 | paths: ['src'] 29 | php_loc: 30 | enabled: true 31 | excluded_dirs: [vendor, tests] 32 | php_cpd: 33 | enabled: true 34 | excluded_dirs: [vendor, tests] 35 | -------------------------------------------------------------------------------- /.styleci.yml: -------------------------------------------------------------------------------- 1 | preset: laravel 2 | 3 | disabled: 4 | - concat_without_spaces 5 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | 3 | php: 4 | - 7.1 5 | - 7.2 6 | - 7.3 7 | 8 | cache: 9 | directories: 10 | - $HOME/.composer/cache 11 | 12 | env: 13 | - TESTBENCH_VERSION=3.6.* PHPUNIT_VERSION=~7 STABILITY=stable 14 | - TESTBENCH_VERSION=dev-master PHPUNIT_VERSION=~7 STABILITY=dev 15 | 16 | matrix: 17 | allow_failures: 18 | - env: TESTBENCH_VERSION=dev-master PHPUNIT_VERSION=~7 STABILITY=dev 19 | - php: nightly 20 | fast_finish: true 21 | 22 | before_script: 23 | - travis_retry composer self-update 24 | - composer config minimum-stability ${STABILITY} 25 | - travis_retry composer require "orchestra/testbench:${TESTBENCH_VERSION}" "phpunit/phpunit:${PHPUNIT_VERSION}" --no-update 26 | - travis_retry composer update --no-interaction --prefer-source 27 | 28 | script: 29 | - vendor/bin/phpunit --coverage-text --coverage-clover=coverage.clover 30 | 31 | after_script: 32 | - wget https://scrutinizer-ci.com/ocular.phar 33 | - php ocular.phar code-coverage:upload --format=php-clover coverage.clover 34 | -------------------------------------------------------------------------------- /art/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/neoighodaro/laravel-early-access/c28e2f5ed9bf3683f7ce384be5bda2bb32710901/art/logo.png -------------------------------------------------------------------------------- /changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes will be documented in this file. 4 | 5 | ## Version v1.1.1 6 | 7 | ## Fixed 8 | 9 | - Typo in the translations file. 10 | - Fixed bug where wrong name space was used for publishing some vendor files. 11 | 12 | ### Fixed 13 | 14 | ## Version 1.0.2 15 | 16 | ### Fixed 17 | 18 | - Error with setting `/` as the early access mode URL. 19 | 20 | ## Version 1.0.1 21 | 22 | ### Fixed 23 | 24 | - Error that occurs when you run the early access activate Artisan command twice in a row with empty allowed networks. 25 | 26 | ## Version 1.0.0 27 | 28 | ### Added 29 | 30 | - Everything 31 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "neo/laravel-early-access", 3 | "description": "Adds an early access page to your Laravel application.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Neo Ighodaro", 8 | "email": "neo@creativitykills.co", 9 | "homepage": "https://creativitykills.co", 10 | "role": "Developer" 11 | } 12 | ], 13 | "homepage": "https://github.com/neo/laravel-early-access", 14 | "keywords": [ 15 | "ck", 16 | "creativitykills", 17 | "early-access", 18 | "landing-page" 19 | ], 20 | "require": { 21 | "php": "^7.2|^8.0", 22 | "ext-json": "*", 23 | "illuminate/support": "^6.0|^7.0|^8.0" 24 | }, 25 | "require-dev": { 26 | "phpunit/phpunit": "^8.0|^9.0", 27 | "mockery/mockery": "^1.1", 28 | "orchestra/testbench": "^4.0|^5.0", 29 | "sempro/phpunit-pretty-print": "^1.0" 30 | }, 31 | "autoload": { 32 | "psr-4": { 33 | "Neo\\EarlyAccess\\": "src/" 34 | } 35 | }, 36 | "autoload-dev": { 37 | "psr-4": { 38 | "Neo\\EarlyAccess\\Tests\\": "tests" 39 | } 40 | }, 41 | "extra": { 42 | "laravel": { 43 | "providers": [ 44 | "Neo\\EarlyAccess\\EarlyAccessServiceProvider" 45 | ], 46 | "aliases": { 47 | "EarlyAccess": "Neo\\EarlyAccess\\Facades\\EarlyAccess" 48 | } 49 | } 50 | }, 51 | "config": { 52 | "sort-packages": true 53 | }, 54 | "minimum-stability": "dev", 55 | "prefer-stable": true 56 | } 57 | -------------------------------------------------------------------------------- /config/early-access.php: -------------------------------------------------------------------------------- 1 | env('EARLY_ACCESS_ENABLED', false), 9 | 10 | /** 11 | * The URL to use as the early access page 12 | */ 13 | 'url' => env('EARLY_ACCESS_URL', '/early-access'), 14 | 15 | /** 16 | * URL to your applications login page. 17 | */ 18 | 'login_url' => env('EARLY_ACCESS_LOGIN_URL', '/login'), 19 | 20 | /** 21 | * Twitter handle without the @. This will be added to the share message included with the subscription message. 22 | */ 23 | 'twitter_handle' => env('EARLY_ACCESS_TWITTER_HANDLE'), 24 | 25 | /** 26 | * The early access view to load. 27 | */ 28 | 'view' => env('EARLY_ACCESS_VIEW', 'early-access::index'), 29 | 30 | /** 31 | * Service driver to use. 32 | * 33 | * Supported: database 34 | * 35 | * To add your own driver, create a class that implements the `Neo\EarlyAccess\Contracts\Subscription\SubscriptionProvider` 36 | * contract. Register the class in your `AppServiceProvider` making sure to prepend the name with 'early-access.'. 37 | * Example: 38 | * 39 | * $this->app->instance('early-access.service-name', function() { 40 | * return new ServiceClass; 41 | * }); 42 | * 43 | * To set your custom service, change the value below to your service name (without the 'early-access.'). 44 | */ 45 | 'service' => env('EARLY_ACCESS_SERVICE_DRIVER', 'database'), 46 | 47 | 'services' => [ 48 | 49 | /** 50 | * Database settings... 51 | */ 52 | 'database' => [ 53 | 'table_name' => env('EARLY_ACCESS_SERVICE_DB_TABLE', 'subscribers'), 54 | ], 55 | 56 | ], 57 | 58 | /** 59 | * Notification classes to be called when a user completes an action. 60 | */ 61 | 'notifications' => [ 62 | 'subscribed' => Neo\EarlyAccess\Notifications\UserSubscribed::class, 63 | 'unsubscribed' => Neo\EarlyAccess\Notifications\UserUnsubscribed::class, 64 | ], 65 | 66 | ]; 67 | -------------------------------------------------------------------------------- /contributing.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Contributions are welcome and will be fully credited. 4 | 5 | Contributions are accepted via Pull Requests on [Github](https://github.com/neoighodaro/laravel-early-access). 6 | 7 | # Things you could do 8 | If you want to contribute but do not know where to start, this list provides some starting points. 9 | - Set up TravisCI, StyleCI, ScrutinizerCI 10 | - Write a more comprehensive ReadMe 11 | 12 | ## Pull Requests 13 | 14 | - **Add tests!** - Your patch won't be accepted if it doesn't have tests. 15 | 16 | - **Document any change in behaviour** - Make sure the `readme.md` and any other relevant documentation are kept up-to-date. 17 | 18 | - **Consider our release cycle** - We try to follow [SemVer v2.0.0](http://semver.org/). Randomly breaking public APIs is not an option. 19 | 20 | - **One pull request per feature** - If you want to do more than one thing, send multiple pull requests. 21 | 22 | - **Send coherent history** - Make sure each individual commit in your pull request is meaningful. If you had to make multiple intermediate commits while developing, please [squash them](http://www.git-scm.com/book/en/v2/Git-Tools-Rewriting-History#Changing-Multiple-Commit-Messages) before submitting. 23 | 24 | 25 | **Happy coding**! 26 | -------------------------------------------------------------------------------- /database/migrations/2019_01_03_114743_create_early_access_table.php: -------------------------------------------------------------------------------- 1 | table = config('early-access.services.database.table_name'); 20 | } 21 | 22 | /** 23 | * Run the migrations. 24 | * 25 | * @return void 26 | */ 27 | public function up() 28 | { 29 | Schema::create($this->table, function (Blueprint $table) { 30 | $table->increments('id'); 31 | $table->string('name')->nullable(); 32 | $table->string('email'); 33 | $table->dateTime('subscribed_at')->nullable(); 34 | $table->dateTime('verified_at')->nullable(); 35 | $table->softDeletes(); 36 | 37 | $table->unique(['email', 'deleted_at']); 38 | }); 39 | } 40 | 41 | /** 42 | * Reverse the migrations. 43 | * 44 | * @return void 45 | */ 46 | public function down() 47 | { 48 | Schema::dropIfExists($this->table); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License 2 | 3 | Copyright (c) 2018 CreativityKills. http://creativitykills.co 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 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /mix-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "/public/css/early-access.css": "/public/css/early-access.css" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "main": "tailwind.js", 3 | "scripts": { 4 | "dev": "npm run development", 5 | "development": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 6 | "watch": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --watch --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js", 7 | "hot": "cross-env NODE_ENV=development webpack-dev-server --inline --hot --config=node_modules/laravel-mix/setup/webpack.config.js", 8 | "prod": "npm run production", 9 | "production": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js" 10 | }, 11 | "devDependencies": { 12 | "cross-env": "^5.2.0", 13 | "laravel-mix": "^4.0.13", 14 | "laravel-mix-tailwind": "^0.1.0", 15 | "resolve-url-loader": "2.3.1", 16 | "sass": "^1.15.3", 17 | "sass-loader": "7.*", 18 | "tailwindcss": "^0.7.3", 19 | "vue-template-compiler": "^2.5.21" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 12 | 13 | 14 | ./tests/ 15 | ./tests/Feature 16 | 17 | 18 | 19 | 20 | src/ 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /public/svg/placeholder.svg: -------------------------------------------------------------------------------- 1 | social dashboard -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |

2 | Laravel Early Access logo 3 |

4 | 5 |

This package makes it easy to add early access mode to your existing application. This is useful for when you want to launch a product and need to gather the email addresses of people who want early access to the application.

6 | 7 |

8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 |

17 | 18 | > Take a look at [contributing.md](contributing.md) to see a to do list. 19 | 20 | >⚠️ This version supports Laravel 6 and above. Use version 1.x if you require Laravel 5 support. 21 | 22 | ## Installation 23 | 24 | #### Via Composer 25 | 26 | To install via composer, run the following command in the root of your Laravel application: 27 | 28 | ```bash 29 | $ composer require neo/laravel-early-access 30 | ``` 31 | 32 | Register the middleware `Neo\EarlyAccess\Http\Middleware\CheckForEarlyAccessMode` at the bottom of your `web` group 33 | middleware in `app/Http/Middleware/Kernel.php`. 34 | 35 | ```php 36 | [ 40 | \App\Http\Middleware\EncryptCookies::class, 41 | 42 | // [...] 43 | 44 | \Neo\EarlyAccess\Http\Middleware\CheckForEarlyAccessMode::class, 45 | ], 46 | 47 | // [...] 48 | ``` 49 | 50 | Next, add/update the `MAIL_*` keys in your `.env` file. Make sure to include `MAIL_FROM_*` keys as it is required when 51 | sending welcome or goodbye emails to subscribers. 52 | 53 | Also, you can optionally add the following environment variables to your `.env` file: 54 | 55 | ``` 56 | EARLY_ACCESS_ENABLED=true 57 | EARLY_ACCESS_URL="/early-access" 58 | EARLY_ACCESS_LOGIN_URL="/login" 59 | EARLY_ACCESS_TWITTER_HANDLE=NeoIghodaro 60 | EARLY_ACCESS_VIEW="early-access::index" 61 | EARLY_ACCESS_SERVICE_DRIVER=database 62 | EARLY_ACCESS_SERVICE_DB_TABLE=subscribers 63 | ``` 64 | 65 | Now migrate the required tables: 66 | 67 | ```shell 68 | $ php artisan migrate 69 | ``` 70 | 71 | And publish the required assets: 72 | 73 | ```shell 74 | $ php artisan vendor:publish --provider="Neo\EarlyAccess\EarlyAccessServiceProvider" 75 | ``` 76 | 77 | This will make the config, migrations, views, and assets available inside your applications directory so you can customise them. 78 | 79 | > **TIP:** You can append the `--tag=assets` flag to publish only the asset files which is required. Other available tag 80 | > values are: `config`, `translations`, `migrations`, `views` and `assets`. 81 | 82 | To activate early access, you can do either of the following: 83 | 84 | - Run the command `$ php artisan early-access --activate` 85 | - Set the `EARLY_ACCESS_ENABLED` to true in your `.env` file 86 | 87 | > **TIP:** Using the artisan command allows you to add IP addresses that are allowed to bypass the early access screen altogether. 88 | > 89 | > `$ php artisan early-access --allow=127.0.0.1 --allow=0.0.0.0` 90 | > 91 | > Note that logged in users will also bypass the early access screen. 92 | 93 | ## Configuration 94 | 95 | ```shell 96 | $ php artisan vendor:publish --provider="Neo\EarlyAccess\EarlyAccessServiceProvider" --tag=config 97 | ``` 98 | 99 | #### Configuration options 100 | 101 | - `enabled` - Sets whether the mode is enabled or not. In terms of priority, this is the last thing that is checked to 102 | see if the early access screen should be shown. Login status is checked, then artisan command status is checked, then 103 | this value is checked. `default: false` 104 | 105 | - `url` - The URL the early access screen will be shown at. The client will be redirected to this URL if they do not have 106 | access and the mode is enabled. You can set the value to `/` or any other existing routes. `default: /early-access` 107 | 108 | - `login_url` - The URL to your application's login page. This URL will automatically be bypassed even if early access 109 | mode is turned on. `default: /login` 110 | 111 | - `twitter_handle` - This is used when sending subscription confirmation via email. The user will have the option to tweet 112 | with the handle you specify tagged. 113 | 114 | - `view` - The early access screen view to be loaded. You can publish the views and customise it, or leave the default. 115 | `default: early-access::index`. 116 | 117 | - `service` - This is the subscription driver. See below for how to create your own driver. `default: database`. 118 | 119 | - `services.database.table_name` - The database table name. This is useful is you want to change the name of the database 120 | table. You need to do this before you run the migration though. `default: subscribers` 121 | 122 | - `notifications` - The default notification classes. You can use your own notification classes if you would like to 123 | change how users will be notified when they subscribe or unsubscribe. 124 | 125 | ## Using `/` or an existing route as the early access URL 126 | 127 | To use `/` or an existing route in your application as the early access URL, you need to do the following: 128 | 129 | First, register the service provider manually below the `App\Providers\RouteServiceProvider::class` in `config/app.php`. 130 | 131 | ```php 132 | [ 137 | 138 | // [...] 139 | 140 | App\Providers\RouteServiceProvider::class, 141 | Neo\EarlyAccess\EarlyAccessServiceProvider::class, 142 | 143 | // [...] 144 | 145 | ], 146 | 147 | // [...] 148 | ]; 149 | ``` 150 | 151 | Next, open your `composer.json` file and add the package in the `dont-discover` array: 152 | 153 | ``` 154 | // [...] 155 | 156 | "laravel": { 157 | "dont-discover": [ 158 | "neo/laravel-early-access" 159 | ] 160 | }, 161 | 162 | // [...] 163 | ``` 164 | 165 | Now run `composer dump-autoload -o` and it should work. 166 | 167 | ## Creating your own subscription service driver 168 | 169 | By default, there is a database driver that manages all the users. You can decide to create your own driver though for other 170 | services like Mailchimp etc. (If you do, please consider submitting a PR with the driver). 171 | 172 | To get started, you need to create a new class that implements the service provider class: 173 | 174 | ```php 175 | app->bind('early-access.mailchimp', function () { 216 | return new \App\Services\SubscriptionServices\MailchimpService; 217 | }); 218 | 219 | // [...] 220 | ``` 221 | 222 | > **NOTE:** Leave the `early-access.` namespace. It is required. Just append the name of your service to the namespace 223 | > as seen above. 224 | 225 | Next, go to your published configuration and change the service driver from `database` to `mailchimp`. That's all. 226 | 227 | ## Change log 228 | 229 | Please see the [changelog](changelog.md) for more information on what has changed recently. 230 | 231 | ## Testing 232 | 233 | ```bash 234 | $ composer test 235 | ``` 236 | 237 | ## Contributing 238 | 239 | Please see [contributing.md](contributing.md) for details and a todolist. 240 | 241 | ## Security 242 | 243 | If you discover any security related issues, please email author email instead of using the issue tracker. 244 | 245 | ## Credits 246 | 247 | - [Neo Ighodaro][link-author] 248 | - [Caneco](https://twitter.com/caneco) (for the logo) 249 | - [All Contributors][link-contributors] 250 | 251 | ## License 252 | 253 | Please see the [license file](license.md) for more information. 254 | 255 | [link-author]: https://github.com/neoighodaro 256 | [link-contributors]: ../../contributors 257 | -------------------------------------------------------------------------------- /resources/lang/en/common.php: -------------------------------------------------------------------------------- 1 | '© :year :name. All rights reserved.', 12 | 'early_access' => 'Early access', 13 | 'email_address' => 'Email address', 14 | 'login' => 'Log in', 15 | 16 | ]; 17 | -------------------------------------------------------------------------------- /resources/lang/en/mail.php: -------------------------------------------------------------------------------- 1 | [ 12 | 'subject' => 'Early access to :name', 13 | 'message' => [ 14 | 'intro' => 'Thanks for requesting early access to :name! We will email you with more information when we are ready to have you try it out.', 15 | 'share' => 'Want to share our project with the world? Please help us reach more people by sharing on Twitter: :url', 16 | 'unsubscribe' => 'Unsubscribe from emails', 17 | ], 18 | ], 19 | 'unsubscribed' => [ 20 | 'subject' => 'Early access to :name', 21 | 'message' => [ 22 | 'intro' => 'You have successfully unsubscribed from our early access list and will not be notified when the product is released.', 23 | ], 24 | ], 25 | 26 | ]; 27 | -------------------------------------------------------------------------------- /resources/lang/en/messages.php: -------------------------------------------------------------------------------- 1 | [ 11 | 'error' => 'An error occurred. Please try again.', 12 | 'success' => 'Thanks for subscribing. We will be in touch.', 13 | ], 14 | 'description' => 'Welcome to :name. You can customise this message in the language directory. You can start by reading the installation guide in the readme file.', 15 | 'get_early_access' => 'Get early access', 16 | 'no_spam' => 'No spam mail. We promise.', 17 | 'welcome' => 'Welcome to :name', 18 | 'twitter_share_text' => '.@:handle is coming soon. Request early access to be one of the first people to try it out :url #:handle', 19 | 20 | 'unsubscribe' => [ 21 | 'title' => 'Unsubscribe from :name', 22 | 'description' => 'We\'re sorry to se you go. Please confirm with your email.', 23 | 'button' => 'Unsubscribe', 24 | 'alerts' => [ 25 | 'error' => 'An error occurred. Please try again.', 26 | 'success' => 'You have been successfully unsubscribed.', 27 | ], 28 | 29 | ], 30 | ]; 31 | -------------------------------------------------------------------------------- /resources/sass/early-access.scss: -------------------------------------------------------------------------------- 1 | /** 2 | * This injects Tailwind's base styles, which is a combination of 3 | * Normalize.css and some additional base styles. 4 | * 5 | * You can see the styles here: 6 | * https://github.com/tailwindcss/tailwindcss/blob/master/css/preflight.css 7 | * 8 | * If using `postcss-import`, use this import instead: 9 | * 10 | * @import "tailwindcss/preflight"; 11 | */ 12 | @tailwind preflight; 13 | 14 | /** 15 | * This injects any component classes registered by plugins. 16 | * 17 | * If using `postcss-import`, use this import instead: 18 | * 19 | * @import "tailwindcss/components"; 20 | */ 21 | @tailwind components; 22 | 23 | /** 24 | * Here you would add any of your custom component classes; stuff that you'd 25 | * want loaded *before* the utilities so that the utilities could still 26 | * override them. 27 | * 28 | * Example: 29 | * 30 | * .btn { ... } 31 | * .form-input { ... } 32 | * 33 | * Or if using a preprocessor or `postcss-import`: 34 | * 35 | * @import "components/buttons"; 36 | * @import "components/forms"; 37 | */ 38 | 39 | /** 40 | * This injects all of Tailwind's utility classes, generated based on your 41 | * config file. 42 | * 43 | * If using `postcss-import`, use this import instead: 44 | * 45 | * @import "tailwindcss/utilities"; 46 | */ 47 | @tailwind utilities; 48 | 49 | /** 50 | * Here you would add any custom utilities you need that don't come out of the 51 | * box with Tailwind. 52 | * 53 | * Example : 54 | * 55 | * .bg-pattern-graph-paper { ... } 56 | * .skew-45 { ... } 57 | * 58 | * Or if using a preprocessor or `postcss-import`: 59 | * 60 | * @import "utilities/background-patterns"; 61 | * @import "utilities/skew-transforms"; 62 | */ 63 | 64 | body { 65 | @apply font-sans text-base text-grey-soft bg-cover bg-no-repeat; 66 | } 67 | -------------------------------------------------------------------------------- /resources/views/index.blade.php: -------------------------------------------------------------------------------- 1 | @extends('early-access::layouts.main') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |

8 | @lang('early-access::messages.welcome', ['name' => config('app.name')]) 9 |

10 |

11 | @lang('early-access::messages.description', ['name' => config('app.name')]) 12 |

13 |
14 |
15 | @if (session('success')) 16 |
18 | @lang('early-access::messages.alerts.success') 19 |
20 | @endif 21 | 22 | @if ($errors->has('email') or session('error')) 23 |
25 | {{ $errors->has('email') ? $errors->first('email') : trans('early-access::messages.alerts.error') }} 26 |
27 | @endif 28 | 29 |
30 | @csrf 31 |
32 | 39 | 45 |
46 |
47 |
48 | 49 | @lang('early-access::messages.no_spam') 50 | 51 |
52 |
53 |
54 |
55 | 56 |
57 |
58 | @endsection 59 | -------------------------------------------------------------------------------- /resources/views/layouts/main.blade.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | {{ $page_title ?? trans('early-access::common.early_access') }} | {{ config('app.name') }} 8 | 9 | 10 | 11 | 12 | 13 |
14 | 31 |
32 | @yield('content') 33 |
34 | 39 |
40 | 41 | 42 | -------------------------------------------------------------------------------- /resources/views/unsubscribe.blade.php: -------------------------------------------------------------------------------- 1 | @extends('early-access::layouts.main') 2 | 3 | @section('content') 4 |
5 |
6 |
7 |

8 | @lang('early-access::messages.unsubscribe.title', ['name' => config('app.name')]) 9 |

10 |

11 | @lang('early-access::messages.unsubscribe.description', ['name' => config('app.name')]) 12 |

13 |
14 |
15 | @if (session('success')) 16 |
18 | @lang('early-access::messages.unsubscribe.alerts.success') 19 |
20 | @endif 21 | 22 | @if ($errors->has('email') or session('error')) 23 |
25 | {{ $errors->has('email') ? $errors->first('email') : trans('early-access::messages.unsubscribe.alerts.error') }} 26 |
27 | @endif 28 | 29 |
30 | @method('delete') 31 | @csrf 32 |
33 | 40 | 46 |
47 |
48 |
49 | 50 | @lang('early-access::messages.no_spam') 51 | 52 |
53 |
54 |
55 |
56 | 57 |
58 |
59 | @endsection 60 | -------------------------------------------------------------------------------- /routes/web.php: -------------------------------------------------------------------------------- 1 | name('early-access.share'); 19 | 20 | Route::get($basePath, 'EarlyAccessController@index') 21 | ->name('early-access.index'); 22 | 23 | Route::post($basePath, 'EarlyAccessController@subscribe') 24 | ->name('early-access.subscribe'); 25 | 26 | Route::get("{$basePath}/unsubscribe", 'EarlyAccessController@unsubscribe') 27 | ->name('early-access.unsubscribe'); 28 | 29 | Route::delete("{$basePath}/unsubscribe", 'EarlyAccessController@verifyUnsubscription') 30 | ->name('early-access.verify-unsubscription'); 31 | } 32 | -------------------------------------------------------------------------------- /src/Console/Commands/EarlyAccess.php: -------------------------------------------------------------------------------- 1 | storage = $storage; 47 | } 48 | 49 | /** 50 | * Execute the console command. 51 | * 52 | * @return mixed 53 | */ 54 | public function handle() 55 | { 56 | if ($this->argument('status')) { 57 | return $this->getStatus(); 58 | } 59 | 60 | $activate = $this->option('activate'); 61 | 62 | $deactivate = $this->option('deactivate'); 63 | 64 | if (! $activate && ! $deactivate) { 65 | $intent = $this->option('allow') 66 | ? 'activate' 67 | : $this->choice('What do you want to do', ['1' => 'activate', '2' => 'deactivate']); 68 | 69 | ${$intent} = true; 70 | } 71 | 72 | return $activate ? $this->activate() : $this->deactivate(); 73 | } 74 | 75 | /** 76 | * Deactivate early access. 77 | */ 78 | protected function activate() 79 | { 80 | $this->saveBeacon($this->option('allow') ?? []) 81 | ? $this->comment('Early access activated.') 82 | : $this->error('Unable to activate early access.'); 83 | } 84 | 85 | /** 86 | * Activate early access. 87 | */ 88 | protected function deactivate() 89 | { 90 | $this->deleteBeacon() 91 | ? $this->comment('Early access deactivated.') 92 | : $this->comment('Could not deactivate. Possibly not active.'); 93 | 94 | if (config('early-access.enabled')) { 95 | $this->comment('Set EARLY_ACCESS_ENABLED to false in your .env to fully deactivate it.'); 96 | } 97 | } 98 | 99 | /** 100 | * Gets the status of the early access mode. 101 | */ 102 | protected function getStatus() 103 | { 104 | $allowedNetworks = with(($data = $this->getBeaconDetails()), function ($details) { 105 | return (isset($details['allowed']) && count($details['allowed'])) 106 | ? implode(', ', $details['allowed']) 107 | : 'none'; 108 | }); 109 | 110 | $this->info($data ? "Active. Allowed networks: {$allowedNetworks}" : 'Not active'); 111 | } 112 | } 113 | -------------------------------------------------------------------------------- /src/Contracts/Subscription/SubscriptionProvider.php: -------------------------------------------------------------------------------- 1 | auth = $auth; 31 | 32 | $this->storage = $storage; 33 | } 34 | 35 | /** 36 | * Checks if early access is enabled or not. 37 | * 38 | * @return bool 39 | */ 40 | public function isEnabled(): bool 41 | { 42 | if ($this->auth->user()) { 43 | return false; 44 | } 45 | 46 | return (bool) $this->getBeaconDetails() ?: config('early-access.enabled'); 47 | } 48 | 49 | /** 50 | * Returns a list of allowed networks. 51 | * 52 | * @return array 53 | */ 54 | public function allowedNetworks(): array 55 | { 56 | $data = $this->getBeaconDetails() ?? []; 57 | 58 | return array_get($data, 'allowed', []); 59 | } 60 | 61 | /** 62 | * Adds a network to the list of allowed networks. 63 | * 64 | * @param array $networks 65 | */ 66 | public function addAllowedNetworksToBeacon(array $networks) 67 | { 68 | if ($data = $this->getBeaconDetails()) { 69 | if (! empty($networks)) { 70 | array_push($data['allowed'], ...$networks); 71 | } 72 | 73 | $data['allowed'] = array_unique($data['allowed']); 74 | 75 | return $this->saveBeaconFileWithData($data); 76 | } 77 | } 78 | 79 | /** 80 | * Save the beacon file. 81 | * 82 | * @param array $allowed 83 | * @return bool 84 | */ 85 | public function saveBeacon(array $allowed = []): bool 86 | { 87 | if ($this->getBeaconDetails()) { 88 | return (bool) $this->addAllowedNetworksToBeacon($allowed); 89 | } 90 | 91 | return $this->saveBeaconFileWithData([ 92 | 'allowed' => array_unique($allowed), 93 | 'time' => $this->currentTime(), 94 | ]); 95 | } 96 | 97 | /** 98 | * Deletes the beacon file. 99 | * 100 | * @return bool 101 | */ 102 | public function deleteBeacon(): bool 103 | { 104 | return $this->storage->delete('early-access'); 105 | } 106 | 107 | /** 108 | * Get the beacon file details. 109 | * 110 | * @return false|array 111 | */ 112 | public function getBeaconDetails() 113 | { 114 | if (! $this->storage->exists('early-access')) { 115 | return false; 116 | } 117 | 118 | return json_decode($this->storage->get('early-access'), true); 119 | } 120 | 121 | /** 122 | * Saves the beacon file. 123 | * 124 | * @param array $data 125 | * @return bool 126 | */ 127 | private function saveBeaconFileWithData(array $data): bool 128 | { 129 | return $this->storage->put('early-access', json_encode($data, JSON_PRETTY_PRINT)); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/EarlyAccessServiceProvider.php: -------------------------------------------------------------------------------- 1 | loadMigrationsFrom(__DIR__ . '/../database/migrations'); 25 | 26 | $this->loadViewsFrom(__DIR__ . '/../resources/views', 'early-access'); 27 | 28 | $this->loadTranslationsFrom(__DIR__ . '/../resources/lang', 'early-access'); 29 | 30 | $this->app['router']->namespace('Neo\\EarlyAccess\\Http\\Controllers') 31 | ->middleware(['web']) 32 | ->group(function () { 33 | $this->loadRoutesFrom(__DIR__ . '/../routes/web.php'); 34 | }); 35 | 36 | $this->registerEventListeners(); 37 | 38 | if ($this->app->runningInConsole()) { 39 | $this->bootForConsole(); 40 | } 41 | } 42 | 43 | /** 44 | * Register any package services. 45 | * 46 | * @return void 47 | */ 48 | public function register() 49 | { 50 | $this->mergeConfigFrom(__DIR__ . '/../config/early-access.php', 'early-access'); 51 | 52 | $this->app->singleton('early-access', function ($app) { 53 | return new EarlyAccess($app['filesystem.disk'], $app['auth.driver']); 54 | }); 55 | 56 | $this->app->bind('early-access.database', function () { 57 | return new DatabaseService(new EloquentRepository); 58 | }); 59 | 60 | $this->app->bind(SubscriptionProvider::class, function () { 61 | return app('early-access.' . config('early-access.service')); 62 | }); 63 | } 64 | 65 | /** 66 | * Get the services provided by the provider. 67 | * 68 | * @return array 69 | */ 70 | public function provides() 71 | { 72 | return ['early-access']; 73 | } 74 | 75 | /** 76 | * Console-specific booting. 77 | * 78 | * @return void 79 | */ 80 | protected function bootForConsole() 81 | { 82 | $this->publishes([ 83 | __DIR__ . '/../config/early-access.php' => config_path('early-access.php'), 84 | ], 'config'); 85 | 86 | $this->publishes([ 87 | __DIR__ . '/../resources/views' => base_path('resources/views/vendor/early-access'), 88 | ], 'views'); 89 | 90 | $this->publishes([ 91 | __DIR__ . '/../public' => public_path('vendor/early-access'), 92 | ], 'assets'); 93 | 94 | $this->publishes([ 95 | __DIR__ . '/../database/migrations/' => database_path('migrations'), 96 | ], 'migrations'); 97 | 98 | $this->publishes([ 99 | __DIR__ . '/../resources/lang' => resource_path('lang/vendor/early-access'), 100 | ], 'translations'); 101 | 102 | $this->commands([ 103 | Commands\EarlyAccess::class, 104 | ]); 105 | } 106 | 107 | /** 108 | * Register the packages event listeners. 109 | */ 110 | protected function registerEventListeners() 111 | { 112 | $this->app['events']->listen(UserSubscribed::class, SendSubscriptionNotification::class); 113 | 114 | $this->app['events']->listen(UserUnsubscribed::class, SendUnsubscribeNotification::class); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/Events/UserSubscribed.php: -------------------------------------------------------------------------------- 1 | subscriber = $subscriber; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Events/UserUnsubscribed.php: -------------------------------------------------------------------------------- 1 | subscriber = $subscriber; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/Facades/EarlyAccess.php: -------------------------------------------------------------------------------- 1 | getBeaconDetails(); 22 | 23 | return view(config('early-access.view'), compact('details')); 24 | } 25 | 26 | /** 27 | * Subscribe. 28 | * 29 | * @param \Illuminate\Http\Request $request 30 | * @return \Illuminate\Http\JsonResponse 31 | */ 32 | public function subscribe(Request $request) 33 | { 34 | $data = $request->validate([ 35 | 'email' => 'required|email', 36 | 'name' => 'string|between:3,100', 37 | ]); 38 | 39 | if (!$subscriber = Subscriber::make()->findByEmail($data['email'])) { 40 | $subscriber = Subscriber::make($data); 41 | $subscriber->subscribe(); 42 | } 43 | 44 | return redirect()->route('early-access.index')->withSuccess(true); 45 | } 46 | 47 | /** 48 | * Show the early access page. 49 | * 50 | * @return \Illuminate\Http\Response 51 | */ 52 | public function unsubscribe() 53 | { 54 | $details = $this->getBeaconDetails(); 55 | 56 | return view('early-access::unsubscribe', compact('details')); 57 | } 58 | 59 | /** 60 | * Unsubscribe. 61 | * 62 | * @param \Neo\EarlyAccess\Subscriber $subscriber 63 | * @param \Illuminate\Http\Request $request 64 | * @return \Illuminate\Http\JsonResponse 65 | */ 66 | public function verifyUnsubscription(Subscriber $subscriber, Request $request) 67 | { 68 | $data = $request->validate(['email' => 'required|email']); 69 | 70 | $unsubscribed = with($subscriber->findByEmail($data['email']), function ($user) { 71 | return $user ? $user->unsubscribe() : false; 72 | }); 73 | 74 | return redirect()->route('early-access.unsubscribe')->with([ 75 | ($unsubscribed ? 'success' : 'error') => true, 76 | ]); 77 | } 78 | 79 | /** 80 | * Share to twitter. 81 | * 82 | * @return \Illuminate\Http\RedirectResponse 83 | */ 84 | public function shareOnTwitter() 85 | { 86 | if ($handle = config('early-access.twitter_handle')) { 87 | $shareText = rawurlencode( 88 | trans('early-access::messages.twitter_share_text', [ 89 | 'handle' => $handle, 90 | 'url' => route('early-access.index'), 91 | ]) 92 | ); 93 | 94 | $url = "https://twitter.com/intent/tweet?text={$shareText}&related={$handle}&handle={$handle}"; 95 | } 96 | 97 | return redirect($url ?? route('early-access.index')); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/Http/Middleware/CheckForEarlyAccessMode.php: -------------------------------------------------------------------------------- 1 | baseUrl = config('early-access.url'); 30 | } 31 | 32 | /** 33 | * Handle the incoming request. 34 | * 35 | * @param \Illuminate\Http\Request $request 36 | * @param \Closure $next 37 | * @return mixed 38 | */ 39 | public function handle(Request $request, Closure $next) 40 | { 41 | if (EarlyAccess::isEnabled()) { 42 | $data = EarlyAccess::getBeaconDetails(); 43 | 44 | if (isset($data['allowed']) && IpUtils::checkIp($request->ip(), (array) $data['allowed'])) { 45 | return $next($request); 46 | } 47 | 48 | if ($this->inExceptArray($request)) { 49 | return $next($request); 50 | } 51 | 52 | return redirect(config('early-access.url')); 53 | } 54 | 55 | return $next($request); 56 | } 57 | 58 | /** 59 | * Determine if the request has a URI that should be accessible in maintenance mode. 60 | * 61 | * @param \Illuminate\Http\Request $request 62 | * @return bool 63 | */ 64 | protected function inExceptArray($request) 65 | { 66 | $defaultExceptions = [ 67 | $this->baseUrl, 68 | $this->baseUrl . '/*', 69 | config('early-access.login_url'), 70 | ]; 71 | 72 | $defaultExceptions = array_filter($defaultExceptions, function ($item) { 73 | return trim($item, '/') !== '*'; 74 | }); 75 | 76 | array_push($this->except, ...$defaultExceptions); 77 | 78 | foreach (array_unique($this->except) as $except) { 79 | if ($except !== '/') { 80 | $except = trim($except, '/'); 81 | } 82 | 83 | if ($request->fullUrlIs($except) || $request->is($except)) { 84 | return true; 85 | } 86 | } 87 | 88 | return false; 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /src/Listeners/SendSubscriptionNotification.php: -------------------------------------------------------------------------------- 1 | subscriber->notify( 20 | new $notifierClass($event->subscriber) 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Listeners/SendUnsubscribeNotification.php: -------------------------------------------------------------------------------- 1 | subscriber->notify( 20 | new $notifierClass($event->subscriber) 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Notifications/UserSubscribed.php: -------------------------------------------------------------------------------- 1 | subscriber = $subscriber; 28 | } 29 | 30 | /** 31 | * Get the notification's delivery channels. 32 | * 33 | * @return array 34 | */ 35 | public function via() 36 | { 37 | return ['mail']; 38 | } 39 | 40 | /** 41 | * Get the mail representation of the notification. 42 | * 43 | * @return \Illuminate\Notifications\Messages\MailMessage 44 | */ 45 | public function toMail() 46 | { 47 | return (new MailMessage) 48 | ->subject(trans('early-access::mail.subscribed.subject', ['name' => config('app.name')])) 49 | ->line(trans('early-access::mail.subscribed.message.intro', ['name' => config('app.name')])) 50 | ->line(trans('early-access::mail.subscribed.message.share', ['url' => route('early-access.share')])) 51 | ->action(trans('early-access::mail.subscribed.message.unsubscribe'), route('early-access.unsubscribe')); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Notifications/UserUnsubscribed.php: -------------------------------------------------------------------------------- 1 | subscriber = $subscriber; 28 | } 29 | 30 | /** 31 | * Get the notification's delivery channels. 32 | * 33 | * @return array 34 | */ 35 | public function via() 36 | { 37 | return ['mail']; 38 | } 39 | 40 | /** 41 | * Get the mail representation of the notification. 42 | * 43 | * @return \Illuminate\Notifications\Messages\MailMessage 44 | */ 45 | public function toMail() 46 | { 47 | return (new MailMessage) 48 | ->subject(trans('early-access::mail.unsubscribed.subject', ['name' => config('app.name')])) 49 | ->line(trans('early-access::mail.unsubscribed.message.intro')); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Subscriber.php: -------------------------------------------------------------------------------- 1 | subscriber = $subscriber; 44 | } 45 | 46 | /** 47 | * Shortcut for instantiating the object. 48 | * 49 | * @param array $attributes 50 | * @return \Neo\EarlyAccess\Subscriber 51 | */ 52 | public static function make(array $attributes = []) 53 | { 54 | /** @var self $instance */ 55 | $instance = resolve(self::class); 56 | $instance->fillAttributes($attributes); 57 | 58 | return $instance; 59 | } 60 | 61 | /** 62 | * Subscribe the user. 63 | * 64 | * @return bool 65 | */ 66 | public function subscribe(): bool 67 | { 68 | if ($this->subscribed()) { 69 | return true; 70 | } 71 | 72 | $subscribed = $this->subscriber->add($this->email, $this->name); 73 | 74 | $this->exists = $subscribed; 75 | 76 | $this->subscribed_at = (string) now(); 77 | 78 | event(new UserSubscribed($this)); 79 | 80 | return $subscribed; 81 | } 82 | 83 | /** 84 | * Unsubscribe the user. 85 | * 86 | * @return bool 87 | */ 88 | public function unsubscribe(): bool 89 | { 90 | $unsubscribed = $this->subscriber->remove($this->email); 91 | 92 | if ($this->exists && $unsubscribed) { 93 | event(new UserUnsubscribed($this)); 94 | } 95 | 96 | $this->exists = $unsubscribed === false; 97 | 98 | $this->subscribed_at = null; 99 | 100 | return $unsubscribed; 101 | } 102 | 103 | /** 104 | * Checks if user is subscribed. 105 | * 106 | * @return bool 107 | */ 108 | public function subscribed(): bool 109 | { 110 | return $this->exists; 111 | } 112 | 113 | /** 114 | * Set the subscription status. 115 | * 116 | * @param bool $subscribed 117 | * @return $this 118 | */ 119 | public function setSubscribed(bool $subscribed = true) 120 | { 121 | $this->exists = $subscribed; 122 | 123 | return $this; 124 | } 125 | 126 | /** 127 | * Find a user by email. 128 | * 129 | * @param string|null $email 130 | * @return \Neo\EarlyAccess\Subscriber|null 131 | */ 132 | public function findByEmail(string $email = null): ?self 133 | { 134 | if (! $email and ! $this->email) { 135 | return null; 136 | } 137 | 138 | if ($this->subscribed() and ! $email) { 139 | return $this; 140 | } 141 | 142 | $subscriber = $this->subscriber->findByEmail($email ?? $this->email); 143 | 144 | return $subscriber ? $subscriber->setSubscribed() : null; 145 | } 146 | 147 | /** 148 | * Verify the subscriber. 149 | * 150 | * @return bool 151 | */ 152 | public function verify() 153 | { 154 | $this->verified = $this->subscriber->verify($this->email); 155 | 156 | if (! $this->exists and $this->verified) { 157 | $this->exists = true; 158 | } 159 | 160 | return $this->verified; 161 | } 162 | 163 | /** 164 | * Get the instance as an array. 165 | * 166 | * @return array 167 | */ 168 | public function toArray() 169 | { 170 | return $this->attributes; 171 | } 172 | 173 | /** 174 | * @return mixed 175 | */ 176 | public function getKey() 177 | { 178 | return $this->email; 179 | } 180 | 181 | /** 182 | * @param $name 183 | * @return mixed 184 | */ 185 | public function __get($name) 186 | { 187 | return $this->attributes[$name] ?? null; 188 | } 189 | 190 | /** 191 | * @param $name 192 | * @param $value 193 | */ 194 | public function __set($name, $value) 195 | { 196 | if (array_key_exists($name, $this->attributes)) { 197 | $this->attributes[$name] = $value; 198 | } 199 | } 200 | 201 | public function fillAttributes(array $attributes): self 202 | { 203 | $this->attributes = $attributes; 204 | return $this; 205 | } 206 | } 207 | -------------------------------------------------------------------------------- /src/SubscriptionServices/DatabaseService.php: -------------------------------------------------------------------------------- 1 | repository = $repository; 24 | } 25 | 26 | /** 27 | * Adds a new email to the subscribers list. 28 | * 29 | * @param string $email 30 | * @param string|null $name 31 | * @return bool 32 | */ 33 | public function add(string $email, string $name = null): bool 34 | { 35 | return (bool) $this->repository->addSubscriber($email, $name); 36 | } 37 | 38 | /** 39 | * Removes an email from the subscribers list. 40 | * 41 | * @param string $email 42 | * @return bool 43 | */ 44 | public function remove(string $email): bool 45 | { 46 | return $this->repository->removeSubscriber($email); 47 | } 48 | 49 | /** 50 | * Verifies a subscribers email address. 51 | * 52 | * @param string $email 53 | * @return bool 54 | */ 55 | public function verify(string $email): bool 56 | { 57 | return $this->repository->verify($email); 58 | } 59 | 60 | /** 61 | * Find a subscriber using their email address. 62 | * 63 | * @param string $email 64 | * @return \Neo\EarlyAccess\Subscriber|false 65 | */ 66 | public function findByEmail(string $email) 67 | { 68 | return with($this->repository->findByEmail($email), function ($user) { 69 | return $user ? Subscriber::make($user) : false; 70 | }); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/SubscriptionServices/Repositories/Database/EloquentRepository.php: -------------------------------------------------------------------------------- 1 | setTable(config('early-access.services.database.table_name')); 39 | 40 | parent::__construct($attributes); 41 | } 42 | 43 | /** 44 | * Adds a new subscriber to the repository. 45 | * 46 | * @param string $email 47 | * @param string|null $name 48 | * @return bool 49 | */ 50 | public function addSubscriber(string $email, ?string $name): bool 51 | { 52 | return (bool) static::firstOrCreate( 53 | ['email' => $email], 54 | ['name' => $name, 'subscribed_at' => now()] 55 | ); 56 | } 57 | 58 | /** 59 | * Removes a subscriber. 60 | * 61 | * @param string $email 62 | * @return bool 63 | */ 64 | public function removeSubscriber(string $email): bool 65 | { 66 | return with(static::byEmail($email)->first(), function (?self $subscriber) { 67 | return $subscriber ? $subscriber->delete() : false; 68 | }); 69 | } 70 | 71 | /** 72 | * Verify the subscriber. 73 | * 74 | * @param string $email 75 | * @return bool 76 | */ 77 | public function verify(string $email): bool 78 | { 79 | return with(static::byEmail($email)->first(), function (?self $subscriber) { 80 | return $subscriber ? $subscriber->update(['verified_at' => now()]) : false; 81 | }); 82 | } 83 | 84 | /** 85 | * Find subscriber by email. 86 | * 87 | * @param string $email 88 | * @return array|false 89 | */ 90 | public function findByEmail(string $email) 91 | { 92 | return with(static::byEmail($email)->first(), function (?self $subscriber) { 93 | return $subscriber ? $subscriber->toArray() : false; 94 | }); 95 | } 96 | 97 | /** 98 | * Query by email. 99 | * 100 | * @param \Illuminate\Database\Eloquent\Builder $query 101 | * @param string $email 102 | * @return \Illuminate\Database\Eloquent\Builder 103 | */ 104 | public function scopeByEmail($query, string $email) 105 | { 106 | return $query->where('email', $email); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Traits/InteractsWithEarlyAccess.php: -------------------------------------------------------------------------------- 1 | artisan('early-access', ['--activate' => true]); 17 | 18 | $this->assertTrue($this->isEarlyAccessEnabled()); 19 | } 20 | 21 | /** @test */ 22 | public function deactivates_when_artisan_command_is_called() 23 | { 24 | $this->artisan('early-access', ['--deactivate' => true]); 25 | 26 | $this->assertFalse($this->isEarlyAccessEnabled()); 27 | } 28 | 29 | /** @test */ 30 | public function adds_current_ip_to_allowed_list() 31 | { 32 | $this->artisan('early-access', [ 33 | '--activate' => true, 34 | '--allow' => ['127.0.0.1', '1.1.1.1'], 35 | ]); 36 | 37 | $this->assertArraySubset(['127.0.0.1', '1.1.1.1'], $this->getAllowedNetworks()); 38 | } 39 | 40 | /** @test */ 41 | public function can_check_early_access_status() 42 | { 43 | $this->artisan('early-access', ['--deactivate' => true]); 44 | 45 | $this->artisan('early-access', ['status' => true]) 46 | ->expectsOutput('Not active'); 47 | 48 | $this->artisan('early-access', ['--activate' => true]); 49 | 50 | $this->artisan('early-access', ['status' => true]) 51 | ->expectsOutput('Active. Allowed networks: none'); 52 | 53 | $this->artisan('early-access', ['--deactivate' => true]); 54 | $this->artisan('early-access', ['--allow' => ['127.0.0.1', '1.1.1.1']]); 55 | 56 | $this->artisan('early-access', ['status' => true]) 57 | ->expectsOutput('Active. Allowed networks: 127.0.0.1, 1.1.1.1'); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/TestCase.php: -------------------------------------------------------------------------------- 1 | resetTestingStage(); 33 | } 34 | 35 | /** 36 | * Clean up the testing environment before the next test. 37 | * 38 | * @return void 39 | */ 40 | protected function tearDown(): void 41 | { 42 | $this->resetTestingStage(); 43 | 44 | parent::tearDown(); 45 | } 46 | 47 | /** 48 | * @param $abstract 49 | * @param \Closure|null $mock 50 | * @return \Mockery\MockInterface 51 | */ 52 | protected function mock($abstract, ?Closure $mock = null) 53 | { 54 | $this->mock = Mockery::mock($abstract); 55 | 56 | $this->app->instance($abstract, $this->mock); 57 | 58 | return $this->mock; 59 | } 60 | 61 | /** 62 | * @param string|null $name 63 | * @param string $email 64 | * @param bool $verified 65 | * @return \Neo\EarlyAccess\Subscriber 66 | */ 67 | protected function createSubscriberInstance(string $name = null, string $email = null, bool $verified = true) 68 | { 69 | return Subscriber::make([ 70 | 'name' => $name ?? 'John Doe', 71 | 'email' => $email ?? 'john@doe.com', 72 | 'subscribed_at' => (string) now(), 73 | 'verified' => $verified, 74 | ]); 75 | } 76 | 77 | /** 78 | * Activates early access. 79 | */ 80 | protected function activateEarlyAccessConfiguration() 81 | { 82 | app('config')->set('early-access.enabled', true); 83 | } 84 | 85 | /** 86 | * Deactivates early access. 87 | */ 88 | protected function deactivateEarlyAccessConfiguration() 89 | { 90 | app('config')->set('early-access.enabled', false); 91 | } 92 | 93 | /** 94 | * Resets the stage so we can carry out tests. 95 | */ 96 | protected function resetTestingStage() 97 | { 98 | $this->deactivateEarlyAccessConfiguration(); 99 | 100 | Storage::disk('local')->delete('early-access'); 101 | } 102 | 103 | /** 104 | * @param $uri 105 | * @param array $headers 106 | * @return \Illuminate\Foundation\Testing\TestResponse 107 | */ 108 | public function get($uri, array $headers = []) 109 | { 110 | return parent::get($uri, $headers); 111 | } 112 | 113 | /** 114 | * Get package providers. 115 | * 116 | * @param \Illuminate\Foundation\Application $app 117 | * 118 | * @return array 119 | */ 120 | protected function getPackageProviders($app) 121 | { 122 | return [ 123 | EarlyAccessServiceProvider::class, 124 | ]; 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /tests/Unit/EarlyAccessTest.php: -------------------------------------------------------------------------------- 1 | activateEarlyAccessConfiguration(); 17 | 18 | $this->assertTrue($this->isEarlyAccessEnabled()); 19 | 20 | $this->deactivateEarlyAccessConfiguration(); 21 | 22 | $this->assertFalse($this->isEarlyAccessEnabled()); 23 | } 24 | 25 | /** @test */ 26 | public function can_save_and_delete_beacon_file() 27 | { 28 | $this->saveBeacon(); 29 | 30 | $this->assertTrue(Storage::disk('local')->exists('early-access')); 31 | 32 | $this->deleteBeacon(); 33 | 34 | $this->assertFalse(Storage::disk('local')->exists('early-access')); 35 | } 36 | 37 | /** @test */ 38 | public function can_save_a_list_of_networks_to_beacon_file() 39 | { 40 | $this->saveBeacon(['127.0.0.1']); 41 | 42 | $this->addAllowedNetworksToBeacon(['0.0.0.0', '1.1.1.1']); 43 | 44 | $this->assertArraySubset(['127.0.0.1', '0.0.0.0', '1.1.1.1'], $this->getAllowedNetworks()); 45 | } 46 | 47 | /** @test */ 48 | public function can_get_beacon_details() 49 | { 50 | $this->assertFalse($this->getBeaconDetails()); 51 | 52 | $this->saveBeacon(['127.0.0.1']); 53 | 54 | $details = $this->getBeaconDetails(); 55 | 56 | $this->assertArrayHasKey('time', $details); 57 | 58 | $this->assertArraySubset(['allowed' => ['127.0.0.1']], $details); 59 | } 60 | 61 | /** @test */ 62 | public function adding_multiple_allowed_appends_to_allowed_list() 63 | { 64 | $this->saveBeacon(['127.0.0.1']); 65 | 66 | $this->saveBeacon(['1.1.1.1']); 67 | 68 | $this->assertArraySubset(['127.0.0.1', '1.1.1.1'], $this->getAllowedNetworks()); 69 | } 70 | 71 | /** @test */ 72 | public function removes_duplicate_networks_from_list() 73 | { 74 | $this->saveBeacon(['127.0.0.1', '0.0.0.0', '0.0.0.0']); 75 | 76 | $this->saveBeacon(['1.1.1.1']); 77 | 78 | $this->saveBeacon(['1.1.1.1']); 79 | 80 | $this->assertArraySubset(['127.0.0.1', '0.0.0.0', '1.1.1.1'], $this->getAllowedNetworks()); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /tests/Unit/SubscribeTest.php: -------------------------------------------------------------------------------- 1 | mock(SubscriptionProvider::class); 22 | } 23 | 24 | /** 25 | * Clean up the testing environment before the next test. 26 | * 27 | * @return void 28 | */ 29 | public function tearDown(): void 30 | { 31 | Mockery::close(); 32 | } 33 | 34 | /** @test */ 35 | public function can_subscribe_and_unsubscribe_to_provider_using_email() 36 | { 37 | $subscriber = Subscriber::make(['email' => 'john@doe.com']); 38 | 39 | $this->mock->shouldReceive(['add' => true, 'remove' => true])->once()->andReturnTrue(); 40 | 41 | $this->assertTrue($subscriber->subscribe()); 42 | 43 | $this->assertTrue($subscriber->unsubscribe()); 44 | } 45 | 46 | /** @test */ 47 | public function can_verify_email_address() 48 | { 49 | $this->mock->shouldReceive('verify')->once()->andReturnTrue(); 50 | 51 | $this->assertTrue(Subscriber::make(['email' => 'neo@ck.co'])->verify()); 52 | } 53 | 54 | /** @test */ 55 | public function can_find_subscriber_by_email_address() 56 | { 57 | $subscriber = $this->createSubscriberInstance('John', 'john@doe.com'); 58 | 59 | $this->mock->shouldReceive('findByEmail')->with('john@doe.com')->once()->andReturn($subscriber); 60 | 61 | $this->assertEquals($subscriber, $subscriber->findByEmail()); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /tests/Unit/SubscriberTest.php: -------------------------------------------------------------------------------- 1 | mock(SubscriptionProvider::class); 30 | 31 | $this->subscriber = $this->createSubscriberInstance(); 32 | } 33 | 34 | /** @test */ 35 | public function can_get_subscriber_details() 36 | { 37 | $this->assertEquals('John Doe', $this->subscriber->name); 38 | 39 | $this->assertEquals('john@doe.com', $this->subscriber->email); 40 | 41 | $this->assertIsString($this->subscriber->subscribed_at); 42 | 43 | $this->assertTrue($this->subscriber->verified); 44 | } 45 | 46 | /** @test */ 47 | public function can_subscribe_and_unsubscribe_from_subscriber_instance() 48 | { 49 | $this->mock->shouldReceive(['add' => true, 'remove' => true]); 50 | 51 | $this->assertTrue($this->subscriber->subscribe()); 52 | 53 | $this->assertTrue($this->subscriber->subscribed()); 54 | 55 | $this->assertTrue($this->subscriber->unsubscribe()); 56 | 57 | $this->assertFalse($this->subscriber->subscribed()); 58 | } 59 | 60 | /** @test */ 61 | public function event_is_fired_on_subscribe() 62 | { 63 | $this->mock->shouldReceive(['add' => true]); 64 | 65 | $this->subscriber->subscribe(); 66 | 67 | Event::assertDispatched(UserSubscribed::class, function ($e) { 68 | return $e->subscriber->email === $this->subscriber->email; 69 | }); 70 | } 71 | 72 | /** @test */ 73 | public function event_is_fired_on_unsubscribe() 74 | { 75 | $this->mock->shouldReceive(['remove' => true]); 76 | 77 | $this->subscriber->setSubscribed()->unsubscribe(); 78 | 79 | Event::assertDispatched(UserUnsubscribed::class, function ($e) { 80 | return $e->subscriber->email === $this->subscriber->email; 81 | }); 82 | } 83 | 84 | /** @test */ 85 | public function can_set_subscribed_status() 86 | { 87 | $this->mock->shouldReceive(['add' => true, 'remove' => true]); 88 | 89 | $this->subscriber->subscribe(); 90 | 91 | $this->assertTrue($this->subscriber->subscribed()); 92 | 93 | $this->subscriber->setSubscribed(false); 94 | 95 | $this->assertFalse($this->subscriber->subscribed()); 96 | } 97 | 98 | /** @test */ 99 | public function can_find_by_email() 100 | { 101 | // 102 | // Find user by supplying email using empty Subscriber object 103 | // 104 | 105 | $this->mock->shouldReceive('findByEmail') 106 | ->with($this->subscriber->email) 107 | ->once() 108 | ->andReturn($this->subscriber); 109 | 110 | $this->assertEquals($this->subscriber, Subscriber::make()->findByEmail($this->subscriber->email)); 111 | 112 | // 113 | // Find user by not supplying email using empty Subscriber object 114 | // 115 | 116 | $this->mock->shouldReceive('findByEmail')->times(0); 117 | 118 | $this->assertNull(Subscriber::make()->findByEmail()); 119 | 120 | // 121 | // Find user by supplying email using loaded Subscriber object 122 | // 123 | 124 | $this->mock->shouldReceive('findByEmail') 125 | ->with($this->subscriber->email) 126 | ->times(2) 127 | ->andReturn($this->subscriber); 128 | 129 | $subscriber = $this->createSubscriberInstance('Neo Ighodaro', 'neo@ck.co', true); 130 | 131 | $subscriber->setSubscribed(false); 132 | 133 | $this->assertEquals($this->subscriber, $subscriber->findByEmail($this->subscriber->email)); 134 | 135 | $subscriber->setSubscribed(true); 136 | 137 | $this->assertEquals($this->subscriber, $subscriber->findByEmail($this->subscriber->email)); 138 | } 139 | 140 | /** @test */ 141 | public function can_use_local_instance_to_find_email_if_existing() 142 | { 143 | $this->subscriber->setSubscribed(); 144 | 145 | $this->mock->shouldReceive('findByEmail')->times(0); 146 | 147 | $this->assertEquals($this->subscriber, $this->subscriber->findByEmail()); 148 | 149 | $this->subscriber->setSubscribed(false); 150 | 151 | $this->mock->shouldReceive('findByEmail')->once()->andReturn($this->subscriber); 152 | 153 | $this->assertEquals($this->subscriber, $this->subscriber->findByEmail()); 154 | } 155 | 156 | /** @test */ 157 | public function can_verify_subscriber() 158 | { 159 | $this->assertFalse($this->subscriber->subscribed()); 160 | 161 | $this->mock->shouldReceive('verify')->once()->with($this->subscriber->email)->andReturn(true); 162 | 163 | $this->subscriber->verify(); 164 | 165 | $this->assertTrue($this->subscriber->verified); 166 | 167 | $this->assertTrue($this->subscriber->subscribed()); 168 | } 169 | 170 | /** @test */ 171 | public function notifications_can_be_sent_from_subscriber_instance() 172 | { 173 | $this->subscriber->notify(new UserSubscribedNotification($this->subscriber)); 174 | 175 | Notification::assertSentTo( 176 | $this->subscriber, 177 | UserSubscribedNotification::class, 178 | function ($notification) { 179 | return $notification->subscriber->email === $this->subscriber->email; 180 | } 181 | ); 182 | 183 | $this->subscriber->notify(new UserUnsubscribedNotification($this->subscriber)); 184 | 185 | Notification::assertSentTo( 186 | $this->subscriber, 187 | UserUnsubscribedNotification::class, 188 | function ($notification) { 189 | return $notification->subscriber->email === $this->subscriber->email; 190 | } 191 | ); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /webpack.mix.js: -------------------------------------------------------------------------------- 1 | let mix = require('laravel-mix'); 2 | require('laravel-mix-tailwind'); 3 | 4 | mix.sass('resources/sass/early-access.scss', 'public/css') 5 | .copyDirectory('resources/svg', 'public/svg') 6 | .tailwind(); 7 | 8 | if (mix.production) { 9 | mix.version(); 10 | } 11 | --------------------------------------------------------------------------------