mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-13 16:23:18 +01:00
move upgrade modal to ui package and add trial status and cancel trial ui
This commit is contained in:
14
e2e/utils/billing.ts
Normal file
14
e2e/utils/billing.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { expect } from '@playwright/test';
|
||||
import { PLAYWRIGHT_BASE_URL } from '../../playwright/config';
|
||||
import type { TestContext } from './api';
|
||||
|
||||
/**
|
||||
* End the trial of the test organization, only works with the Billing extension. Reload the page
|
||||
* afterwards, the page props still contain the trial otherwise.
|
||||
*/
|
||||
export async function cancelTrialViaApi(ctx: TestContext): Promise<void> {
|
||||
const response = await ctx.request.post(
|
||||
`${PLAYWRIGHT_BASE_URL}/api/v1/organizations/${ctx.orgId}/trial/cancel`
|
||||
);
|
||||
expect(response.status()).toBe(204);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import { enabledExtensions } from './playwright/config';
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
@@ -7,23 +7,11 @@ import { existsSync, readFileSync } from 'node:fs';
|
||||
*/
|
||||
// require('dotenv').config();
|
||||
|
||||
/**
|
||||
/*
|
||||
* Extensions ship their e2e specs in extensions/<Name>/tests/e2e. Only the specs of extensions
|
||||
* that are enabled in modules_statuses.json (php artisan module:enable <Name>) are collected,
|
||||
* a checked out but disabled extension would fail its specs otherwise.
|
||||
*/
|
||||
function enabledExtensions(): string[] {
|
||||
const statusesFile = './modules_statuses.json';
|
||||
if (!existsSync(statusesFile)) {
|
||||
return [];
|
||||
}
|
||||
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
||||
return Object.entries(statuses)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
/*
|
||||
*
|
||||
* Every test root gets its own testDir (instead of testDir: '.') so playwright does not walk the
|
||||
* whole repository, including node_modules, vendor and nested worktrees, to find the specs.
|
||||
*/
|
||||
|
||||
@@ -1,3 +1,23 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
|
||||
export const PLAYWRIGHT_BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? 'http://solidtime.test';
|
||||
export const MAILPIT_BASE_URL = process.env.MAILPIT_BASE_URL ?? 'http://mailpit:8025';
|
||||
export const TEST_USER_PASSWORD = 'amazingpassword123';
|
||||
|
||||
/**
|
||||
* Extensions that are enabled in modules_statuses.json (php artisan module:enable <Name>)
|
||||
*/
|
||||
export function enabledExtensions(): string[] {
|
||||
const statusesFile = './modules_statuses.json';
|
||||
if (!existsSync(statusesFile)) {
|
||||
return [];
|
||||
}
|
||||
const statuses: Record<string, boolean> = JSON.parse(readFileSync(statusesFile, 'utf-8'));
|
||||
return Object.entries(statuses)
|
||||
.filter(([, enabled]) => enabled)
|
||||
.map(([name]) => name);
|
||||
}
|
||||
|
||||
export function isExtensionEnabled(name: string): boolean {
|
||||
return enabledExtensions().includes(name);
|
||||
}
|
||||
|
||||
@@ -1,137 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import { CheckBadgeIcon, XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import { XMarkIcon, XCircleIcon } from '@heroicons/vue/16/solid';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
daysLeftInTrial,
|
||||
isBillingActivated,
|
||||
isBlocked,
|
||||
isFreePlan,
|
||||
isInTrial,
|
||||
} from '@/utils/billing';
|
||||
import { isBillingActivated, isBlocked } from '@/utils/billing';
|
||||
import { useSessionStorage } from '@vueuse/core';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
|
||||
const hideTrialBanner = useSessionStorage('showTrialBanner-' + getCurrentOrganizationId(), false);
|
||||
const showTrialBanner = computed(() => isInTrial() && !hideTrialBanner.value);
|
||||
const hideBlockedBanner = useSessionStorage(
|
||||
'showBlockedBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showBlockedBanner = computed(() => isBlocked() && !hideBlockedBanner.value);
|
||||
const hideFreeUpgradeBanner = useSessionStorage(
|
||||
'showFreeUpgradeBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
const showFreeUpgradeBanner = computed(
|
||||
() =>
|
||||
isFreePlan() && !isBlocked() && !hideFreeUpgradeBanner.value && !showBlackFridayBanner.value
|
||||
);
|
||||
const hideBlackFridayBanner = useSessionStorage(
|
||||
'hideBlackFridayBanner-' + getCurrentOrganizationId(),
|
||||
false
|
||||
);
|
||||
|
||||
const showBlackFridayBanner = computed(() => {
|
||||
if (hideBlackFridayBanner.value) {
|
||||
return false;
|
||||
}
|
||||
const today = new Date();
|
||||
const blackFriday = new Date(2024, 10, 30);
|
||||
return today < blackFriday;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
v-if="showBlackFridayBanner"
|
||||
class="bg-tertiary text-xs lg:text-sm pb-1 pt-2 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<svg class="w-4 mr-1" viewBox="0 0 256 256" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
fill="#FF37AD"
|
||||
d="M22.498 68.97a11.845 11.845 0 1 0 0-23.687c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844m181.393-10.04a11.845 11.845 0 1 0-.003-23.688c-6.471.098-11.665 5.373-11.665 11.845c.001 6.472 5.197 11.745 11.668 11.842" />
|
||||
<path
|
||||
fill="#FCC954"
|
||||
d="M213.503 211.097a11.845 11.845 0 1 0-.003-23.687c-6.471.098-11.665 5.373-11.664 11.845s5.196 11.745 11.667 11.842M70.872 23.689a11.845 11.845 0 1 0 0-23.688C64.4.1 59.206 5.373 59.206 11.845S64.4 23.591 70.872 23.689" />
|
||||
<path
|
||||
fill="#2890E9"
|
||||
d="M140.945 105.94a9.25 9.25 0 0 1-8.974-11.484c.37-1.482.672-2.97.899-4.455a25.4 25.4 0 0 1-8.732 1.904c-5.379.205-10.195-.702-14.3-2.69a22.23 22.23 0 0 1-9.614-8.877c-4.415-7.652-4.034-17.718.964-25.645c4.765-7.568 12.836-11.664 21.586-10.995c6.74.527 12.647 3.051 17.378 7.382q1.293-3.647 2.473-7.803c4.833-17.058 6.429-34.187 6.442-34.36a9.24 9.24 0 0 1 10.041-8.37a9.25 9.25 0 0 1 8.37 10.044c-.067.767-1.768 19.03-7.068 37.735c-2.676 9.445-5.838 17.426-9.42 23.798q.396 2.13.631 4.372c.746 7.211.152 14.974-1.714 22.445a9.256 9.256 0 0 1-8.962 6.998m-20.123-43.827c-.956 0-2.64.28-3.996 2.43c-1.298 2.06-1.552 4.873-.588 6.544c1.282 2.223 5.054 2.417 7.19 2.336c2.424-.092 4.908-1.612 7.338-4.382a16 16 0 0 0-1.43-2.422c-2.007-2.787-4.547-4.212-7.998-4.482c-.13-.008-.305-.024-.516-.024" />
|
||||
<path
|
||||
fill="#F0A420"
|
||||
d="M114.361 131.268c-38.343-30.224-78.42-43.319-89.514-29.246a12.8 12.8 0 0 0-2.257 4.509a4 4 0 0 0-.156.61v.024q-.223.947-.333 1.917L.393 236.18c-3.477 20.412 16.73 36.755 35.967 29.093l117.721-46.908c2.076-.826 7.185-3.982 8.583-5.724q.556-.544 1.037-1.153c11.092-14.075-11-49.988-49.34-80.223z" />
|
||||
<path
|
||||
fill="#FCC954"
|
||||
d="M163.688 211.494c11.1-14.08-10.984-50-49.327-80.226c-38.343-30.227-78.425-43.316-89.524-29.236s10.983 50 49.326 80.226c38.343 30.227 78.425 43.316 89.525 29.236" />
|
||||
<path
|
||||
fill="#F0A420"
|
||||
d="M156.994 203.294c9.108-11.556-10.956-42.563-44.817-69.256c-33.861-26.695-68.697-38.966-77.804-27.413c-9.11 11.556 10.954 42.563 44.815 69.256c33.86 26.695 68.697 38.969 77.806 27.413" />
|
||||
<path
|
||||
fill="#2E6AC9"
|
||||
d="M76.059 249.456c-14.327.07-26.004-7.101-40.158-18.257C19.431 218.21 8.493 202.665 7.63 193.81l-4.668 27.327c2.16 7.798 9.523 17.683 20.202 26.101c8.883 7.004 17.844 11.813 27.135 12.48l25.76-10.266zm-14.332-49.6c-27.443-21.637-45.271-46.467-44.77-60.669l-4.549 26.63c.351 12.685 15.175 33.184 36.262 49.808c18.894 14.896 38.583 25.38 53.66 23.363l25.593-10.2c-20.62 1.425-42.376-10.147-66.196-28.931" />
|
||||
<path
|
||||
fill="#2890E9"
|
||||
d="M118.535 145.052a11.845 11.845 0 1 0 0-23.688c-6.471.098-11.666 5.372-11.666 11.844s5.195 11.746 11.666 11.844" />
|
||||
<path
|
||||
fill="#FF37AD"
|
||||
d="m182.412 122.007l.087-.097c.108-.116.308-.33.596-.621a45 45 0 0 1 2.8-2.56c3.56-2.98 7.45-5.54 11.594-7.63c10.128-5.125 25.208-9.307 44.985-4.747c5.943 1.37 11.87-2.336 13.241-8.278c1.37-5.942-2.336-11.87-8.278-13.24c-25.602-5.903-45.957-.506-59.922 6.566a82.5 82.5 0 0 0-15.857 10.449a66 66 0 0 0-4.215 3.866a45 45 0 0 0-1.53 1.615l-.12.135l-.042.048l-.02.022l-.007.008c-.003.005-.009.01 8.361 7.21l-8.37-7.2c-3.877 4.622-3.328 11.5 1.233 15.448s11.446 3.506 15.464-.994M73.03 43.248a11.75 11.75 0 0 0-16.23-3.664a11.76 11.76 0 0 0-3.665 16.227c.427.683 9.178 14.86 10.976 34.276c1.83 19.727-3.966 37.86-17.253 54.12c4.474 5.686 9.858 11.596 16.008 17.507c8.51-9.834 14.913-20.402 19.12-31.583c5.175-13.756 7.006-28.342 5.445-43.348c-2.487-23.874-12.874-41.11-14.402-43.535" />
|
||||
<path
|
||||
fill="#2890E9"
|
||||
d="M220.242 156.578c6.002 1.553 10.244 3.246 12.077 4.034a11.86 11.86 0 0 0 13.94-1.12a11.87 11.87 0 0 0 4.107-8.765a11.85 11.85 0 0 0-8.06-11.426c-5.618-2.495-26.905-10.92-55.044-9.423c-18.941 1.007-37.155 6.253-54.133 15.608c-16.076 8.86-31.004 21.412-44.556 37.425a199 199 0 0 0 20.17 12.607c22.882-26.08 49.283-40.217 78.7-42.085a105.9 105.9 0 0 1 32.8 3.145" />
|
||||
</svg>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
<strong>BLACK FRIDAY SALE!</strong> Use the code
|
||||
<strong>BLACKFRIDAY</strong> at checkout and get
|
||||
<strong>30% off</strong> the solidtime yearly plan.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideBlackFridayBanner = true">
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showTrialBanner"
|
||||
class="bg-accent-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<CheckBadgeIcon class="w-4 text-text-primary/50"></CheckBadgeIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium">
|
||||
Your trial expires in {{ daysLeftInTrial() }} days.
|
||||
</span>
|
||||
<span class="hidden md:inline">
|
||||
To continue using all features & support the development of solidtime,
|
||||
please upgrade your plan.
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideTrialBanner = true">
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showBlockedBanner"
|
||||
class="bg-red-600/50 text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
@@ -159,33 +43,6 @@ const showBlackFridayBanner = computed(() => {
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
<div
|
||||
v-if="showFreeUpgradeBanner"
|
||||
class="bg-tertiary text-xs lg:text-sm py-0.5 border-b border-border-secondary">
|
||||
<MainContainer class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-1.5">
|
||||
<XCircleIcon class="w-4 text-text-primary/50"></XCircleIcon>
|
||||
<div class="flex-1 space-x-1">
|
||||
<span class="font-medium"> You are currently using the Free Plan. </span>
|
||||
<span class="hidden md:inline">
|
||||
To unlock all premium features & support the development of solidtime,
|
||||
please upgrade your plan.</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
||||
<div
|
||||
class="text-text-primary font-semibold uppercase text-xs flex space-x-1 items-center hover:bg-white/10 rounded-lg px-2 py-1.5">
|
||||
<span>Upgrade now</span>
|
||||
</div>
|
||||
</Link>
|
||||
<button class="p-1" @click="hideFreeUpgradeBanner = true">
|
||||
<XMarkIcon class="w-4 opacity-50 hover:opacity-100"></XMarkIcon>
|
||||
</button>
|
||||
</div>
|
||||
</MainContainer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
86
resources/js/Components/Billing/CancelTrialModal.vue
Normal file
86
resources/js/Components/Billing/CancelTrialModal.vue
Normal file
@@ -0,0 +1,86 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import DangerButton from '@/packages/ui/src/Buttons/DangerButton.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import { XMarkIcon } from '@heroicons/vue/16/solid';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import { getCurrentOrganizationId } from '@/utils/useUser';
|
||||
import { api } from '@/packages/api/src';
|
||||
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
|
||||
const processing = ref(false);
|
||||
const { handleApiRequestNotifications } = useNotificationsStore();
|
||||
|
||||
const lostFeatures = [
|
||||
'Billable hours & rates',
|
||||
'Invoicing',
|
||||
'Project & task estimates',
|
||||
'Rounding of time entries',
|
||||
'PDF reports & shareable reports',
|
||||
'Team support & access management',
|
||||
];
|
||||
|
||||
async function cancelTrial() {
|
||||
// Note: The organization of this page, the current organization on the server can differ if the user switched organizations in another tab
|
||||
const organizationId = getCurrentOrganizationId();
|
||||
if (processing.value || organizationId === null) {
|
||||
return;
|
||||
}
|
||||
processing.value = true;
|
||||
try {
|
||||
await handleApiRequestNotifications(
|
||||
() => api.cancelTrial(undefined, { params: { organization: organizationId } }),
|
||||
'Your trial has been canceled',
|
||||
'Your trial could not be canceled, please try again',
|
||||
() => {
|
||||
show.value = false;
|
||||
// Reloads the shared billing props of the page, they still contain the trial otherwise
|
||||
router.reload();
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal :show="show" closeable @close="show = false">
|
||||
<template #title>Cancel trial</template>
|
||||
|
||||
<template #content>
|
||||
<p class="text-sm text-text-secondary">
|
||||
Your organization switches to the Free plan right away. You will lose access to
|
||||
these Professional features:
|
||||
</p>
|
||||
<ul class="mt-4 grid gap-2 sm:grid-cols-2">
|
||||
<li
|
||||
v-for="feature in lostFeatures"
|
||||
:key="feature"
|
||||
class="flex items-center gap-2 text-sm text-text-primary">
|
||||
<XMarkIcon class="h-4 w-4 shrink-0 text-red-500/80" />
|
||||
{{ feature }}
|
||||
</li>
|
||||
</ul>
|
||||
<p class="mt-5 text-sm text-text-secondary">
|
||||
Organizations with more than one member are blocked until you upgrade or remove
|
||||
everyone except the owner.
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false">Keep trial</SecondaryButton>
|
||||
<DangerButton
|
||||
class="ms-3"
|
||||
data-testid="confirm_cancel_trial"
|
||||
:class="{ 'opacity-25': processing }"
|
||||
:disabled="processing"
|
||||
@click="cancelTrial">
|
||||
Cancel trial
|
||||
</DangerButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
56
resources/js/Components/Billing/SidebarPlanStatus.vue
Normal file
56
resources/js/Components/Billing/SidebarPlanStatus.vue
Normal file
@@ -0,0 +1,56 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { router } from '@inertiajs/vue3';
|
||||
import { SparklesIcon } from '@heroicons/vue/16/solid';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import TrialUpgradeModal from '@/Components/Billing/TrialUpgradeModal.vue';
|
||||
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
|
||||
import { daysLeftInTrial, isBlocked, isFreePlan, isInTrial } from '@/utils/billing';
|
||||
|
||||
const showTrialUpgradeModal = ref(false);
|
||||
const showCancelTrialModal = ref(false);
|
||||
|
||||
const daysLeftLabel = computed(() => {
|
||||
const days = daysLeftInTrial();
|
||||
return days === 1 ? '1 day left' : `${days} days left`;
|
||||
});
|
||||
|
||||
function openBilling() {
|
||||
router.visit('/billing');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isInTrial() || (isFreePlan() && !isBlocked())" class="pb-3">
|
||||
<Button
|
||||
v-if="isInTrial()"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
data-testid="sidebar_trial_days_left"
|
||||
class="w-full justify-between rounded-lg px-2.5 text-xs font-normal [&_svg]:size-3.5"
|
||||
@click="showTrialUpgradeModal = true">
|
||||
<span class="flex items-center gap-1.5 font-medium text-text-primary">
|
||||
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
|
||||
Professional trial
|
||||
</span>
|
||||
<span class="text-text-secondary">{{ daysLeftLabel }}</span>
|
||||
</Button>
|
||||
<div
|
||||
v-else-if="isFreePlan() && !isBlocked()"
|
||||
class="flex items-center justify-between gap-2 rounded-lg border border-border-secondary bg-card-background py-1.5 pl-2.5 pr-1.5 text-xs">
|
||||
<span class="font-medium text-text-primary">Free plan</span>
|
||||
<Button
|
||||
size="xs"
|
||||
data-testid="sidebar_upgrade_button"
|
||||
class="h-6 bg-accent-500 px-2.5 font-semibold text-white hover:bg-accent-400"
|
||||
@click="openBilling">
|
||||
Upgrade
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<TrialUpgradeModal
|
||||
v-model:show="showTrialUpgradeModal"
|
||||
@cancel-trial="showCancelTrialModal = true" />
|
||||
<CancelTrialModal v-model:show="showCancelTrialModal" />
|
||||
</div>
|
||||
</template>
|
||||
79
resources/js/Components/Billing/TrialUpgradeModal.vue
Normal file
79
resources/js/Components/Billing/TrialUpgradeModal.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/packages/ui/src/dialog';
|
||||
import { Button } from '@/packages/ui/src/Buttons';
|
||||
import { SparklesIcon } from '@heroicons/vue/16/solid';
|
||||
import { featureIcons } from '@/packages/ui/src/UpgradeModal/featureIcons';
|
||||
import { professionalFeatures, useUpgradeCta } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||
import { daysLeftInTrial } from '@/utils/billing';
|
||||
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
|
||||
const emit = defineEmits<{ cancelTrial: [] }>();
|
||||
|
||||
const { goToBilling } = useUpgradeCta();
|
||||
|
||||
const daysLeftLabel = computed(() => {
|
||||
const days = daysLeftInTrial();
|
||||
return days === 1 ? '1 day' : `${days} days`;
|
||||
});
|
||||
|
||||
function cancelTrial() {
|
||||
show.value = false;
|
||||
emit('cancelTrial');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="show">
|
||||
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
|
||||
<div class="px-8 pt-8 text-center">
|
||||
<div
|
||||
class="mx-auto inline-flex items-center gap-1.5 rounded-full border border-border-secondary bg-card-background px-3 py-1 text-xs font-medium text-text-primary">
|
||||
<SparklesIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
|
||||
{{ daysLeftLabel }} left in your trial
|
||||
</div>
|
||||
<DialogTitle class="mt-4 text-xl font-semibold tracking-tight text-text-primary">
|
||||
Keep solidtime Professional
|
||||
</DialogTitle>
|
||||
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
|
||||
Upgrade now to keep these features after your trial ends.
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-8">
|
||||
<div v-for="item in professionalFeatures" :key="item.key" class="flex gap-3">
|
||||
<div
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
|
||||
<component
|
||||
:is="featureIcons[item.key]"
|
||||
class="h-3.5 w-3.5 text-text-secondary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mx-auto flex max-w-xs flex-col items-center gap-3 px-8 pt-8 pb-6">
|
||||
<Button
|
||||
data-testid="trial_upgrade_modal_cta"
|
||||
class="w-full bg-accent-500 px-4 font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 hover:bg-accent-400"
|
||||
@click="goToBilling">
|
||||
Upgrade to Professional
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
data-testid="trial_upgrade_modal_cancel_trial"
|
||||
class="font-normal text-text-tertiary hover:text-text-primary"
|
||||
@click="cancelTrial">
|
||||
Cancel trial
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -18,7 +18,7 @@ import ProjectEditModal from '@/Components/Common/Project/ProjectEditModal.vue';
|
||||
import { formatCents } from '@/packages/ui/src/utils/money';
|
||||
import { getOrganizationCurrencyString } from '@/utils/money';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { canUpdateProjects, canDeleteProjects } from '@/utils/permissions';
|
||||
@@ -119,7 +119,9 @@ const showEditProjectModal = ref(false);
|
||||
<div v-else class="text-text-tertiary">--</div>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-primary">
|
||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"
|
||||
feature="estimates"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="project.estimated_time"
|
||||
:estimated="project.estimated_time"
|
||||
|
||||
@@ -4,7 +4,7 @@ import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue'
|
||||
import { h, ref } from 'vue';
|
||||
import type { CreateReportBodyProperties } from '@/packages/api/src';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import { canCreateReports } from '@/utils/permissions';
|
||||
defineProps<{
|
||||
reportProperties: CreateReportBodyProperties;
|
||||
@@ -30,9 +30,7 @@ function onSaveReportClick() {
|
||||
<ReportCreateModal
|
||||
v-model:show="showCreateReportModal"
|
||||
:properties="reportProperties"></ReportCreateModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>Sharable Reports</strong> is only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" feature="shareable-reports" />
|
||||
<SecondaryButton v-if="canCreateReports()" :icon="SaveIcon" @click="onSaveReportClick"
|
||||
>Save Report</SecondaryButton
|
||||
>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
import type { ExportFormat } from '@/types/reporting';
|
||||
import { ref } from 'vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
download: (format: ExportFormat) => Promise<void>;
|
||||
@@ -48,9 +48,7 @@ function triggerDownload(format: ExportFormat) {
|
||||
<DropdownMenuItem @click="triggerDownload('ods')"> Export as ODS </DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -30,7 +30,8 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from '@/packages/ui/src';
|
||||
import ReportCreateModal from '@/Components/Common/Report/ReportCreateModal.vue';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import type { UpgradeFeature } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||
import { canCreateReports } from '@/utils/permissions';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import { computed, type ComputedRef, inject, ref, watch } from 'vue';
|
||||
@@ -214,10 +215,13 @@ const showExportModal = ref(false);
|
||||
const exportUrl = ref<string | null>(null);
|
||||
const showCreateReportModal = ref(false);
|
||||
const showPremiumModal = ref(false);
|
||||
// Note: The premium modal is shared by the PDF export and saving a report
|
||||
const premiumFeature = ref<UpgradeFeature>('pdf-reports');
|
||||
const exportLoading = ref(false);
|
||||
|
||||
function triggerExport(format: ExportFormat) {
|
||||
if (format === 'pdf' && !isAllowedToPerformPremiumAction()) {
|
||||
premiumFeature.value = 'pdf-reports';
|
||||
showPremiumModal.value = true;
|
||||
return;
|
||||
}
|
||||
@@ -231,6 +235,7 @@ function onSaveReportClick() {
|
||||
if (isAllowedToPerformPremiumAction()) {
|
||||
showCreateReportModal.value = true;
|
||||
} else {
|
||||
premiumFeature.value = 'shareable-reports';
|
||||
showPremiumModal.value = true;
|
||||
}
|
||||
}
|
||||
@@ -299,9 +304,7 @@ const tableData = computed(() => {
|
||||
<ReportCreateModal
|
||||
v-model:show="showCreateReportModal"
|
||||
:properties="reportProperties"></ReportCreateModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
This feature is only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" :feature="premiumFeature" />
|
||||
<MainContainer
|
||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||
|
||||
@@ -9,7 +9,7 @@ import TaskEditModal from '@/Components/Common/Task/TaskEditModal.vue';
|
||||
import { ref, inject, type ComputedRef } from 'vue';
|
||||
import { isAllowedToPerformPremiumAction } from '@/utils/billing';
|
||||
import EstimatedTimeProgress from '@/packages/ui/src/EstimatedTimeProgress.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||
import { formatHumanReadableDuration } from '../../../packages/ui/src/utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
@@ -55,7 +55,9 @@ const showTaskEditModal = ref(false);
|
||||
<span v-else> -- </span>
|
||||
</div>
|
||||
<div class="whitespace-nowrap px-3 flex items-center text-sm text-text-secondary">
|
||||
<UpgradeBadge v-if="!isAllowedToPerformPremiumAction()"></UpgradeBadge>
|
||||
<UpgradeBadge
|
||||
v-if="!isAllowedToPerformPremiumAction()"
|
||||
feature="estimates"></UpgradeBadge>
|
||||
<EstimatedTimeProgress
|
||||
v-else-if="task.estimated_time"
|
||||
:estimated="task.estimated_time"
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import DialogModal from '@/packages/ui/src/DialogModal.vue';
|
||||
import PrimaryButton from '@/packages/ui/src/Buttons/PrimaryButton.vue';
|
||||
import { Link } from '@inertiajs/vue3';
|
||||
import { isBillingActivated } from '@/utils/billing';
|
||||
import { CreditCardIcon, UserGroupIcon } from '@heroicons/vue/20/solid';
|
||||
import { canManageBilling, canUpdateOrganization } from '@/utils/permissions';
|
||||
import { SecondaryButton } from '@/packages/ui/src';
|
||||
|
||||
const show = defineModel('show', { default: false });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DialogModal closeable :show="show" @close="show = false">
|
||||
<template #title>
|
||||
<div class="flex space-x-2">
|
||||
<span> Upgrade Plan </span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #content>
|
||||
<div>
|
||||
<div
|
||||
class="rounded-full flex items-center justify-center w-20 h-20 mx-auto border border-border-tertiary bg-secondary">
|
||||
<UserGroupIcon class="w-12"></UserGroupIcon>
|
||||
</div>
|
||||
<div class="max-w-sm text-center mx-auto py-4 text-base">
|
||||
<p class="py-1">
|
||||
<slot></slot>
|
||||
</p>
|
||||
<p class="py-1 text-sm">
|
||||
If you want to use this feature,
|
||||
<strong class="font-semibold text-text-primary"
|
||||
>please upgrade to a paid plan</strong
|
||||
>
|
||||
or
|
||||
<strong class="font-semibold text-text-primary"
|
||||
>request a free trial</strong
|
||||
>
|
||||
via
|
||||
<a
|
||||
class="text-accent-200/80 transition text-accent-300"
|
||||
href="mailto:hello@solidtime.io"
|
||||
>hello@solidtime.io</a
|
||||
>
|
||||
to try out this feature.
|
||||
</p>
|
||||
|
||||
<Link v-if="isBillingActivated() && canManageBilling()" href="/billing">
|
||||
<PrimaryButton
|
||||
v-if="isBillingActivated() && canUpdateOrganization()"
|
||||
type="button"
|
||||
class="mt-6"
|
||||
:icon="CreditCardIcon">
|
||||
Go to Billing
|
||||
</PrimaryButton>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #footer>
|
||||
<SecondaryButton @click="show = false">Close</SecondaryButton>
|
||||
</template>
|
||||
</DialogModal>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { Head, usePage } from '@inertiajs/vue3';
|
||||
import { Head, router, usePage } from '@inertiajs/vue3';
|
||||
import Banner from '@/Components/Banner.vue';
|
||||
import OrganizationSwitcher from '@/Components/OrganizationSwitcher.vue';
|
||||
import CurrentSidebarTimer from '@/Components/CurrentSidebarTimer.vue';
|
||||
@@ -23,7 +23,7 @@ import { PanelLeft, Target } from '@lucide/vue';
|
||||
import NavigationSidebarItem from '@/Components/NavigationSidebarItem.vue';
|
||||
import UserSettingsIcon from '@/Components/UserSettingsIcon.vue';
|
||||
import MainContainer from '@/packages/ui/src/MainContainer.vue';
|
||||
import { nextTick, onMounted, provide, ref } from 'vue';
|
||||
import { computed, nextTick, onMounted, provide, ref } from 'vue';
|
||||
import NotificationContainer from '@/Components/NotificationContainer.vue';
|
||||
import { initializeStores } from '@/utils/init';
|
||||
import { useCurrentTimeEntryStore } from '@/utils/useCurrentTimeEntry';
|
||||
@@ -39,11 +39,13 @@ import {
|
||||
canViewTags,
|
||||
} from '@/utils/permissions';
|
||||
import { isBillingActivated, isInvoicingActivated } from '@/utils/billing';
|
||||
import { upgradeCtaKey } from '@/packages/ui/src/UpgradeModal/useUpgradeCta';
|
||||
import type { User } from '@/types/models';
|
||||
import { ArrowsRightLeftIcon } from '@heroicons/vue/16/solid';
|
||||
import { fetchToken, isTokenValid } from '@/utils/session';
|
||||
import UpdateSidebarNotification from '@/Components/UpdateSidebarNotification.vue';
|
||||
import BillingBanner from '@/Components/Billing/BillingBanner.vue';
|
||||
import SidebarPlanStatus from '@/Components/Billing/SidebarPlanStatus.vue';
|
||||
import UserTimezoneMismatchModal from '@/Components/Common/User/UserTimezoneMismatchModal.vue';
|
||||
import { useTheme } from '@/utils/theme';
|
||||
import { useOrganizationQuery } from '@/utils/useOrganizationQuery';
|
||||
@@ -89,6 +91,11 @@ const { organization, isLoading: isOrganizationLoading } = useOrganizationQuery(
|
||||
|
||||
provide('organization', organization);
|
||||
|
||||
provide(upgradeCtaKey, {
|
||||
canUpgrade: computed(() => isBillingActivated() && canManageBilling()),
|
||||
goToBilling: () => router.visit('/billing'),
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
useTheme();
|
||||
// make sure that the initial requests are only loaded once, this can be removed once we move away from inertia
|
||||
@@ -306,6 +313,7 @@ const page = usePage<{
|
||||
</div>
|
||||
<div class="justify-self-end">
|
||||
<UpdateSidebarNotification></UpdateSidebarNotification>
|
||||
<SidebarPlanStatus v-if="isBillingActivated() && canManageBilling()" />
|
||||
<ul
|
||||
class="border-t border-default-background-separator pt-3 gap-1 flex justify-between items-center">
|
||||
<UserSettingsIcon></UserSettingsIcon>
|
||||
|
||||
@@ -8,9 +8,9 @@ import { computed, ref } from 'vue';
|
||||
import GoalTable from '@/Components/Common/Goal/GoalTable.vue';
|
||||
import GoalCreateModal from '@/Components/Common/Goal/GoalCreateModal.vue';
|
||||
import PageTitle from '@/Components/Common/PageTitle.vue';
|
||||
import UpgradeBadge from '@/Components/Common/UpgradeBadge.vue';
|
||||
import UpgradeBadge from '@/packages/ui/src/UpgradeBadge.vue';
|
||||
import { canCreateGoals } from '@/utils/permissions';
|
||||
import { canCreateMoreGoals, goalLimit } from '@/utils/billing';
|
||||
import { canCreateMoreGoals } from '@/utils/billing';
|
||||
import { useGoalsQuery } from '@/utils/useGoalsQuery';
|
||||
import { TabBar, TabBarItem } from '@/packages/ui/src';
|
||||
|
||||
@@ -25,7 +25,6 @@ const visibleGoals = computed(() =>
|
||||
);
|
||||
const archivedCount = computed(() => goals.value.filter((goal) => goal.is_archived).length);
|
||||
const canCreateMore = computed(() => canCreateMoreGoals(goals.value.length));
|
||||
const limit = goalLimit();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -43,10 +42,7 @@ const limit = goalLimit();
|
||||
</TabBar>
|
||||
</div>
|
||||
<div class="flex items-center space-x-3">
|
||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore">
|
||||
<strong>More than {{ limit }} goal{{ limit === 1 ? '' : 's' }}</strong> is only
|
||||
available in solidtime Professional.
|
||||
</UpgradeBadge>
|
||||
<UpgradeBadge v-if="canCreateGoals() && !canCreateMore" feature="goals" />
|
||||
<SecondaryButton
|
||||
v-if="canCreateGoals() && canCreateMore"
|
||||
:icon="PlusIcon"
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useMembersQuery } from '@/utils/useMembersQuery';
|
||||
import { useQueryClient } from '@tanstack/vue-query';
|
||||
import { getCurrentOrganizationId, getCurrentMembershipId } from '@/utils/useUser';
|
||||
import ReportingTabNavbar from '@/Components/Common/Reporting/ReportingTabNavbar.vue';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from '@/packages/ui/src/UpgradeModal.vue';
|
||||
import type { ExportFormat } from '@/types/reporting';
|
||||
import { useNotificationsStore } from '@/utils/notification';
|
||||
import TimeEntryMassActionRow from '@/packages/ui/src/TimeEntry/TimeEntryMassActionRow.vue';
|
||||
@@ -253,9 +253,7 @@ async function downloadExport(format: ExportFormat) {
|
||||
<ReportingExportModal
|
||||
v-model:show="showExportModal"
|
||||
:export-url="exportUrl"></ReportingExportModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal">
|
||||
<strong>PDF Reports</strong> are only available in solidtime Professional.
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showPremiumModal" feature="pdf-reports" />
|
||||
<MainContainer
|
||||
class="h-14 sm:h-16 border-b border-default-background-separator flex flex-wrap gap-y-3 justify-between items-center">
|
||||
<div class="flex items-center space-x-3 sm:space-x-6">
|
||||
|
||||
51
resources/js/Pages/Teams/Partials/OrganizationTrial.vue
Normal file
51
resources/js/Pages/Teams/Partials/OrganizationTrial.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, type ComputedRef } from 'vue';
|
||||
import { usePage } from '@inertiajs/vue3';
|
||||
import ActionSection from '@/Components/ActionSection.vue';
|
||||
import SecondaryButton from '@/packages/ui/src/Buttons/SecondaryButton.vue';
|
||||
import CancelTrialModal from '@/Components/Billing/CancelTrialModal.vue';
|
||||
import { daysLeftInTrial } from '@/utils/billing';
|
||||
import { formatDateLocalized } from '@/packages/ui/src/utils/time';
|
||||
import type { Organization } from '@/packages/api/src';
|
||||
|
||||
const showCancelTrialModal = ref(false);
|
||||
|
||||
const organization = inject<ComputedRef<Organization | undefined>>('organization');
|
||||
const page = usePage<{ billing: { trial_until: string | null } | null }>();
|
||||
const trialUntil = computed(() =>
|
||||
page.props.billing?.trial_until
|
||||
? formatDateLocalized(page.props.billing.trial_until, organization?.value?.date_format)
|
||||
: null
|
||||
);
|
||||
const daysLeftLabel = computed(() => {
|
||||
const days = daysLeftInTrial();
|
||||
return days === 1 ? '1 day' : `${days} days`;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ActionSection>
|
||||
<template #title>Trial</template>
|
||||
|
||||
<template #description>Your organization is trying out solidtime Professional.</template>
|
||||
|
||||
<template #content>
|
||||
<div class="max-w-xl text-sm text-text-secondary">
|
||||
Your trial ends in
|
||||
<span class="font-medium text-text-primary">{{ daysLeftLabel }}</span>
|
||||
<template v-if="trialUntil"> ({{ trialUntil }})</template>. If you don't need the
|
||||
Professional features, you can switch to the Free plan right away.
|
||||
</div>
|
||||
|
||||
<div class="mt-5">
|
||||
<SecondaryButton
|
||||
data-testid="cancel_trial_button"
|
||||
@click="showCancelTrialModal = true">
|
||||
Cancel trial
|
||||
</SecondaryButton>
|
||||
</div>
|
||||
|
||||
<CancelTrialModal v-model:show="showCancelTrialModal" />
|
||||
</template>
|
||||
</ActionSection>
|
||||
</template>
|
||||
@@ -8,6 +8,9 @@ import type { Permissions } from '@/types/jetstream';
|
||||
import OrganizationBillableRate from '@/Pages/Teams/Partials/OrganizationBillableRate.vue';
|
||||
import OrganizationFormatSettings from '@/Pages/Teams/Partials/OrganizationFormatSettings.vue';
|
||||
import OrganizationTimeEntrySettings from '@/Pages/Teams/Partials/OrganizationTimeEntrySettings.vue';
|
||||
import OrganizationTrial from '@/Pages/Teams/Partials/OrganizationTrial.vue';
|
||||
import { isBillingActivated, isInTrial } from '@/utils/billing';
|
||||
import { canManageBilling } from '@/utils/permissions';
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useOrganizationStore } from '@/utils/useOrganization';
|
||||
import { storeToRefs } from 'pinia';
|
||||
@@ -52,6 +55,11 @@ onMounted(async () => {
|
||||
<SectionBorder v-if="permissions.canUpdateTeam" />
|
||||
<OrganizationTimeEntrySettings v-if="permissions.canUpdateTeam" />
|
||||
|
||||
<template v-if="isBillingActivated() && isInTrial() && canManageBilling()">
|
||||
<SectionBorder />
|
||||
<OrganizationTrial />
|
||||
</template>
|
||||
|
||||
<template v-if="permissions.canDeleteTeam">
|
||||
<SectionBorder />
|
||||
<DeleteTeamForm class="mt-10 sm:mt-0" :team="team" />
|
||||
|
||||
@@ -5069,6 +5069,45 @@ If the group parameters are all set to `null` or are all missing, the
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'post',
|
||||
path: '/v1/organizations/:organization/trial/cancel',
|
||||
alias: 'cancelTrial',
|
||||
description: `The organization switches to the free plan right away.`,
|
||||
requestFormat: 'json',
|
||||
parameters: [
|
||||
{
|
||||
name: 'organization',
|
||||
type: 'Path',
|
||||
schema: z.string(),
|
||||
},
|
||||
],
|
||||
response: z.void(),
|
||||
errors: [
|
||||
{
|
||||
status: 400,
|
||||
description: `API exception`,
|
||||
schema: z
|
||||
.object({ error: z.boolean(), key: z.string(), message: z.string() })
|
||||
.passthrough(),
|
||||
},
|
||||
{
|
||||
status: 401,
|
||||
description: `Unauthenticated`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 403,
|
||||
description: `Authorization error`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
{
|
||||
status: 404,
|
||||
description: `Not found`,
|
||||
schema: z.object({ message: z.string() }).passthrough(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
method: 'get',
|
||||
path: '/v1/public/reports',
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/20/solid';
|
||||
import UpgradeModal from '@/Components/Common/UpgradeModal.vue';
|
||||
import UpgradeModal from './UpgradeModal.vue';
|
||||
import type { UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||
import { ref } from 'vue';
|
||||
|
||||
defineProps<{
|
||||
// The Professional feature behind the badge, picks the variant of the upgrade modal
|
||||
feature: UpgradeFeature;
|
||||
}>();
|
||||
|
||||
const showUpgradeModal = ref(false);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UpgradeModal v-model:show="showUpgradeModal">
|
||||
<slot>
|
||||
<strong>Project and Task Estimates</strong> is only available in solidtime Professional.
|
||||
</slot>
|
||||
</UpgradeModal>
|
||||
<UpgradeModal v-model:show="showUpgradeModal" :feature="feature" />
|
||||
<button
|
||||
data-testid="upgrade_badge"
|
||||
class="inline-flex bg-secondary hover:bg-tertiary px-2 py-1 rounded border border-border-secondary hover:border-border-tertiary items-center space-x-1"
|
||||
@click.prevent="showUpgradeModal = true">
|
||||
<LockClosedIcon class="w-3 text-text-tertiary"></LockClosedIcon>
|
||||
139
resources/js/packages/ui/src/UpgradeModal.vue
Normal file
139
resources/js/packages/ui/src/UpgradeModal.vue
Normal file
@@ -0,0 +1,139 @@
|
||||
<script setup lang="ts">
|
||||
import { Dialog, DialogContent, DialogTitle, DialogDescription } from './dialog';
|
||||
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||
import { computed, type Component } from 'vue';
|
||||
import MockupBillableRates from './UpgradeModal/MockupBillableRates.vue';
|
||||
import MockupEstimates from './UpgradeModal/MockupEstimates.vue';
|
||||
import MockupGoals from './UpgradeModal/MockupGoals.vue';
|
||||
import MockupInvoicing from './UpgradeModal/MockupInvoicing.vue';
|
||||
import MockupPdfReports from './UpgradeModal/MockupPdfReports.vue';
|
||||
import MockupShareableReports from './UpgradeModal/MockupShareableReports.vue';
|
||||
import UpgradeCtaFooter from './UpgradeModal/UpgradeCtaFooter.vue';
|
||||
import { featureIcons } from './UpgradeModal/featureIcons';
|
||||
import {
|
||||
professionalFeatures,
|
||||
type ProfessionalFeatureKey,
|
||||
type UpgradeFeature,
|
||||
} from './UpgradeModal/useUpgradeCta';
|
||||
|
||||
const show = defineModel<boolean>('show', { default: false });
|
||||
|
||||
const props = defineProps<{
|
||||
// The Professional feature the user tried to use, picks the mock-up and the copy
|
||||
feature: UpgradeFeature;
|
||||
}>();
|
||||
|
||||
const contents: Record<
|
||||
UpgradeFeature,
|
||||
{
|
||||
mockup: Component;
|
||||
pill: string;
|
||||
headline: string;
|
||||
body: string;
|
||||
// Feature of the pricing page that is listed first, if the feature is listed there
|
||||
featureKey?: ProfessionalFeatureKey;
|
||||
}
|
||||
> = {
|
||||
'billable-rates': {
|
||||
mockup: MockupBillableRates,
|
||||
pill: 'Unlock billable rates',
|
||||
headline: 'Turn tracked hours into revenue',
|
||||
body: 'Upgrade to solidtime Professional to track billable hours',
|
||||
featureKey: 'rates',
|
||||
},
|
||||
estimates: {
|
||||
mockup: MockupEstimates,
|
||||
pill: 'Unlock time estimates',
|
||||
headline: 'Keep every project on budget',
|
||||
body: 'Upgrade to solidtime Professional to set time estimates',
|
||||
featureKey: 'estimates',
|
||||
},
|
||||
goals: {
|
||||
mockup: MockupGoals,
|
||||
pill: 'Unlock unlimited goals',
|
||||
headline: 'Set a goal for everything that matters',
|
||||
body: 'Upgrade to solidtime Professional to track unlimited goals',
|
||||
},
|
||||
invoicing: {
|
||||
mockup: MockupInvoicing,
|
||||
pill: 'Unlock invoicing',
|
||||
headline: 'Get paid for every tracked hour',
|
||||
body: 'Upgrade to solidtime Professional to create invoices',
|
||||
featureKey: 'invoicing',
|
||||
},
|
||||
'pdf-reports': {
|
||||
mockup: MockupPdfReports,
|
||||
pill: 'Unlock PDF reports',
|
||||
headline: 'Send reports your clients will read',
|
||||
body: 'Upgrade to solidtime Professional to export PDF reports',
|
||||
featureKey: 'pdf',
|
||||
},
|
||||
'shareable-reports': {
|
||||
mockup: MockupShareableReports,
|
||||
pill: 'Unlock shareable reports',
|
||||
headline: 'Share live reports with one link',
|
||||
body: 'Upgrade to solidtime Professional to share reports',
|
||||
featureKey: 'shared',
|
||||
},
|
||||
};
|
||||
|
||||
const content = computed(() => contents[props.feature]);
|
||||
|
||||
// The feature the user tried to use comes first
|
||||
const orderedFeatures = computed(() => [
|
||||
...professionalFeatures.filter((feature) => feature.key === content.value.featureKey),
|
||||
...professionalFeatures.filter((feature) => feature.key !== content.value.featureKey),
|
||||
]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog v-model:open="show">
|
||||
<DialogContent class="sm:max-w-xl p-0 overflow-hidden">
|
||||
<div class="relative">
|
||||
<component :is="content.mockup" />
|
||||
<div
|
||||
class="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 translate-y-1/2 whitespace-nowrap">
|
||||
<div
|
||||
class="flex items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 py-1 dark:border-border-secondary dark:bg-default-background text-xs font-medium text-text-primary shadow-sm">
|
||||
<LockClosedIcon class="h-3.5 w-3.5 text-accent-500 dark:text-accent-300" />
|
||||
{{ content.pill }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 pt-9 text-center">
|
||||
<DialogTitle class="text-xl font-semibold tracking-tight text-text-primary">
|
||||
{{ content.headline }}
|
||||
</DialogTitle>
|
||||
<DialogDescription class="mt-1.5 text-sm text-text-secondary">
|
||||
{{ content.body }}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-x-6 gap-y-4 px-8 pt-9">
|
||||
<div v-for="item in orderedFeatures" :key="item.key" class="flex gap-3">
|
||||
<div
|
||||
class="flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-border-tertiary bg-card-background">
|
||||
<component
|
||||
:is="featureIcons[item.key]"
|
||||
class="h-3.5 w-3.5 text-text-secondary" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<p class="text-sm font-medium text-text-primary">{{ item.title }}</p>
|
||||
<p class="mt-0.5 text-xs leading-relaxed text-text-tertiary">
|
||||
{{ item.description }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 pt-9 pb-6">
|
||||
<UpgradeCtaFooter
|
||||
align="stack"
|
||||
label="Upgrade to Professional"
|
||||
class="mx-auto max-w-xs"
|
||||
@close="show = false" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||
import MockupFrame from './MockupFrame.vue';
|
||||
|
||||
const previewRows = [
|
||||
{ name: 'Acme Corp', duration: '42h 30min', cost: '€ 5,100.00' },
|
||||
{ name: 'Globex', duration: '27h 15min', cost: '€ 3,270.00' },
|
||||
{ name: 'Initech', duration: '12h 45min', cost: '€ 1,530.00' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MockupFrame :glow-in-light-mode="false">
|
||||
<div class="px-6 pt-4">
|
||||
<div
|
||||
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
|
||||
<span>Client</span>
|
||||
<span class="text-right">Duration</span>
|
||||
<span class="flex items-center justify-end gap-1">
|
||||
<LockClosedIcon class="h-3 w-3" />
|
||||
Cost
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in previewRows"
|
||||
:key="row.name"
|
||||
class="grid grid-cols-[1fr_100px_100px] border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
|
||||
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
|
||||
<span class="text-right tabular-nums text-gray-500 dark:text-white/50">{{
|
||||
row.duration
|
||||
}}</span>
|
||||
<span
|
||||
class="select-none text-right font-medium text-accent-600 blur-[5px] dark:text-accent-300"
|
||||
>{{ row.cost }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</MockupFrame>
|
||||
</template>
|
||||
@@ -0,0 +1,48 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||
import MockupFrame from './MockupFrame.vue';
|
||||
|
||||
const previewRows = [
|
||||
{ name: 'Website Redesign', spent: '32h', estimate: '40h', progress: 80, muted: false },
|
||||
{ name: 'Mobile App', spent: '11h', estimate: '20h', progress: 55, muted: true },
|
||||
{ name: 'Support Retainer', spent: '6h', estimate: '20h', progress: 30, muted: false },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MockupFrame :glow-in-light-mode="false">
|
||||
<div class="px-6 pt-4">
|
||||
<div
|
||||
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
|
||||
<span>Project</span>
|
||||
<span class="flex items-center gap-1">
|
||||
<LockClosedIcon class="h-3 w-3" />
|
||||
Progress
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="row in previewRows"
|
||||
:key="row.name"
|
||||
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
|
||||
<span class="text-gray-800 dark:text-white/90">{{ row.name }}</span>
|
||||
<div class="flex select-none items-center gap-2.5 blur-[3px]">
|
||||
<div
|
||||
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
|
||||
<div
|
||||
class="h-full rounded-full"
|
||||
:class="
|
||||
row.muted
|
||||
? 'bg-gray-400 dark:bg-white/40'
|
||||
: 'bg-accent-500 dark:bg-accent-300'
|
||||
"
|
||||
:style="{ width: row.progress + '%' }" />
|
||||
</div>
|
||||
<span
|
||||
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
|
||||
>{{ row.spent }} / {{ row.estimate }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MockupFrame>
|
||||
</template>
|
||||
23
resources/js/packages/ui/src/UpgradeModal/MockupFrame.vue
Normal file
23
resources/js/packages/ui/src/UpgradeModal/MockupFrame.vue
Normal file
@@ -0,0 +1,23 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
// Show the blue glow in light mode too, dark mode always has it
|
||||
glowInLightMode?: boolean;
|
||||
}>(),
|
||||
{ glowInLightMode: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Note: Shared header of the upgrade modal, all mock-ups have the same height -->
|
||||
<div
|
||||
class="relative h-[156px] overflow-hidden border-b border-border-tertiary bg-background dark:border-white/10 dark:bg-[#0b1220]">
|
||||
<!-- Note: Glow rises from behind the divider, around the pill -->
|
||||
<div
|
||||
:class="glowInLightMode ? '' : 'hidden dark:block'"
|
||||
class="pointer-events-none absolute -bottom-32 left-1/2 h-56 w-[30rem] -translate-x-1/2 rounded-full bg-accent-400/25 blur-3xl dark:bg-accent-500/[0.18]" />
|
||||
<div class="relative h-full" aria-hidden="true">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
54
resources/js/packages/ui/src/UpgradeModal/MockupGoals.vue
Normal file
54
resources/js/packages/ui/src/UpgradeModal/MockupGoals.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||
import MockupFrame from './MockupFrame.vue';
|
||||
|
||||
// Note: The first goal is the one of the free plan, the others are locked
|
||||
const previewGoals = [
|
||||
{
|
||||
name: 'Billable hours per month',
|
||||
tracked: '62h',
|
||||
target: '80h',
|
||||
progress: 78,
|
||||
locked: false,
|
||||
},
|
||||
{ name: 'Deep work per week', tracked: '14h', target: '20h', progress: 70, locked: true },
|
||||
{ name: 'Support per week', tracked: '3h', target: '8h', progress: 40, locked: true },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MockupFrame :glow-in-light-mode="false">
|
||||
<div class="px-6 pt-4">
|
||||
<div
|
||||
class="grid grid-cols-[1fr_170px] border-b border-gray-300/70 pb-1.5 text-[11px] text-gray-400 dark:border-white/10 dark:text-white/40">
|
||||
<span>Goal</span>
|
||||
<span>Progress</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="goal in previewGoals"
|
||||
:key="goal.name"
|
||||
class="grid grid-cols-[1fr_170px] items-center border-b border-gray-300/50 py-1.5 text-sm last:border-b-0 dark:border-white/5">
|
||||
<span class="flex items-center gap-1.5 text-gray-800 dark:text-white/90">
|
||||
<LockClosedIcon
|
||||
v-if="goal.locked"
|
||||
class="h-3 w-3 text-gray-400 dark:text-white/40" />
|
||||
{{ goal.name }}
|
||||
</span>
|
||||
<div
|
||||
class="flex select-none items-center gap-2.5"
|
||||
:class="goal.locked ? 'blur-[3px]' : ''">
|
||||
<div
|
||||
class="h-1.5 flex-1 overflow-hidden rounded-full bg-gray-300/60 dark:bg-white/10">
|
||||
<div
|
||||
class="h-full rounded-full bg-accent-500 dark:bg-accent-300"
|
||||
:style="{ width: goal.progress + '%' }" />
|
||||
</div>
|
||||
<span
|
||||
class="w-16 text-right text-xs tabular-nums text-gray-500 dark:text-white/60"
|
||||
>{{ goal.tracked }} / {{ goal.target }}</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MockupFrame>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import MockupFrame from './MockupFrame.vue';
|
||||
|
||||
const lineItems = [
|
||||
{ name: 'Website Redesign', hours: '32h', amount: '€ 3,840.00' },
|
||||
{ name: 'Mobile App', hours: '18h', amount: '€ 2,160.00' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MockupFrame>
|
||||
<!-- Note: An invoice created from tracked time, it runs off the bottom edge of the header -->
|
||||
<div
|
||||
class="absolute left-1/2 top-5 w-60 -translate-x-1/2 rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold text-gray-900">Invoice 2026-014</p>
|
||||
<p class="text-[9px] text-gray-500">Acme Corp · Due November 14</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-accent-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
|
||||
>SENT</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-3 space-y-1 text-[9px] text-gray-600">
|
||||
<div v-for="item in lineItems" :key="item.name" class="flex justify-between">
|
||||
<span>{{ item.name }} · {{ item.hours }}</span>
|
||||
<span class="tabular-nums">{{ item.amount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mt-2 flex justify-between border-t border-gray-200 pt-1.5 text-[10px] font-semibold text-gray-900">
|
||||
<span>Total</span>
|
||||
<span class="tabular-nums">€ 6,000.00</span>
|
||||
</div>
|
||||
</div>
|
||||
</MockupFrame>
|
||||
</template>
|
||||
35
resources/js/packages/ui/src/UpgradeModal/MockupPdfPage.vue
Normal file
35
resources/js/packages/ui/src/UpgradeModal/MockupPdfPage.vue
Normal file
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
const bars = [45, 70, 55, 85, 65, 35, 50];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Note: A PDF page that runs off the bottom edge of the header -->
|
||||
<div
|
||||
class="rounded-t-md border border-b-0 border-gray-200 bg-white px-5 pt-4 pb-10 shadow-xl shadow-gray-400/25 dark:border-transparent dark:shadow-2xl dark:shadow-black/50">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<p class="text-[11px] font-semibold text-gray-900">Time report</p>
|
||||
<p class="text-[9px] text-gray-500">Acme Corp · October 2026</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded bg-red-500 px-1.5 py-0.5 text-[8px] font-bold tracking-wide text-white"
|
||||
>PDF</span
|
||||
>
|
||||
</div>
|
||||
<div class="mt-2.5 flex h-7 items-end gap-1">
|
||||
<div
|
||||
v-for="(height, index) in bars"
|
||||
:key="index"
|
||||
class="flex-1 rounded-sm bg-accent-500/70"
|
||||
:style="{ height: height + '%' }" />
|
||||
</div>
|
||||
<div class="mt-2 space-y-1 text-[9px] text-gray-600">
|
||||
<div class="flex justify-between">
|
||||
<span>Website Redesign</span><span class="tabular-nums">32h 15min</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Mobile App</span><span class="tabular-nums">18h 40min</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import MockupFrame from './MockupFrame.vue';
|
||||
import MockupPdfPage from './MockupPdfPage.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MockupFrame>
|
||||
<!-- Note: The PDF report with two more pages fanned out behind it -->
|
||||
<div class="absolute left-1/2 top-5 h-full w-56 -translate-x-1/2">
|
||||
<div
|
||||
class="absolute inset-x-0 top-3 h-48 origin-bottom -translate-x-10 -rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
|
||||
<div
|
||||
class="absolute inset-x-0 top-3 h-48 origin-bottom translate-x-10 rotate-[8deg] rounded-md border border-gray-200 bg-white/80 shadow-lg shadow-gray-400/20 dark:border-transparent dark:bg-white/70 dark:shadow-xl dark:shadow-black/40" />
|
||||
<MockupPdfPage class="absolute inset-x-0 top-0" />
|
||||
</div>
|
||||
</MockupFrame>
|
||||
</template>
|
||||
@@ -0,0 +1,50 @@
|
||||
<script setup lang="ts">
|
||||
import { LinkIcon, LockClosedIcon } from '@heroicons/vue/16/solid';
|
||||
import MockupFrame from './MockupFrame.vue';
|
||||
|
||||
const previewRows = [
|
||||
{ name: 'Website Redesign', duration: '32h 15min' },
|
||||
{ name: 'Mobile App', duration: '18h 40min' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MockupFrame>
|
||||
<!-- Note: A browser window showing the shared report, it runs into the bottom edge -->
|
||||
<div
|
||||
class="absolute inset-x-10 top-4 -bottom-px rounded-t-lg border border-b-0 border-gray-300/80 bg-white shadow-xl shadow-gray-400/20 dark:border-white/10 dark:bg-white/[0.04] dark:shadow-2xl dark:shadow-black/40">
|
||||
<div
|
||||
class="flex items-center gap-2 border-b border-gray-200 px-3 py-2 dark:border-white/10">
|
||||
<div class="flex gap-1">
|
||||
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
|
||||
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
|
||||
<span class="h-2 w-2 rounded-full bg-gray-300 dark:bg-white/15" />
|
||||
</div>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 items-center gap-1.5 rounded-md bg-gray-100 px-2 py-1 text-[11px] text-gray-500 dark:bg-black/30 dark:text-white/50">
|
||||
<LockClosedIcon class="h-3 w-3 shrink-0 text-gray-400 dark:text-white/30" />
|
||||
<span class="truncate">app.solidtime.io/shared-report</span>
|
||||
</div>
|
||||
<span
|
||||
class="flex shrink-0 items-center gap-1 rounded-md border border-gray-200 bg-gray-50 px-2 py-1 text-[11px] font-medium text-gray-700 dark:border-transparent dark:bg-white/15 dark:text-white/90">
|
||||
<LinkIcon class="h-3 w-3" />
|
||||
Copy link
|
||||
</span>
|
||||
</div>
|
||||
<div class="px-4 pt-2 pb-1.5">
|
||||
<p class="py-1.5 text-xs font-medium text-gray-900 dark:text-white/90">
|
||||
Acme Corp · October
|
||||
</p>
|
||||
<div
|
||||
v-for="row in previewRows"
|
||||
:key="row.name"
|
||||
class="flex justify-between border-b border-gray-100 py-1 text-xs last:border-b-0 dark:border-white/5">
|
||||
<span class="text-gray-600 dark:text-white/70">{{ row.name }}</span>
|
||||
<span class="tabular-nums text-gray-400 dark:text-white/50">{{
|
||||
row.duration
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</MockupFrame>
|
||||
</template>
|
||||
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { ArrowTopRightOnSquareIcon } from '@heroicons/vue/16/solid';
|
||||
import { pricingUrl, useUpgradeCta } from './useUpgradeCta';
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
label?: string;
|
||||
align?: 'between' | 'end' | 'stack';
|
||||
showCompare?: boolean;
|
||||
}>(),
|
||||
{ label: 'Upgrade to Professional', align: 'between', showCompare: true }
|
||||
);
|
||||
|
||||
const emit = defineEmits<{ close: [] }>();
|
||||
const { canUpgrade, goToBilling } = useUpgradeCta();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="
|
||||
props.align === 'stack'
|
||||
? 'flex flex-col items-center gap-3'
|
||||
: 'flex flex-wrap items-center gap-2'
|
||||
">
|
||||
<a
|
||||
v-if="props.align !== 'stack' && props.showCompare"
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="mr-auto inline-flex items-center gap-1 text-xs text-text-tertiary transition-colors hover:text-text-primary">
|
||||
Compare plans
|
||||
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
|
||||
</a>
|
||||
<template v-if="canUpgrade">
|
||||
<button
|
||||
v-if="props.align !== 'stack'"
|
||||
type="button"
|
||||
class="h-9 whitespace-nowrap rounded-md px-3 text-sm font-medium text-text-secondary transition-colors hover:bg-white/5 hover:text-text-primary"
|
||||
@click="emit('close')">
|
||||
Not now
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="upgrade_modal_cta"
|
||||
class="h-9 whitespace-nowrap rounded-md bg-accent-500 px-4 text-sm font-semibold text-white shadow-[0_0_24px_-6px] shadow-accent-500/60 transition-colors hover:bg-accent-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
:class="props.align === 'stack' ? 'w-full' : ''"
|
||||
@click="goToBilling">
|
||||
{{ props.label }}
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="text-xs text-text-tertiary">Only the organization owner can upgrade.</span>
|
||||
<button
|
||||
type="button"
|
||||
class="h-9 rounded-md border border-border-secondary px-3 text-sm font-medium text-text-primary transition-colors hover:bg-white/5"
|
||||
@click="emit('close')">
|
||||
Got it
|
||||
</button>
|
||||
</template>
|
||||
<div v-if="props.align === 'stack'" class="flex items-center gap-4 text-xs">
|
||||
<button
|
||||
v-if="canUpgrade"
|
||||
type="button"
|
||||
class="text-text-tertiary transition-colors hover:text-text-primary"
|
||||
@click="emit('close')">
|
||||
Not now
|
||||
</button>
|
||||
<a
|
||||
:href="pricingUrl"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="inline-flex items-center gap-1 text-text-tertiary transition-colors hover:text-text-primary">
|
||||
Compare plans
|
||||
<ArrowTopRightOnSquareIcon class="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
19
resources/js/packages/ui/src/UpgradeModal/featureIcons.ts
Normal file
19
resources/js/packages/ui/src/UpgradeModal/featureIcons.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import type { Component } from 'vue';
|
||||
import {
|
||||
AdjustmentsHorizontalIcon,
|
||||
ClockIcon,
|
||||
CurrencyEuroIcon,
|
||||
DocumentArrowDownIcon,
|
||||
DocumentTextIcon,
|
||||
LinkIcon,
|
||||
} from '@heroicons/vue/16/solid';
|
||||
import type { ProfessionalFeatureKey } from './useUpgradeCta';
|
||||
|
||||
export const featureIcons: Record<ProfessionalFeatureKey, Component> = {
|
||||
rates: CurrencyEuroIcon,
|
||||
invoicing: DocumentTextIcon,
|
||||
estimates: ClockIcon,
|
||||
rounding: AdjustmentsHorizontalIcon,
|
||||
pdf: DocumentArrowDownIcon,
|
||||
shared: LinkIcon,
|
||||
};
|
||||
72
resources/js/packages/ui/src/UpgradeModal/useUpgradeCta.ts
Normal file
72
resources/js/packages/ui/src/UpgradeModal/useUpgradeCta.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { computed, inject, type ComputedRef, type InjectionKey } from 'vue';
|
||||
|
||||
export interface UpgradeCta {
|
||||
canUpgrade: ComputedRef<boolean>;
|
||||
goToBilling: () => void;
|
||||
}
|
||||
|
||||
export const upgradeCtaKey: InjectionKey<UpgradeCta> = Symbol('upgradeCta');
|
||||
|
||||
/**
|
||||
* Shared call to action for the upgrade modal variations: members who can manage billing are
|
||||
* sent to the billing page, everyone else is asked to contact the organization owner.
|
||||
*
|
||||
* The app layout provides who can upgrade and how to get to the billing page. Without a
|
||||
* provider (e.g. other consumers of this package) nobody can upgrade from the modal.
|
||||
*/
|
||||
export function useUpgradeCta(): UpgradeCta {
|
||||
return inject(upgradeCtaKey, {
|
||||
canUpgrade: computed(() => false),
|
||||
goToBilling: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
// Note: Copy mirrors https://www.solidtime.io/pricing so the gate and the pricing page match.
|
||||
export const pricingUrl = 'https://www.solidtime.io/pricing';
|
||||
|
||||
// Professional features the upgrade modal has a variant for
|
||||
export type UpgradeFeature =
|
||||
'billable-rates' | 'estimates' | 'goals' | 'invoicing' | 'pdf-reports' | 'shareable-reports';
|
||||
|
||||
export type ProfessionalFeatureKey =
|
||||
'rates' | 'invoicing' | 'estimates' | 'rounding' | 'pdf' | 'shared';
|
||||
|
||||
export type ProfessionalFeature = {
|
||||
key: ProfessionalFeatureKey;
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
// Note: Billing and reporting features of the Professional plan on https://www.solidtime.io/pricing
|
||||
export const professionalFeatures: ProfessionalFeature[] = [
|
||||
{
|
||||
key: 'rates',
|
||||
title: 'Billable rates',
|
||||
description: 'Set rates per organization, project or member.',
|
||||
},
|
||||
{
|
||||
key: 'invoicing',
|
||||
title: 'Invoicing',
|
||||
description: 'Create invoices straight from your tracked time.',
|
||||
},
|
||||
{
|
||||
key: 'estimates',
|
||||
title: 'Time estimates',
|
||||
description: 'Budget hours per project and task and see what is left.',
|
||||
},
|
||||
{
|
||||
key: 'rounding',
|
||||
title: 'Rounding',
|
||||
description: 'Round time in reports, e.g. to 15 minutes.',
|
||||
},
|
||||
{
|
||||
key: 'pdf',
|
||||
title: 'PDF reports',
|
||||
description: 'Export clean reports to send to clients.',
|
||||
},
|
||||
{
|
||||
key: 'shared',
|
||||
title: 'Shareable reports',
|
||||
description: 'Share a live report link, no login needed.',
|
||||
},
|
||||
];
|
||||
@@ -21,6 +21,8 @@ export type { CalendarSettings } from './FullCalendar/calendarSettings';
|
||||
export type { ActivityPeriod } from './FullCalendar/activityTypes';
|
||||
export { cn } from './utils/cn';
|
||||
export { useCssVariable } from './utils/useCssVariable';
|
||||
export { upgradeCtaKey, useUpgradeCta } from './UpgradeModal/useUpgradeCta';
|
||||
export type { UpgradeCta, UpgradeFeature } from './UpgradeModal/useUpgradeCta';
|
||||
|
||||
import Badge from './Badge.vue';
|
||||
import Button from './Buttons/Button.vue';
|
||||
@@ -162,6 +164,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from './tabs/index';
|
||||
import TabBar from './TabBar/TabBar.vue';
|
||||
import TabBarItem from './TabBar/TabBarItem.vue';
|
||||
import TimezoneMismatchModal from './TimezoneMismatchModal.vue';
|
||||
import UpgradeBadge from './UpgradeBadge.vue';
|
||||
import UpgradeModal from './UpgradeModal.vue';
|
||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './tooltip/index';
|
||||
|
||||
export {
|
||||
@@ -305,4 +309,6 @@ export {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
UpgradeBadge,
|
||||
UpgradeModal,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user