Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Allow trial team to be manually created #4941

Merged
merged 10 commits into from
Dec 19, 2024
7 changes: 7 additions & 0 deletions forge/db/models/Team.js
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,13 @@ module.exports = {
}]
})
},
countForUser: async function (User) {
return M.TeamMember.count({
where: {
UserId: User.id
}
})
},
forUser: async function (User) {
return M.TeamMember.findAll({
where: {
Expand Down
2 changes: 1 addition & 1 deletion forge/lib/userTeam.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ async function completeUserSignup (app, user) {
// return
}
// only create a personal team if no other teams exist
if (!((await app.db.models.Team.forUser(user)).length)) {
if ((await app.db.models.Team.countForUser(user)) === 0) {
let teamTypeId = app.settings.get('user:team:auto-create:teamType')

if (!teamTypeId) {
Expand Down
73 changes: 69 additions & 4 deletions forge/routes/api/team.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
const crypto = require('crypto')
knolleary marked this conversation as resolved.
Show resolved Hide resolved

const { Op } = require('sequelize')

const { Roles } = require('../../lib/roles')
Expand Down Expand Up @@ -395,7 +397,8 @@ module.exports = async function (app) {
properties: {
name: { type: 'string' },
type: { type: 'string' },
slug: { type: 'string' }
slug: { type: 'string' },
trial: { type: 'boolean' }
}
},
response: {
Expand Down Expand Up @@ -429,6 +432,34 @@ module.exports = async function (app) {
return
}

let trialMode = false
if (app.license.active() && app.billing && request.body.trial) {
// Check this user is allowed to create a trial team of this type.
// Rules:
// 1. teamType must have trial mode enabled
const teamTrialActive = await teamType.getProperty('trial.active', false)
if (!teamTrialActive) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}
// 2. user must have no existing teams
const existingTeamCount = await app.db.models.Team.countForUser(request.session.User)
if (existingTeamCount > 0) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}
// 3. user must be < 1 week old
const delta = Date.now() - request.session.User.createdAt.getTime()
if (delta > 1000 * 60 * 60 * 24 * 7) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}
trialMode = true
} else if (request.body.trial) {
reply.code(400).send({ code: 'invalid_request', error: 'trial mode not available' })
return
}

let team

try {
Expand All @@ -444,9 +475,43 @@ module.exports = async function (app) {
const teamView = app.db.views.Team.team(team)

if (app.license.active() && app.billing) {
const session = await app.billing.createSubscriptionSession(team, request.session.User)
app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
teamView.billingURL = session.url
if (trialMode) {
await app.billing.setupTrialTeamSubscription(team, request.session.User)
// In trial mode, we may also auto-create their first application and instance
if (app.settings.get('user:team:auto-create:instanceType')) {
const instanceTypeId = app.settings.get('user:team:auto-create:instanceType')
const instanceType = await app.db.models.ProjectType.byId(instanceTypeId)
const instanceStack = await instanceType?.getDefaultStack() || (await instanceType.getProjectStacks())?.[0]
const instanceTemplate = await app.db.models.ProjectTemplate.findOne({ where: { active: true } })
if (!instanceType) {
app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Instance type with id ${instanceTypeId} from 'user:team:auto-create:instanceType' not found`)
} else if (!instanceStack) {
app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Unable to find a stack for use with instance type ${instanceTypeId}`)
} else if (!instanceTemplate) {
app.log.warn(`Unable to create Trial Instance in team ${team.hashid}: Unable to find the default instance template`)
} else {
const applicationName = `${request.session.User.name}'s Application`
const application = await app.db.models.Application.create({
name: applicationName.charAt(0).toUpperCase() + applicationName.slice(1),
TeamId: team.id
})
await app.auditLog.Team.application.created(request.session.User, null, team, application)
await app.auditLog.Application.application.created(request.session.User, null, application)

const safeTeamName = team.name.toLowerCase().replace(/[\W_]/g, '-')
const safeUserName = request.session.User.username.toLowerCase().replace(/[\W_]/g, '-')

const instanceProperties = {
name: `${safeTeamName}-${safeUserName}-${crypto.randomBytes(4).toString('hex')}`
}
await app.db.controllers.Project.create(team, application, request.session.User, instanceType, instanceStack, instanceTemplate, instanceProperties)
}
}
} else {
const session = await app.billing.createSubscriptionSession(team, request.session.User)
app.auditLog.Team.billing.session.created(request.session.User, null, team, session)
teamView.billingURL = session.url
}
}

reply.send(teamView)
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/PageHeader.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
<router-link :to="homeLink">
<img class="ff-logo" src="/ff-logo--wordmark--dark.png">
</router-link>
<global-search v-if="hasAMinimumTeamRoleOf(Roles.Viewer)" />
<global-search v-if="teams.length > 0 && hasAMinimumTeamRoleOf(Roles.Viewer)" />
<!-- Mobile: Toggle(User Options) -->
<div class="flex ff-mobile-navigation-right" data-el="mobile-nav-right">
<NotificationsButton class="ff-header--mobile-notificationstoggle" :class="{'active': mobileTeamSelectionOpen}" />
Expand Down
34 changes: 34 additions & 0 deletions frontend/src/components/TeamTypeSelection.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<template>
<div>
<div class="flex gap-6 justify-center relative z-10 flex-wrap">
<team-type-tile v-for="type in types" :key="type.id" :team-type="type" />
</div>
</div>
</template>

<script>
import { mapState } from 'vuex'

import teamTypesApi from '../api/teamTypes.js'

import TeamTypeTile from './TeamTypeTile.vue'

export default {
name: 'TeamTypeSelection',
components: {
'team-type-tile': TeamTypeTile
},
data () {
return {
types: []
}
},
computed: {
...mapState('account', ['user'])
},
async created () {
const { types } = await teamTypesApi.getTeamTypes()
this.types = types.sort((a, b) => a.order - b.order)
}
}
</script>
143 changes: 143 additions & 0 deletions frontend/src/components/TeamTypeTile.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<template>
<div class="ff-team-type-tile">
<div v-if="isTrial(teamType)" class="trial-ribbon">
<label>{{ teamType.properties.trial.duration }} Days Free Trial</label>
</div>
<div class="space-y-2">
<img class="w-36 m-auto" src="../images/empty-states/application-instances.png">
<div class="flex flex-col gap-5">
<div class="flex justify-between items-center text-2xl">
<label class="font-medium">{{ teamType.name }}</label>
<span v-if="pricing?.value">
{{ pricing.value }} <span class="text-xs">/{{ pricing.interval }}</span>
</span>
</div>
<ff-markdown-viewer :content="teamType.description" />
</div>
</div>
<template v-if="enableCTA">
<ff-button v-if="isTrial(teamType)" kind="primary" class="w-full mt-4" :to="`/team/create?teamType=${teamType.id}`">
Start Free Trial
</ff-button>
<ff-button v-else-if="isManualBilling(teamType)" kind="secondary" class="w-full mt-4" @click="contactFF(teamType)">
Contact FlowFuse
</ff-button>
<ff-button v-else kind="secondary" class="w-full mt-4" :to="`/team/create?teamType=${teamType.id}`">
Select
</ff-button>
</template>
</div>
</template>

<script>
import { mapState } from 'vuex'
import BillingAPI from '../api/billing.js'
import Alerts from '../services/alerts.js'
export default {
name: 'TeamTypeTile',
props: {
teamType: {
type: Object,
required: true
},
enableCTA: {
type: Boolean,
default: true
}
},
computed: {
...mapState('account', ['user', 'teams']),
pricing: function () {
const billing = this.teamType.properties?.billing.description?.split('/')
const price = {}
if (typeof billing !== 'undefined') {
price.value = billing[0]
price.interval = billing[1]
}
return price
}
},
methods: {
isTrial (teamType) {
// A team trial can be offered if:
// 1. User has no other teams
return this.teams.length === 0 &&
// 2. User is less than a week old
(Date.now() - (new Date(this.user.createdAt)).getTime()) < 1000 * 60 * 60 * 24 * 7 &&
// 3. TeamType meta data says so
teamType.properties?.trial?.active
},
isManualBilling (teamType) {
return teamType.properties?.billing?.requireContact
},
contactFF (teamType) {
BillingAPI.sendTeamTypeContact(this.user, teamType, 'Create Team').then(() => {
Alerts.emit('A message has been sent to our team. We will contact you soon regarding your request. In the mean time, feel free to choose another plan to get started.', 'confirmation', 20000)
}).catch(err => {
Alerts.emit('Something went wrong with the request. Please try again or contact support for help.', 'warning', 15000)
console.error('Failed to submit hubspot form: ', err)
})
}
}
}
</script>
<style lang="scss">
.ff-team-type-tile {
position: relative;
border-radius: 6px;
border: 2px solid $ff-grey-300;
background: white;
box-shadow: 0px 4px 8px 0px rgba(0, 0, 0, 0.25);
padding: 24px;
width: 100%;
max-width: 300px;
display: flex;
flex-direction: column;
justify-content: space-between;
ul {
list-style: disc;
padding-left: 16px;
li {
margin-bottom: 6px;
}
}
}
.trial-ribbon {
--ribbon-overlap: 8px;
display: flex;
justify-content: center;
align-items: center;
height: 30px;
left: calc(-1 * var(--ribbon-overlap));
line-height: 1.3;
width: calc(100% + 2 * var(--ribbon-overlap));
margin: 0;
position: absolute;
top: 8px;
color: white;
// text-shadow: 0 1px 1px #111;
border-top: 1px solid #363636;
border-bottom: 1px solid #202020;
background: $ff-red-500;
// background: linear-gradient($ff-red-500 0%, $ff-red-700 100%);
border-radius: 2px 2px 0 0;
box-shadow: 0 1px 2px rgba(0,0,0,0.3);
}
.trial-ribbon::before,
.trial-ribbon::after {
content: '';
display: block;
width: 0;
height: 0;
position: absolute;
bottom: calc((-2 * var(--ribbon-overlap)) - 1px);
z-index: -10;
border: var(--ribbon-overlap) solid;
border-color: $ff-red-900 transparent transparent transparent;
}
.trial-ribbon::before {left: 0;}
.trial-ribbon::after {right: 0;}
</style>
19 changes: 13 additions & 6 deletions frontend/src/pages/Home.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
<template>
<main>
<main class="min-h-full">
<template v-if="pending">
<div class="flex-grow flex flex-col items-center justify-center mx-auto text-gray-600 opacity-50">
<FlowFuseLogo class="max-w-xs mx-auto w-full" />
</div>
</template>
<template v-else-if="teams.length === 0">
<NoTeamsUser />
</template>
<ff-page v-else-if="teams.length === 0">
<template #header>
<ff-page-header title="Choose Team Type">
<template #context>
Choose which team type you'd like to get started with.
</template>
</ff-page-header>
</template>
<TeamTypeSelection />
</ff-page>
</main>
</template>

Expand All @@ -17,13 +24,13 @@ import { mapGetters, mapState } from 'vuex'
import FlowFuseLogo from '../components/Logo.vue'
import NoTeamsUser from './account/NoTeamsUser.vue'
import TeamTypeSelection from '../components/TeamTypeSelection.vue'
export default {
name: 'HomePage',
components: {
FlowFuseLogo,
NoTeamsUser
TeamTypeSelection
},
data () {
return {
Expand Down
44 changes: 0 additions & 44 deletions frontend/src/pages/account/NoTeamsUser.vue

This file was deleted.

Loading
Loading