feat(plus): activate license from web interface

This commit is contained in:
Phan An 2024-01-13 18:57:24 +01:00
parent c620aaefe5
commit 40af08f2f6
23 changed files with 297 additions and 16 deletions

View file

@ -40,7 +40,7 @@ class CheckLicenseStatusCommand extends Command
case LicenseStatus::STATUS_NO_LICENSE:
$this->components->info(
'No license found. You can purchase one at ' . config('lemonsqueezy.store_url')
'No license found. You can purchase one at https://store.plus.koel.dev/checkout/buy/' . config('lemonsqueezy.plus_product_id') // @phpcs-ignore
);
break;

View file

@ -0,0 +1,20 @@
<?php
namespace App\Http\Controllers\API;
use App\Http\Controllers\Controller;
use App\Http\Requests\API\ActivateLicenseRequest;
use App\Models\License;
use App\Services\License\LicenseServiceInterface;
class ActivateLicenseController extends Controller
{
public function __invoke(ActivateLicenseRequest $request, LicenseServiceInterface $licenseService)
{
$this->authorize('activate', License::class);
$licenseService->activate($request->key);
return response()->noContent();
}
}

View file

@ -59,7 +59,7 @@ class FetchInitialDataController extends Controller
'short_key' => $licenseStatus->license?->short_key,
'customer_name' => $licenseStatus->license?->meta->customerName,
'customer_email' => $licenseStatus->license?->meta->customerEmail,
'store_url' => config('lemonsqueezy.store_url'),
'product_id' => config('lemonsqueezy.product_id'),
],
]);
}

View file

@ -0,0 +1,17 @@
<?php
namespace App\Http\Requests\API;
/**
* @property-read string $key
*/
class ActivateLicenseRequest extends Request
{
/** @return array<mixed> */
public function rules(): array
{
return [
'key' => 'required|string',
];
}
}

View file

@ -0,0 +1,14 @@
<?php
namespace App\Policies;
use App\Facades\License;
use App\Models\User;
class LicensePolicy
{
public function activate(User $user): bool
{
return $user->is_admin && License::isCommunity();
}
}

View file

@ -2,5 +2,5 @@
return [
'store_id' => 62685,
'store_url' => 'https://store.plus.koel.dev',
'product_id' => env('PLUS_PRODUCT_ID', '58e8adbc-b1aa-43f9-b768-a52d1d8e6a40'),
];

View file

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<svg viewBox="0 0 500 500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<defs>
<linearGradient id="gradient-2-0" gradientTransform="matrix(1.630484, 0.681896, -0.385832, 0.922568, 1.187751, 0.103153)" x1="-0.856" y1="0.304" x2="0.144" y2="0.304" xlink:href="#gradient-2"/>
<linearGradient id="gradient-2">
<stop offset="0" style="stop-color: rgb(198, 43, 232);"/>
<stop offset="0.22" style="stop-color: rgb(103, 28, 228);"/>
<stop offset="1" style="stop-color: rgb(198, 43, 232);"/>
</linearGradient>
</defs>
<rect style="fill: url('#gradient-2-0'); fill-rule: nonzero; stroke-width: 0px; stroke-miterlimit: 4.98; stroke: rgb(0, 0, 0); paint-order: fill; transform-origin: 250px 250px;" width="500" height="500"/>
<g transform="matrix(0.8879029750823975, 0, 0, 0.8879029750823975, 52.0737762451173, 56.69451904296875)" style="opacity: 1;">
<path d="M 328.303 188.961 C 328.303 188.961 318.17 203.266 347.76 217.348 C 347.76 217.348 302.636 212.569 290.67 201.233 C 290.67 201.233 212.854 211.423 243.947 286.124 C 243.947 286.124 218.36 267.71 210.518 243.685 C 210.518 243.685 152.216 304.894 191.314 405.117 C 191.314 405.117 148.62 359.498 142.608 314.694 C 142.608 314.694 138.995 321.72 140.515 332.469 C 140.515 332.469 123.333 282.915 127.745 256.742 C 127.745 256.742 117.266 271.261 116.622 279.47 C 116.622 279.47 118.088 261.069 125.336 249.74 C 125.336 249.74 123.225 233.321 130.436 216.127 C 137.63 198.974 135.336 180.594 133.949 177.325 C 133.949 177.325 131.722 184.773 128.532 187.975 C 128.532 187.975 138.732 147.647 134.398 128.305 C 134.398 128.305 125.485 136.922 126.786 158.527 C 126.786 158.527 121.629 139.445 126.723 119.042 C 126.723 119.042 122.162 119.49 121.789 124.142 C 121.789 124.142 116.949 111.085 123.76 96.051 C 123.76 96.051 84.833 69.942 81.028 66.853 C 81.028 66.853 75.417 64.012 68.493 64.63 C 90.415 55.506 119.45 71.392 137.4 83.323 C 147.164 90.108 151.962 94.401 162.608 97.243 C 173.395 100.122 182.999 109.637 182.999 109.637 C 172.215 93.891 168.799 102.591 139.349 81.622 C 97.464 51.998 74.541 59.985 66.511 64.903 C 65.463 65.098 64.397 65.373 63.323 65.762 C 63.323 65.762 79.902 30.643 149.477 53.127 C 149.477 53.127 160.244 42.164 166.903 41.09 C 166.903 41.09 157.622 47.703 155.936 53.17 C 155.936 53.17 169.148 43.598 179.921 43.705 C 179.921 43.705 167.56 49.992 161.35 55.813 C 161.35 55.813 169.556 51.438 181.381 51.582 C 181.381 51.582 174.035 55.135 171.511 56.355 C 170.985 56.609 170.668 56.762 170.668 56.762 C 170.668 56.762 178.105 55.185 182.139 55.101 C 182.139 55.101 202.253 45.441 220.442 47.332 C 220.442 47.332 214.117 47.379 205.347 54.386 C 205.347 54.386 232.76 52.951 251.126 62.02 C 251.126 62.02 235.577 58.969 225.896 59.036 C 225.896 59.036 273.212 60.924 312.72 112.498 C 354.285 166.757 356.018 171.163 389.963 196.683 C 389.963 196.683 348.906 194.896 328.303 188.961 Z" class="cls-1" style="fill: rgb(255, 255, 255); fill-rule: evenodd;"/>
<path d="M 299.325 169.037 C 299.325 169.037 292.029 181.568 313.73 191.067 C 313.73 191.067 280.601 190.376 271.363 181.746 C 271.363 181.746 210.649 197.114 237.47 256.311 C 237.47 256.311 216.699 243.673 209.508 224.113 C 209.508 224.113 164.263 283.396 200.188 358.838 C 200.188 358.838 163.476 328.412 156.127 291.899 C 156.127 291.899 153.416 298.307 155.279 307.151 C 155.279 307.151 137.679 267.002 140.027 243.601 C 140.027 243.601 131.625 257.518 131.554 264.785 C 131.554 264.785 131.733 248.454 137.485 237.67 C 137.485 237.67 134.624 223.264 140.027 207.166 C 145.431 191.069 142.313 174.483 140.875 171.579 C 140.875 171.579 139.316 178.576 136.638 181.746 C 136.638 181.746 143.429 143.784 138.333 125.823 C 138.333 125.823 130.702 134.405 133.249 154.632 C 133.249 154.632 127.312 136.991 130.707 117.35 C 130.707 117.35 126.513 117.968 126.47 122.433 C 126.47 122.433 121.098 110.097 126.47 95.319 C 126.47 95.319 87.26 70.415 83.256 67.357 C 83.256 67.357 77.435 64.55 70.552 65.282 C 91.536 55.723 120.764 71.278 138.333 82.609 C 147.705 88.926 152.314 92.915 162.058 95.319 C 171.802 97.723 180.699 106.334 180.699 106.334 C 170.441 91.87 167.857 100.193 140.027 80.914 C 98.378 52.251 76.414 60.484 68.707 65.56 C 67.634 65.774 66.548 66.082 65.463 66.51 C 65.463 66.51 79.865 30.545 147.653 52.953 C 147.653 52.953 156.901 42.146 162.905 41.09 C 162.905 41.09 154.816 47.589 153.585 52.953 C 153.585 52.953 165.075 43.543 174.768 43.632 C 174.768 43.632 163.981 49.785 158.669 55.495 C 158.669 55.495 165.859 51.182 176.462 51.258 C 176.462 51.258 170.079 54.74 167.877 55.94 C 167.419 56.191 167.142 56.342 167.142 56.342 C 167.142 56.342 173.722 54.758 177.31 54.647 C 177.31 54.647 194.569 45.267 210.356 47.021 C 210.356 47.021 204.931 47.086 197.646 53.8 C 197.646 53.8 221.018 52.269 236.623 60.579 C 236.623 60.579 223.597 57.887 215.44 58.037 C 215.44 58.037 254.712 59.521 286.615 104.64 C 318.519 149.758 319.557 152.847 343.386 171.579 C 343.386 171.579 314.382 172.661 299.325 169.037 Z" class="cls-2" style="fill-rule: evenodd;"/>
<path d="M 139.18 66.51 C 139.18 66.51 145.52 60.69 147.653 69.899 C 147.653 69.899 144.627 72.868 142.569 71.594 C 140.512 70.319 139.18 66.51 139.18 66.51 Z" class="cls-1" style="fill: rgb(255, 255, 255); fill-rule: evenodd;"/>
<g style="" transform="matrix(0.847328, 0, 0, 0.847328, 39.194847, 35.159096)">
<path d="M184.000,67.000 C184.000,67.000 189.596,53.143 207.000,58.000 C221.245,62.388 222.000,74.000 222.000,74.000 C222.000,74.000 215.773,89.560 204.000,88.000 C192.227,86.440 184.492,76.630 184.000,67.000 Z" class="cls-3" style="fill: rgb(250, 0, 0); fill-rule: evenodd;"/>
<path d="M200.055,61.063 C207.079,60.796 213.823,64.926 214.031,70.403 C214.243,75.962 207.620,79.457 200.388,78.313 C194.177,77.332 189.733,73.180 189.712,68.967 C189.691,64.801 194.001,61.293 200.055,61.063 Z" class="cls-2" style="fill-rule: evenodd;"/>
<ellipse cx="196.5" cy="65" rx="4.5" ry="4" class="cls-4" style="fill: rgb(255, 255, 255);"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 6 KiB

View file

@ -0,0 +1,61 @@
<template>
<form class="license-form" @submit.prevent="validateLicenseKey">
<input
type="text"
name="license"
v-model="licenseKey"
placeholder="Enter your license key"
required
v-koel-focus
:disabled="loading"
>
<Btn blue type="submit" :disabled="loading">Activate</Btn>
</form>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { plusService } from '@/services'
import { forceReloadWindow, logger } from '@/utils'
import { useDialogBox } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
const { showSuccessDialog, showErrorDialog } = useDialogBox()
const licenseKey = ref('')
const loading = ref(false)
const validateLicenseKey = async () => {
try {
loading.value = true
await plusService.activateLicense(licenseKey.value)
await showSuccessDialog('Thanks for purchasing Koel Plus! Koel will now refresh to activate the changes.')
forceReloadWindow()
} catch (e) {
logger.error(e)
await showErrorDialog('Failed to activate Koel Plus. Please try again.')
} finally {
loading.value = false
}
}
</script>
<style scoped lang="scss">
form {
display: flex;
align-items: stretch;
&:has(:focus) {
outline: 4px solid rgba(255, 255, 255, 0);
}
input {
border-radius: 4px 0 0 4px;
}
button {
border-radius: 0 4px 4px 0;
}
}
</style>

View file

@ -1,15 +1,17 @@
<template>
<a :href="storeUrl" target="_blank" class="upgrade-to-plus-btn">
<a href class="upgrade-to-plus-btn" @click.prevent="openModal">
<Icon :icon="faPlus" fixed-width/>
Upgrade to Plus
</a>
</template>
<script setup lang="ts">
import { useKoelPlus } from '@/composables'
import { faPlus } from '@fortawesome/free-solid-svg-icons'
import { eventBus } from '@/utils'
const { storeUrl } = useKoelPlus()
const openModal = () => {
eventBus.emit('MODAL_SHOW_KOEL_PLUS')
}
</script>
<style scoped lang="scss">

View file

@ -0,0 +1,116 @@
<template>
<div class="plus text-secondary" data-testid="koel-plus" tabindex="0">
<img class="plus-icon" alt="Koel Plus" src="@/../img/koel-plus.svg" width="96">
<main>
<div class="intro">
Koel Plus adds premium features on top of the default installation.<br>
Pay <em>once</em> and enjoy all additional features forever including those to be built into the app
in the future!
</div>
<div class="buttons" v-show="!showingActivateLicenseForm">
<Btn big red @click.prevent="openPurchaseOverlay">Purchase Koel Plus</Btn>
<Btn big green @click.prevent="showActivateLicenseForm">I have a license key</Btn>
</div>
<div class="activate-form" v-if="showingActivateLicenseForm">
<ActivateLicenseForm v-if="showingActivateLicenseForm" />
<Btn transparent class="cancel" @click.prevent="hideActivateLicenseForm">Cancel</Btn>
</div>
<div class="more-info">
Visit <a href="https://koel.dev#plus" target="_blank">koel.dev</a> for more information.
</div>
</main>
<footer>
<Btn data-testid="close-modal-btn" red rounded @click.prevent="close">Close</Btn>
</footer>
</div>
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useKoelPlus } from '@/composables'
import Btn from '@/components/ui/Btn.vue'
import ActivateLicenseForm from '@/components/koel-plus/ActivateLicenseForm.vue'
const { checkoutUrl } = useKoelPlus()
const emit = defineEmits<{ (e: 'close'): void }>()
const close = () => emit('close')
const showingActivateLicenseForm = ref(false)
const openPurchaseOverlay = () => {
close()
LemonSqueezy.Url.Open(checkoutUrl.value) // @ts-ignore
}
const showActivateLicenseForm = () => (showingActivateLicenseForm.value = true)
const hideActivateLicenseForm = () => (showingActivateLicenseForm.value = false)
onMounted(() => window.createLemonSqueezy()) // @ts-ignore
</script>
<style scoped lang="scss">
.plus {
max-width: 480px;
display: flex;
flex-direction: column;
align-items: center;
main {
padding: .7rem 1.7rem;
text-align: center;
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.plus-icon {
margin-top: calc(-48px);
border-radius: 99rem;
border: 6px solid #fff;
}
.intro {
text-align: center;
padding: .5rem 1.5rem;
}
.buttons {
display: flex;
justify-content: center;
gap: 1rem
}
.more-info {
font-size: .9rem;
opacity: .7;
}
.activate-form {
display: flex;
gap: .5rem;
form {
flex: 1;
}
button.cancel {
color: var(--color-text-secondary);
}
}
footer {
margin-top: .5rem;
width: 100%;
text-align: center;
padding: 1rem;
background: rgba(0, 0, 0, .2);
}
}
</style>

View file

@ -21,6 +21,7 @@ const modalNameToComponentMap = {
'create-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/CreatePlaylistFolderForm.vue')),
'edit-playlist-folder-form': defineAsyncComponent(() => import('@/components/playlist/EditPlaylistFolderForm.vue')),
'about-koel': defineAsyncComponent(() => import('@/components/meta/AboutKoelModal.vue')),
'koel-plus': defineAsyncComponent(() => import('@/components/koel-plus/KoelPlusModal.vue')),
'equalizer': defineAsyncComponent(() => import('@/components/ui/Equalizer.vue'))
}
@ -40,6 +41,7 @@ const close = () => {
}
eventBus.on('MODAL_SHOW_ABOUT_KOEL', () => (activeModalName.value = 'about-koel'))
.on('MODAL_SHOW_KOEL_PLUS', () => (activeModalName.value = 'koel-plus'))
.on('MODAL_SHOW_ADD_USER_FORM', () => (activeModalName.value = 'add-user-form'))
.on('MODAL_SHOW_INVITE_USER_FORM', () => (activeModalName.value = 'invite-user-form'))
.on('MODAL_SHOW_CREATE_PLAYLIST_FORM', (folder, songs) => {
@ -85,6 +87,7 @@ dialog {
border-radius: 4px;
min-width: 460px;
max-width: calc(100vw - 24px);
overflow: visible;
@media screen and (max-width: 768px) {
min-width: calc(100vw - 24px);

View file

@ -60,7 +60,7 @@ import QueueSidebarItem from './QueueSidebarItem.vue'
import YouTubeSidebarItem from './YouTubeSidebarItem.vue'
import PlaylistList from './PlaylistSidebarList.vue'
import SearchForm from '@/components/ui/SearchForm.vue'
import BtnUpgradeToPlus from '@/components/meta/BtnUpgradeToPlus.vue'
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
const { onRouteChanged } = useRouter()
const { useYouTube } = useThirdPartyServices()

View file

@ -13,12 +13,13 @@
<p v-if="isPlus" class="plus-badge">
Licensed to {{ license.customerName }} &lt;{{ license.customerEmail }}&gt;
<br>
License key <span class="key text-green">{{ license.shortKey }}</span>
License key: <span class="key">{{ license.shortKey }}</span>
</p>
<template v-else>
<p class="upgrade" v-if="isAdmin">
<BtnUpgradeToPlus/>
<!-- close the modal first to prevent it from overlapping Lemonsqueezy's overlay -->
<BtnUpgradeToPlus @click="close"/>
</p>
</template>
</div>
@ -72,7 +73,7 @@ import { http } from '@/services'
import SponsorList from '@/components/meta/SponsorList.vue'
import Btn from '@/components/ui/Btn.vue'
import BtnUpgradeToPlus from '@/components/meta/BtnUpgradeToPlus.vue'
import BtnUpgradeToPlus from '@/components/koel-plus/BtnUpgradeToPlus.vue'
type DemoCredits = {
name: string
@ -168,6 +169,9 @@ onMounted(async () => {
.plus-badge {
.key {
font-family: monospace;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-image: linear-gradient(97.78deg, #c62be8 17.5%, #671ce4 113.39%);
}
}

View file

@ -4,7 +4,9 @@ exports[`renders 1`] = `
<div data-v-6b5b01a9="" class="about text-secondary" data-testid="about-koel" tabindex="0">
<main data-v-6b5b01a9="">
<div data-v-6b5b01a9="" class="logo"><img data-v-6b5b01a9="" alt="Koel's logo" src="undefined/resources/assets/img/logo.svg" width="128"></div>
<p data-v-6b5b01a9="" class="current-version">Koel v0.0.0</p>
<div data-v-6b5b01a9="" class="current-version"> Koel v0.0.0 <span data-v-6b5b01a9="">Community</span> Edition
<!--v-if-->
</div>
<!--v-if-->
<p data-v-6b5b01a9="" class="author"> Made with ❤️ by <a data-v-6b5b01a9="" href="https://github.com/phanan" rel="noopener" target="_blank">Phan An</a> and quite a few <a data-v-6b5b01a9="" href="https://github.com/koel/core/graphs/contributors" rel="noopener" target="_blank">awesome</a>&nbsp;<a data-v-6b5b01a9="" href="https://github.com/koel/koel/graphs/contributors" rel="noopener" target="_blank">contributors</a>. </p>
<!--v-if--><br data-v-6b5b01a9="" data-testid="sponsor-list">

View file

@ -30,6 +30,10 @@ button {
box-shadow: inset 0 0 0 10rem rgba(0, 0, 0, .05);
}
&[big] {
padding: .85rem 1.4rem;
}
&[small] {
font-size: .9rem;
padding: .4rem .7rem;

View file

@ -9,6 +9,6 @@ export const useKoelPlus = () => {
customerName: commonStore.state.koel_plus.customer_name,
customerEmail: commonStore.state.koel_plus.customer_email
},
storeUrl: computed(() => commonStore.state.koel_plus.store_url)
checkoutUrl: computed(() => `https://store.plus.koel.dev/checkout/buy/${commonStore.state.koel_plus.product_id}?embed=1&media=0`)
}
}

View file

@ -27,6 +27,7 @@ export interface Events {
MODAL_SHOW_CREATE_PLAYLIST_FOLDER_FORM: () => void
MODAL_SHOW_EDIT_PLAYLIST_FOLDER_FORM: (playlistFolder: PlaylistFolder) => void
MODAL_SHOW_ABOUT_KOEL: () => void
MODAL_SHOW_KOEL_PLUS: () => void
MODAL_SHOW_EQUALIZER: () => void
PLAYLIST_DELETE: (playlist: Playlist) => void

View file

@ -12,3 +12,4 @@ export * from './cache'
export * from './socketListener'
export * from './volumeManager'
export * from './invitationService'
export * from './plusService'

View file

@ -164,7 +164,7 @@ class PlaybackService {
position: 0
})
} catch (error) {
console.log(error)
logger.error(error)
}
this.player.restart()
@ -397,7 +397,7 @@ class PlaybackService {
position: Math.ceil(media.currentTime)
})
} catch (error) {
console.log(error)
logger.error(error)
}
}

View file

@ -0,0 +1,7 @@
import { http } from '@/services'
export const plusService = {
activateLicense: async (key: string) => {
return await http.post('licenses/activate', { key })
}
}

View file

@ -14,7 +14,7 @@ interface CommonStoreState {
short_key: string | null
customer_name: string | null
customer_email: string | null
store_url: string
product_id: string
}
media_path_set: boolean
playlists: Playlist[]
@ -41,7 +41,7 @@ export const commonStore = {
short_key: null,
customer_name: null,
customer_email: null,
store_url: ''
product_id: ''
},
latest_version: '',
media_path_set: false,

View file

@ -17,6 +17,10 @@
<link rel="icon" href="{{ static_url('img/icon.png') }}">
<link rel="apple-touch-icon" href="{{ static_url('img/icon.png') }}">
@unless(License::isPlus())
<script src="https://app.lemonsqueezy.com/js/lemon.js" defer></script>
@endunless
<script>
// Work around for "global is not defined" error with local-storage.js
window.global = window

View file

@ -1,6 +1,7 @@
<?php
use App\Facades\YouTube;
use App\Http\Controllers\API\ActivateLicenseController;
use App\Http\Controllers\API\AlbumController;
use App\Http\Controllers\API\AlbumSongController;
use App\Http\Controllers\API\ArtistAlbumController;
@ -161,6 +162,8 @@ Route::prefix('api')->middleware('api')->group(static function (): void {
Route::put('songs/make-public', MakeSongsPublicController::class);
Route::put('songs/make-private', MakeSongsPrivateController::class);
Route::post('licenses/activate', ActivateLicenseController::class);
});
// Object-storage (S3) routes