fix: implemented Billing, Plans, Invoices in sidebar - body layout

This commit is contained in:
Shariq Ansari 2024-11-13 12:08:30 +05:30
parent 92920bc232
commit 7b7eafc188
47 changed files with 3459 additions and 5 deletions

View file

@ -9,9 +9,12 @@
"serve": "vite preview"
},
"dependencies": {
"@stripe/stripe-js": "^1.3.0",
"@vueuse/core": "^11.2.0",
"frappe-ui": "^v0.1.72",
"tailwindcss": "^3.3.3",
"vue": "^3.4.12"
"vue": "^3.4.12",
"vue-router": "^4.2.2"
},
"devDependencies": {
"@vitejs/plugin-vue": "^4.2.3",

View file

@ -1,7 +1,18 @@
<template>
<div><Button label="Click Me" /></div>
<div class="flex h-screen w-screen">
<div class="h-full border-r bg-gray-50">
<AppSidebar />
</div>
<div class="flex-1 flex flex-col h-full overflow-auto px-28">
<router-view />
</div>
</div>
<Dialogs />
<Toasts />
</template>
<script setup>
import { Button } from 'frappe-ui'
import AppSidebar from '@/components/AppSidebar.vue'
import { Dialogs } from '@/dialogs.js'
import { Toasts } from 'frappe-ui'
</script>

View file

@ -0,0 +1,35 @@
<template>
<Dialog v-model="show" :options="{ title: 'Add new card' }">
<template #body-content>
<div
v-if="showMessage"
class="inline-flex gap-1.5 text-base mb-5 text-gray-700"
>
<FeatherIcon class="h-4" name="info" />
<span> Add at least one card before changing the payment mode. </span>
</div>
<CardForm
@success="
() => {
show = false
emit('success')
}
"
/>
</template>
</Dialog>
</template>
<script setup>
import CardForm from './CardForm.vue'
import { Dialog, FeatherIcon } from 'frappe-ui'
const props = defineProps({
showMessage: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['success'])
const show = defineModel()
</script>

View file

@ -0,0 +1,32 @@
<template>
<Dialog v-model="show" :options="{ title: 'Add Credit Balance' }">
<template #body-content>
<div v-if="showMessage" class="inline-flex gap-1.5 text-base mb-5 text-gray-700">
<FeatherIcon class="h-4" name="info" />
<span> Add credits to your account before changing the payment mode. </span>
</div>
<PrepaidCreditsForm
@success="
() => {
show = false
emit('success')
}
"
/>
</template>
</Dialog>
</template>
<script setup>
import PrepaidCreditsForm from './PrepaidCreditsForm.vue'
import { Dialog, FeatherIcon } from 'frappe-ui'
const props = defineProps({
showMessage: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['success'])
const show = defineModel()
</script>

View file

@ -0,0 +1,246 @@
<template>
<div>
<div class="flex flex-col gap-5">
<div
v-for="section in sections"
:key="section.name"
class="grid gap-4"
:class="'grid-cols-' + section.columns"
>
<div v-for="field in section.fields" :key="field.name">
<FormControl
v-model="billingInformation[field.fieldname]"
:label="field.label || field.fieldname"
:type="getInputType(field)"
:name="field.fieldname"
:options="field.options"
:required="field.required"
/>
</div>
</div>
<div v-show="billingInformation.country == 'India'">
<FormControl label="I have GSTIN" type="checkbox" v-model="gstApplicable" />
<FormControl
v-if="gstApplicable"
class="mt-5"
label="GSTIN"
type="text"
v-model="billingInformation.gstin"
/>
</div>
</div>
<ErrorMessage class="mt-2" :message="updateBillingInformation.error" />
</div>
</template>
<script setup>
import { FormControl, ErrorMessage, createResource, toast } from 'frappe-ui'
import { ref, computed, inject, watch } from 'vue'
const emit = defineEmits(['success'])
const { team } = inject('billing')
const billingInformation = defineModel()
const updateBillingInformation = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
makeParams: () => {
return {
method: 'billing.update_information',
data: { billing_details: billingInformation.value },
}
},
validate: async () => {
let error = await validate()
if (error) return error
},
onSuccess: () => {
toast({
title: 'Billing Information Updated',
icon: 'check',
iconClasses: 'text-green-600',
position: 'bottom-right',
})
emit('success')
},
})
const gstApplicable = ref(false)
watch(
() => billingInformation.value.gstin,
(gstin) => {
gstApplicable.value = gstin && gstin !== 'Not Applicable'
},
)
async function validate() {
// validate mandatory fields
for (let field of sections.value.flatMap((s) => s.fields)) {
if (field.required && !billingInformation.value[field.fieldname]) {
return `${field.label} is required`
}
}
if (!gstApplicable.value) {
billingInformation.value.gstin = 'Not Applicable'
}
// validate gstin
return await validateGST()
}
const _indianStates = [
'Andaman and Nicobar Islands',
'Andhra Pradesh',
'Arunachal Pradesh',
'Assam',
'Bihar',
'Chandigarh',
'Chhattisgarh',
'Dadra and Nagar Haveli and Daman and Diu',
'Delhi',
'Goa',
'Gujarat',
'Haryana',
'Himachal Pradesh',
'Jammu and Kashmir',
'Jharkhand',
'Karnataka',
'Kerala',
'Ladakh',
'Lakshadweep Islands',
'Madhya Pradesh',
'Maharashtra',
'Manipur',
'Meghalaya',
'Mizoram',
'Nagaland',
'Odisha',
'Other Territory',
'Puducherry',
'Punjab',
'Rajasthan',
'Sikkim',
'Tamil Nadu',
'Telangana',
'Tripura',
'Uttar Pradesh',
'Uttarakhand',
'West Bengal',
]
const _countryList = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.country_list' },
cache: 'countryList',
auto: true,
onSuccess: () => {
let userCountry = team.value?.country
if (userCountry) {
let country = countryList.value?.find((d) => d.label === userCountry)
if (country) {
billingInformation.value.country = country.value
}
}
},
})
const countryList = computed(() => {
return (_countryList.data || []).map((d) => ({
label: d.name,
value: d.name,
}))
})
const indianStates = computed(() => {
return _indianStates.map((state) => ({
label: state,
value: state,
}))
})
const sections = computed(() => {
return [
{
name: 'Country and City',
columns: 2,
fields: [
{
fieldtype: 'Select',
label: 'Country',
fieldname: 'country',
options: countryList.value,
required: true,
},
{
fieldtype: 'Data',
label: 'City',
fieldname: 'city',
required: true,
},
],
},
{
name: 'Address',
columns: 1,
fields: [
{
fieldtype: 'Data',
label: 'Address',
fieldname: 'address',
required: true,
},
],
},
{
name: 'State and Postal Code',
columns: 2,
fields: [
{
fieldtype: billingInformation.value.country === 'India' ? 'Select' : 'Data',
label: 'State / Province / Region',
fieldname: 'state',
required: true,
options:
billingInformation.value.country === 'India' ? indianStates.value : null,
},
{
fieldtype: 'Data',
label: 'Postal Code',
fieldname: 'postal_code',
required: true,
},
],
},
]
})
function getInputType(field) {
return {
Data: 'text',
Int: 'number',
Select: 'select',
Check: 'checkbox',
Password: 'password',
Text: 'textarea',
Date: 'date',
}[field.fieldtype || 'Data']
}
const _validateGST = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
makeParams() {
return {
method: 'billing.validate_gst',
data: { address: billingInformation.value },
}
},
})
async function validateGST() {
billingInformation.value.gstin = billingInformation.value.gstin || 'Not Applicable'
await _validateGST.submit()
}
defineExpose({ updateBillingInformation, validate })
</script>

View file

@ -0,0 +1,97 @@
<template>
<div
class="relative flex h-full flex-col justify-between transition-all duration-300 ease-in-out"
:class="isSidebarCollapsed ? 'w-12' : 'w-[220px]'"
>
<div class="flex-1 mt-3 overflow-y-auto">
<div class="mb-3 flex flex-col">
<SidebarLink
:label="previousRoute ? 'Back to app' : 'Back'"
icon="chevron-left"
:isCollapsed="isSidebarCollapsed"
@click="goBack"
class="relative mx-2 my-0.5"
/>
</div>
<nav class="mb-3 flex flex-col">
<SidebarLink
v-for="link in links"
:icon="link.icon"
:label="link.label"
:to="link.to"
:isCollapsed="isSidebarCollapsed"
class="mx-2 my-0.5"
/>
</nav>
</div>
<div class="m-2 flex flex-col gap-1">
<SidebarLink
:label="isSidebarCollapsed ? 'Expand' : 'Collapse'"
:isCollapsed="isSidebarCollapsed"
@click="isSidebarCollapsed = !isSidebarCollapsed"
>
<template #icon>
<span class="grid h-4.5 w-4.5 flex-shrink-0 place-items-center">
<CollapseSidebarIcon
class="h-4.5 w-4.5 text-gray-700 duration-300 ease-in-out"
:class="{ '[transform:rotateY(180deg)]': isSidebarCollapsed }"
/>
</span>
</template>
</SidebarLink>
</div>
</div>
</template>
<script setup>
import CollapseSidebarIcon from '@/icons/CollapseSidebarIcon.vue'
import BillingIcon from '@/icons/BillingIcon.vue'
import Plans from '@/icons/PlansIcon.vue'
import InvoiceIcon from '@/icons/InvoiceIcon.vue'
import SidebarLink from '@/components/SidebarLink.vue'
import Generic from '@/logo/Generic.vue'
import { FeatherIcon } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { useStorage } from '@vueuse/core'
import { onMounted } from 'vue'
const router = useRouter()
const isSidebarCollapsed = useStorage('isSidebarCollapsed', false)
const previousRoute = useStorage('previousRoute', null)
onMounted(() => {
if (document.referrer) {
previousRoute.value = document.referrer
}
})
const links = [
{
label: 'Billing',
icon: BillingIcon,
to: 'Billing',
},
{
label: 'Plans',
icon: Plans,
to: 'Plans',
},
{
label: 'Invoices',
icon: InvoiceIcon,
to: 'Invoices',
},
{
label: 'Cards',
icon: Generic,
to: 'Cards',
},
]
function goBack() {
if (previousRoute.value) {
window.location.href = previousRoute.value
} else {
router.go(-1)
}
}
</script>

View file

@ -0,0 +1,72 @@
<template>
<div class="flex flex-col gap-5">
<FormControl
v-model="billingInformation.billing_name"
type="text"
name="billing_name"
label="Billing Name"
:required="true"
/>
<AddressForm
ref="addressFormRef"
v-model="billingInformation"
@success="() => emit('success')"
/>
<ErrorMessage class="mt-2" :message="errorMessage" />
</div>
<div v-if="addressFormRef" class="mt-6">
<Button
class="w-full"
variant="solid"
label="Update billing details"
:loading="addressFormRef.updateBillingInformation.loading"
@click="updateBillingInformation"
/>
</div>
</template>
<script setup>
import AddressForm from './AddressForm.vue'
import { FormControl, ErrorMessage, Button, createResource } from 'frappe-ui'
import { reactive, ref } from 'vue'
const emit = defineEmits(['success'])
const addressFormRef = ref(null)
const billingInformation = reactive({
billing_name: '',
address: '',
city: '',
state: '',
postal_code: '',
country: '',
gstin: '',
})
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.get_information' },
auto: true,
onSuccess: (data) => {
Object.assign(billingInformation, {
address: data.address_line1,
city: data.city,
state: data.state,
postal_code: data.pincode,
country: data.country,
gstin: data.gstin == 'Not Applicable' ? '' : data.gstin,
billing_name: data.billing_name,
})
},
})
const errorMessage = ref('')
function updateBillingInformation() {
if (!billingInformation.billing_name) {
errorMessage.value = 'Billing Name is required'
return
}
addressFormRef.value.updateBillingInformation.submit()
}
</script>

View file

@ -0,0 +1,37 @@
<template>
<Dialog v-model="show" :options="{ title: 'Billing Details' }">
<template #body-content>
<div
v-if="showMessage"
class="inline-flex gap-1.5 text-base mb-5 text-gray-700"
>
<FeatherIcon class="h-4" name="info" />
<span> Add billing details to your account before proceeding.</span>
</div>
<BillingDetails
ref="billingRef"
@success="
() => {
show = false
emit('success')
}
"
/>
</template>
</Dialog>
</template>
<script setup>
import BillingDetails from './BillingDetails.vue'
import { FeatherIcon, Dialog } from 'frappe-ui'
import { ref } from 'vue'
const props = defineProps({
showMessage: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['success'])
const show = defineModel()
const billingRef = ref(null)
</script>

View file

@ -0,0 +1,133 @@
<template>
<div>
<span
v-if="team.currency === 'INR'"
class="mt-2.5 inline-flex gap-2 text-base text-gray-700"
>
<FeatherIcon name="info" class="h-4 my-1" />
<span class="leading-5">
If you select Razorpay, you can pay using Credit Card, Debit Card, Net Banking,
UPI, Wallets, etc. If you are using Net Banking, it may take upto 5 days for
balance to reflect.
</span>
</span>
<ErrorMessage class="mt-3" :message="createRazorpayOrder.error" />
<div class="mt-8">
<Button
v-if="!isPaymentComplete"
class="w-full"
size="md"
variant="solid"
label="Proceed to payment using Razorpay"
:loading="createRazorpayOrder.loading"
@click="createRazorpayOrder.submit()"
/>
<Button
v-else
class="w-full"
size="md"
label="Confirming payment"
variant="solid"
:loading="isVerifyingPayment"
/>
</div>
</div>
</template>
<script setup>
import { Button, ErrorMessage, FeatherIcon, createResource, toast } from 'frappe-ui'
import { ref, onMounted, onBeforeUnmount, inject } from 'vue'
const props = defineProps({
amount: {
type: Number,
default: 0,
},
minimumAmount: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['success'])
const { team } = inject('billing')
const isPaymentComplete = ref(false)
const isVerifyingPayment = ref(false)
const razorpayCheckoutJS = ref(null)
onMounted(() => {
razorpayCheckoutJS.value = document.createElement('script')
razorpayCheckoutJS.value.setAttribute('src', 'https://checkout.razorpay.com/v1/checkout.js')
razorpayCheckoutJS.value.async = true
document.head.appendChild(razorpayCheckoutJS.value)
})
onBeforeUnmount(() => {
razorpayCheckoutJS.value?.remove()
})
const createRazorpayOrder = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: {
method: 'billing.create_razorpay_order',
data: { amount: props.amount },
},
onSuccess: (data) => processOrder(data),
validate: () => {
if (props.amount < props.minimumAmount) {
return 'Amount less than minimum amount required'
}
},
})
const handlePaymentFailed = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.handle_razorpay_payment_failed' },
onSuccess: () => {
console.log('Payment Failed.')
},
})
function processOrder(data) {
const options = {
key: data.key_id,
order_id: data.order_id,
name: 'Frappe Cloud',
image: 'https://frappe.io/files/cloud.png',
prefill: { email: team.value?.user },
handler: handlePaymentSuccess,
theme: { color: '#171717' },
}
const rzp = new Razorpay(options)
// Opens the payment checkout frame
rzp.open()
// Attach failure handler
rzp.on('payment.failed', handlePaymentFailure)
// rzp.on('payment.success', this.handlePaymentSuccess);
}
function handlePaymentFailure(response) {
handlePaymentFailed.submit({ response })
toast({
title: 'Payment failed',
icon: 'x',
iconClasses: 'text-red-600',
position: 'bottom-right',
})
}
function handlePaymentSuccess() {
isPaymentComplete.value = true
emit('success')
toast({
title: 'Payment successful',
icon: 'check',
iconClasses: 'text-green-600',
position: 'bottom-right',
})
}
</script>

View file

@ -0,0 +1,172 @@
<template>
<div>
<label
class="block"
:class="{
'pointer-events-none h-0.5 opacity-0': step != 'Add Card Details',
'mt-4': step == 'Add Card Details',
}"
>
<span class="text-sm leading-4 text-gray-700">
Credit or Debit Card
</span>
<div class="form-input mt-2 block w-full pl-3" ref="cardElementRef"></div>
<ErrorMessage class="mt-1" :message="cardErrorMessage" />
</label>
<div v-if="step == 'Setting up Stripe'" class="mt-8 flex justify-center">
<Spinner class="h-4 w-4 text-gray-700" />
</div>
<ErrorMessage
class="mt-2"
:message="createPaymentIntent.error || errorMessage"
/>
<div class="mt-8">
<Button
v-if="step == 'Get Amount'"
class="w-full"
size="md"
variant="solid"
label="Proceed to payment using Stripe"
:loading="createPaymentIntent.loading"
@click="createPaymentIntent.submit()"
/>
<Button
v-else-if="step == 'Add Card Details'"
class="w-full"
size="md"
variant="solid"
label="Make payment via Stripe"
:loading="paymentInProgress"
@click="onBuyClick"
/>
</div>
</div>
</template>
<script setup>
import { Button, ErrorMessage, Spinner, createResource, toast } from 'frappe-ui'
import { loadStripe } from '@stripe/stripe-js'
import { ref, nextTick, inject } from 'vue'
const props = defineProps({
amount: {
type: Number,
default: 0,
},
minimumAmount: {
type: Number,
default: 0,
},
})
const emit = defineEmits(['success'])
const { team } = inject('billing')
const step = ref('Get Amount')
const clientSecret = ref(null)
const cardErrorMessage = ref(null)
const errorMessage = ref(null)
const paymentInProgress = ref(false)
const stripe = ref(null)
const card = ref(null)
const elements = ref(null)
const ready = ref(false)
const cardElementRef = ref(null)
const createPaymentIntent = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: {
method: 'billing.create_payment_intent_for_buying_credits',
data: { amount: props.amount },
},
validate() {
if (props.amount < props.minimumAmount && !team.value.erpnext_partner) {
return `Amount must be greater than or equal to ${props.minimumAmount}`
}
},
async onSuccess(data) {
step.value = 'Setting up Stripe'
let { publishable_key, client_secret } = data
clientSecret.value = client_secret
stripe.value = await loadStripe(publishable_key)
elements.value = stripe.value.elements()
const style = {
base: {
color: '#171717',
fontFamily: [
'ui-sans-serif',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans"',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Noto Color Emoji"',
].join(', '),
fontSmoothing: 'antialiased',
fontSize: '13px',
'::placeholder': {
color: '#C7C7C7',
},
},
invalid: {
color: '#7C7C7C',
iconColor: '#7C7C7C',
},
}
card.value = elements.value.create('card', {
hidePostalCode: true,
style: style,
classes: {
complete: '',
focus: 'bg-gray-100',
},
})
step.value = 'Add Card Details'
nextTick(() => {
card.value.mount(cardElementRef.value)
})
card.value.addEventListener('change', (event) => {
cardErrorMessage.value = event.error?.message || null
})
card.value.addEventListener('ready', () => {
ready.value = true
})
},
})
async function onBuyClick() {
paymentInProgress.value = true
let payload = await stripe.value.confirmCardPayment(clientSecret.value, {
payment_method: { card: card.value },
})
if (payload.error) {
errorMessage.value = payload.error.message
paymentInProgress.value = false
} else {
toast({
position: 'bottom-right',
title: 'Payment successful',
text: 'Payment processed successfully, we will update your account shortly on confirmation from Stripe',
icon: 'check',
iconClasses: 'text-green-600',
timeout: 10,
})
paymentInProgress.value = false
emit('success')
errorMessage.value = null
}
}
</script>

View file

@ -0,0 +1,362 @@
<template>
<div class="relative">
<div
v-if="!ready"
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-8 transform"
>
<Spinner class="h-5 w-5 text-gray-700" />
</div>
<div :class="{ 'opacity-0': !ready }">
<div v-show="!tryingMicroCharge">
<label class="block">
<span class="block text-xs text-gray-600"> Credit or Debit Card </span>
<div
class="form-input mt-2 block h-[unset] w-full py-2 pl-3"
ref="cardElementRef"
></div>
<ErrorMessage class="mt-1" :message="cardErrorMessage" />
</label>
<FormControl
class="mt-4"
label="Name on Card"
type="text"
v-model="billingInformation.cardHolderName"
/>
<AddressForm
ref="addressFormRef"
class="mt-5"
v-model="billingInformation"
@success="console.log('Address form submitted')"
/>
</div>
<div class="mt-3 space-y-4" v-show="tryingMicroCharge">
<p class="text-base text-gray-700">
We are attempting to charge your card with
<strong>{{ formattedMicroChargeAmount }}</strong> to make sure the card works.
This amount will be <strong>refunded</strong> back to your account.
</p>
<Button :loading="!microChargeCompleted" :loadingText="'Verifying Card'">
Card Verified
<template #prefix>
<GreenCheckIcon class="h-4 w-4" />
</template>
</Button>
</div>
<ErrorMessage class="mt-2" :message="errorMessage" />
<div class="mt-6 flex items-center justify-between">
<PoweredByStripeLogo />
<Button
v-if="showAddAnotherCardButton"
label="Add Another Card"
@click="clearForm"
>
<template #prefix>
<FeatherIcon class="h-4" name="plus" />
</template>
</Button>
<Button
v-else-if="!tryingMicroCharge"
variant="solid"
label="Verify & Save Card"
:loading="addingCard"
@click="submit"
/>
</div>
</div>
</div>
</template>
<script setup>
import AddressForm from './AddressForm.vue'
import PoweredByStripeLogo from '../logo/PoweredByStripeLogo.vue'
import GreenCheckIcon from '../icons/GreenCheckIcon.vue'
import {
FeatherIcon,
Button,
FormControl,
Spinner,
ErrorMessage,
createResource,
toast,
} from 'frappe-ui'
import { currency } from '../utils.js'
import { loadStripe } from '@stripe/stripe-js'
import { ref, reactive, computed, inject, onMounted } from 'vue'
const emit = defineEmits(['success'])
const { team } = inject('billing')
const stripe = ref(null)
const elements = ref(null)
const card = ref(null)
const ready = ref(false)
const _setupIntent = ref(null)
const errorMessage = ref(null)
const cardErrorMessage = ref(null)
const addingCard = ref(false)
const tryingMicroCharge = ref(false)
const showAddAnotherCardButton = ref(false)
const microChargeCompleted = ref(false)
onMounted(() => setupStripeIntent())
const cardElementRef = ref(null)
const getPublishedKeyAndSetupIntent = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.get_publishable_key_and_setup_intent' },
onSuccess: async (data) => {
const { publishable_key, setup_intent } = data
_setupIntent.value = setup_intent
stripe.value = await loadStripe(publishable_key)
elements.value = stripe.value.elements()
const style = {
base: {
color: '#171717',
fontFamily: [
'ui-sans-serif',
'system-ui',
'-apple-system',
'BlinkMacSystemFont',
'"Segoe UI"',
'Roboto',
'"Helvetica Neue"',
'Arial',
'"Noto Sans"',
'sans-serif',
'"Apple Color Emoji"',
'"Segoe UI Emoji"',
'"Segoe UI Symbol"',
'"Noto Color Emoji"',
].join(', '),
fontSmoothing: 'antialiased',
fontSize: '13px',
'::placeholder': {
color: '#C7C7C7',
},
},
invalid: {
color: '#C7C7C7',
iconColor: '#C7C7C7',
},
}
card.value = elements.value.create('card', {
hidePostalCode: true,
style: style,
classes: {
complete: '',
focus: 'bg-gray-100',
},
})
card.value.mount(cardElementRef.value)
card.value.addEventListener('change', (event) => {
cardErrorMessage.value = event.error?.message || null
})
card.value.addEventListener('ready', () => {
ready.value = true
})
},
})
const countryList = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.country_list' },
cache: 'countryList',
auto: true,
})
const browserTimezone = computed(() => {
if (!window.Intl) {
return null
}
return Intl.DateTimeFormat().resolvedOptions().timeZone
})
const billingInformation = reactive({
cardHolderName: '',
country: '',
gstin: '',
})
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: {
method: 'billing.get_information',
data: { timezone: browserTimezone.value },
},
auto: true,
onSuccess: (data) => {
billingInformation.country = data?.country
billingInformation.address = data?.address_line1
billingInformation.city = data?.city
billingInformation.state = data?.state
billingInformation.postal_code = data?.pincode
billingInformation.gstin = data?.gstin == 'Not Applicable' ? '' : data?.gstin
},
})
const setupIntentSuccess = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
makeParams: ({ setupIntent }) => {
return {
method: 'billing.setup_intent_success',
data: {
setup_intent: setupIntent,
address: billingInformation,
},
}
},
onSuccess: async ({ payment_method_name }) => {
await verifyWithMicroChargeIfApplicable(payment_method_name)
addingCard.value = false
toast({
title: 'Card added successfully',
icon: 'check',
iconClasses: 'text-green-600',
position: 'bottom-right',
})
},
onError: (error) => {
console.error(error)
addingCard.value = false
errorMessage.value = error.messages.join('\n')
toast({
title: errorMessage.value,
icon: 'x',
iconClasses: 'text-red-600',
position: 'bottom-right',
})
},
})
const verifyCardWithMicroCharge = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
makeParams: ({ paymentMethodName }) => {
return {
method: 'billing.create_payment_intent_for_micro_debit',
data: { payment_method_name: paymentMethodName },
}
},
})
async function setupStripeIntent() {
await getPublishedKeyAndSetupIntent.submit()
const { first_name, last_name = '' } = team.value?.user_info
const fullname = first_name + ' ' + last_name
billingInformation.cardHolderName = fullname.trimEnd()
}
const addressFormRef = ref(null)
async function submit() {
addingCard.value = true
let message = await addressFormRef.value.validate()
if (message) {
errorMessage.value = message
addingCard.value = false
return
} else {
errorMessage.value = null
}
const { setupIntent, error } = await stripe.value.confirmCardSetup(
_setupIntent.value.client_secret,
{
payment_method: {
card: card.value,
billing_details: {
name: billingInformation.cardHolderName,
address: {
line1: billingInformation.address,
city: billingInformation.city,
state: billingInformation.state,
postal_code: billingInformation.postal_code,
country: getCountryCode(team.value?.country),
},
},
},
},
)
if (error) {
addingCard.value = false
let declineCode = error.decline_code
let _errorMessage = error.message
if (declineCode === 'do_not_honor') {
errorMessage.value =
"Your card was declined. It might be due to insufficient funds or you might've exceeded your daily limit. Please try with another card or contact your bank."
showAddAnotherCardButton.value = true
} else if (declineCode === 'transaction_not_allowed') {
errorMessage.value =
'Your card was declined. It might be due to restrictions on your card, like international transactions or online payments. Please try with another card or contact your bank.'
showAddAnotherCardButton.value = true
} else if (_errorMessage != 'Your card number is incomplete.') {
errorMessage.value = _errorMessage
}
} else {
if (setupIntent?.status === 'succeeded') {
setupIntentSuccess.submit({ setupIntent })
}
}
}
async function verifyWithMicroChargeIfApplicable(paymentMethodName) {
const teamCurrency = team.value?.currency
const verifyCardsWithMicroCharge = window.verify_cards_with_micro_charge
const isMicroChargeApplicable =
verifyCardsWithMicroCharge === 'Both INR and USD' ||
(verifyCardsWithMicroCharge == 'Only INR' && teamCurrency === 'INR') ||
(verifyCardsWithMicroCharge === 'Only USD' && teamCurrency === 'USD')
if (isMicroChargeApplicable) {
await _verifyWithMicroCharge(paymentMethodName)
} else {
emit('success')
}
}
async function _verifyWithMicroCharge(paymentMethodName) {
tryingMicroCharge.value = true
return verifyCardWithMicroCharge.submit({
paymentMethodName,
onSuccess: async (paymentIntent) => {
let { client_secret } = paymentIntent
let payload = await stripe.value.confirmCardPayment(client_secret, {
payment_method: { card: card.value },
})
if (payload.paymentIntent?.status === 'succeeded') {
microChargeCompleted.value = true
emit('success')
}
},
onError: (error) => {
console.error(error)
tryingMicroCharge.value = false
errorMessage.value = error.messages.join('\n')
},
})
}
function getCountryCode(country) {
let code = countryList.data.find((d) => d.name === country).code
return code.toUpperCase()
}
async function clearForm() {
ready.value = false
errorMessage.value = null
showAddAnotherCardButton.value = false
card.value = null
setupStripeIntent()
}
const formattedMicroChargeAmount = computed(() => {
if (!team.value?.currency) {
return 0
}
return currency(team.value?.billing_info?.micro_debit_charge_amount, team.value?.currency)
})
</script>

View file

@ -0,0 +1,137 @@
<template>
<div>
<Dialog
v-model="show"
:options="{ title: 'Choose active card' }"
:disableOutsideClickToClose="confirmDialogOpened"
>
<template #body-content>
<div v-if="cards.data?.length" class="flex flex-col gap-2.5">
<div
v-for="card in cards.data"
:key="card.name"
class="flex gap-2 justify-between text-base text-gray-900 p-2.5 rounded hover:bg-gray-100"
>
<div class="flex gap-2">
<component :is="cardBrandIcon(card.brand)" class="size-7" />
<div>
<div class="flex items-center gap-1 h-7 font-medium">
<div>{{ card.name_on_card }}</div>
<div>&middot;</div>
<div>Card ending in </div>
<div>{{ card.last_4 }}</div>
<Badge
v-if="card.is_default"
class="ml-1.5"
label="Primary"
variant="outline"
theme="green"
/>
</div>
<div class="text-gray-600">
Expiry
{{
card.expiry_month < 10
? `0${card.expiry_month}`
: card.expiry_month
}}/{{ card.expiry_year }}
</div>
<div v-if="!card.is_default" class="-ml-2 mt-2">
<Button
class="!text-gray-700"
label="Set as primary"
variant="ghost"
@click="setAsPrimary(card)"
/>
</div>
</div>
</div>
<div v-if="cards.data.length > 1 && !card.is_default">
<Dropdown
:options="[
{ label: 'Remove', onClick: () => removeCard(card) },
]"
>
<Button icon="more-horizontal" variant="ghost" />
</Dropdown>
</div>
</div>
</div>
</template>
<template #actions>
<Button
label="Add new card"
class="w-full"
variant="solid"
@click="emit('addCard')"
>
<template #prefix>
<FeatherIcon name="plus" class="h-4" />
</template>
</Button>
</template>
</Dialog>
</div>
</template>
<script setup>
import { createDialog } from '../dialogs.js'
import { Dropdown, Badge, Dialog, Button, FeatherIcon, createResource } from 'frappe-ui'
import { cardBrandIcon } from '../utils.js'
import { ref } from 'vue'
const emit = defineEmits(['success', 'addCard'])
const show = defineModel()
const cards = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.get_payment_methods' },
auto: true,
})
const setAsPrimary = (card) => {
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.set_as_default', data: { name: card.name } },
auto: true,
onSuccess: () => {
cards.reload()
emit('success')
},
})
}
const confirmDialogOpened = ref(false)
const removeCard = (card) => {
confirmDialogOpened.value = true
createDialog({
title: 'Remove Card',
message: 'Are you sure you want to remove this card?',
actions: [
{
label: 'Delete',
variant: 'solid',
theme: 'red',
onClick: (close) => {
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: {
method: 'billing.remove_payment_method',
data: { name: card.name },
},
auto: true,
onSuccess: () => {
cards.reload()
confirmDialogOpened.value = false
close()
},
})
},
},
],
onClose: () => {
confirmDialogOpened.value = false
},
})
}
</script>

View file

@ -0,0 +1,172 @@
<template>
<div class="flex flex-col gap-4">
<div class="text-lg font-semibold text-gray-900">
{{ 'Current plan' }}
</div>
<div
v-if="currentPlan?.is_trial_plan"
class="flex justify-between shadow rounded-lg py-3 px-4 text-base"
>
<div class="flex gap-3">
<div class="flex flex-col gap-4 flex-1">
<div class="flex flex-col gap-1.5">
<div class="font-semibold text-gray-900 text-lg">
{{ currentPlan.is_trial_plan ? 'Trial plan' : currentPlan.name }}
</div>
<div v-if="currentPlan.is_trial_plan" class="text-gray-700">
{{ trialDescription }}
</div>
</div>
<div
v-if="currentPlan.is_trial_plan && currentPlan.support_included"
class="text-gray-700 inline-flex items-center gap-1.5"
>
<FeatherIcon class="h-4" name="info" />
<span> Support Included </span>
</div>
<div v-else class="text-gray-700">
<span>{{ currency }}{{ price.value }}</span>
<span class="font-normal">{{ ' / month · See plan details' }}</span>
</div>
</div>
</div>
<Button
variant="solid"
:label="currentPlan.is_trial_plan ? 'Upgrade now' : 'Change plan'"
@click="emit('changePlan')"
/>
</div>
<div
v-else-if="currentPlan"
class="flex flex-col shadow rounded-lg text-base text-gray-900"
>
<div class="flex flex-col gap-2.5 py-3 px-4">
<div class="flex justify-between items-center">
<div class="flex flex-col gap-1.5">
<div class="font-semibold text-lg">Recurring Charges</div>
<div class="text-gray-700">
<span>Next charge date </span>
<span>{{ currentMonthEnd() }}</span>
<span> · </span>
<Tooltip>
<template #body>
<PlanDetails :plan="currentPlan" />
</template>
<span class="hover:underline cursor-pointer">
See plan details
</span>
</Tooltip>
</div>
</div>
<div class="flex flex-col gap-1.5 text-end">
<div>
<span class="font-semibold text-xl"> {{ currency }}{{ price }} </span>
<span>/mo</span>
</div>
<div class="text-gray-600">
<span>{{ currency }}{{ (price / 30).toFixed(2) }}</span>
<span>/day</span>
</div>
</div>
</div>
<div class="flex justify-between items-center">
<div class="text-gray-700 flex gap-2">
<BillingIcon class="h-4 w-4" />
<div>
<span>Current billing amount so far </span>
<span class="text-gray-900 font-medium">
{{ currency }} {{ currentBillingAmount?.toFixed(2) }}
</span>
</div>
</div>
<div>
<Button variant="solid" label="Upgrade plan" @click="emit('changePlan')" />
</div>
</div>
</div>
<div
v-if="unpaidAmount.data"
class="flex justify-between items-center rounded-lg py-2 px-2.5 m-1.5 bg-gray-50"
>
<div class="text-gray-800 flex items-center gap-2 h-7">
<UnPaidBillIcon class="h-4 w-4" />
<div>
<span>Unpaid amount is </span>
<span>{{ currency }} {{ unpaidAmount.data?.toFixed(2) }}</span>
</div>
</div>
<div v-if="team.payment_mode == 'Prepaid Credits'">
<Button
variant="outline"
label="Pay now"
@click="showAddPrepaidCreditsModal = true"
/>
</div>
</div>
</div>
<div v-else class="flex items-start justify-center">
<Spinner class="h-4 w-4 text-gray-700" />
</div>
<AddPrepaidCreditsModal
v-if="showAddPrepaidCreditsModal"
v-model="showAddPrepaidCreditsModal"
@success="reloadUpcomingInvoice()"
/>
</div>
</template>
<script setup>
import BillingIcon from '../icons/BillingIcon.vue'
import UnPaidBillIcon from '../icons/UnPaidBillIcon.vue'
import PlanDetails from './PlanDetails.vue'
import AddPrepaidCreditsModal from './AddPrepaidCreditsModal.vue'
import { Button, Tooltip, Spinner, FeatherIcon, createResource } from 'frappe-ui'
import { calculateTrialEndDays } from '../utils.js'
import { ref, computed, inject } from 'vue'
const emit = defineEmits(['changePlan'])
const { team, currentBillingAmount, reloadUpcomingInvoice } = inject('billing')
const showAddPrepaidCreditsModal = ref(false)
const trialEndDays = ref(0)
const trialDescription = computed(() => {
return trialEndDays.value > 1
? 'Your trial plan ends in ' + trialEndDays.value + ' days'
: 'Your trial plan will end tomorrow'
})
const currentSiteInfo = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.current_site_info',
auto: true,
cache: 'currentSiteInfo',
})
const price = ref(null)
const currency = computed(() => (team.value.currency == 'INR' ? '₹' : '$'))
const currentPlan = computed(() => {
if (!currentSiteInfo.data) return null
trialEndDays.value = calculateTrialEndDays(currentSiteInfo.data.trial_end_date)
let _currentPlan = currentSiteInfo.data.plan
price.value = currency.value === '₹' ? _currentPlan.price_inr : _currentPlan.price_usd
return _currentPlan
})
const unpaidAmount = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.total_unpaid_amount' },
cache: 'unpaidAmount',
auto: true,
})
const currentMonthEnd = () => {
const date = new Date()
const lastDay = new Date(date.getFullYear(), date.getMonth() + 1, 0)
return lastDay.toLocaleDateString('en-US', {
day: 'numeric',
month: 'short',
year: 'numeric',
})
}
</script>

View file

@ -0,0 +1,22 @@
<template>
<div>
<Button
class="flex justify-between w-full rounded text-base"
variant="ghost"
:label="label"
@click="onClick"
>
<template v-if="active" #suffix>
<FeatherIcon class="size-4" name="check" />
</template>
</Button>
</div>
</template>
<script setup>
import { Button, FeatherIcon } from 'frappe-ui'
const props = defineProps({
label: String,
active: Boolean,
onClick: Array,
})
</script>

View file

@ -0,0 +1,264 @@
<template>
<div class="flex flex-col gap-6">
<div class="text-lg font-semibold text-gray-900">
{{ 'Payment details' }}
</div>
<div class="flex flex-col">
<div
v-if="team.payment_mode == 'Card'"
class="flex justify-between items-center text-base text-gray-900"
>
<div class="flex flex-col gap-1.5">
<div class="font-medium">{{ 'Active card' }}</div>
<div class="overflow-hidden text-gray-700 text-ellipsis">
<div v-if="team.payment_method" class="inline-flex items-center gap-2">
<component :is="cardBrandIcon(team.payment_method.brand)" />
<div class="text-gray-700">
<span>{{ team.payment_method.name_on_card }}</span>
<span> &middot; Card ending in </span>
<span>{{ team.payment_method.last_4 }}</span>
</div>
</div>
<span v-else class="text-gray-700">No card added</span>
</div>
</div>
<div class="shrink-0">
<Button
:label="team.payment_method ? 'Change card' : 'Add card'"
@click="changeMethod"
>
<template v-if="!team.payment_method" #prefix>
<FeatherIcon class="h-4" name="plus" />
</template>
</Button>
</div>
</div>
<div v-if="team.payment_mode == 'Card'" class="bg-gray-100 h-px my-3" />
<div class="flex justify-between items-center text-base text-gray-900">
<div class="flex flex-col gap-1.5">
<div class="font-medium">{{ 'Mode of payment' }}</div>
<div
v-if="team.payment_mode"
class="inline-flex items-center gap-2 text-gray-700"
>
<FeatherIcon class="h-4" name="info" />
{{
paymentModeOptions.find((o) => o.value === team.payment_mode)
.description
}}
</div>
<span v-else class="text-gray-700">Not set</span>
</div>
<div class="shrink-0">
<Dropdown :options="paymentModeOptions">
<template #default="{ open }">
<Button
:label="
team.payment_mode
? paymentModeOptions.find(
(o) => o.value === team.payment_mode,
).label
: 'Set mode'
"
>
<template #suffix>
<FeatherIcon
:name="open ? 'chevron-up' : 'chevron-down'"
class="h-4"
/>
</template>
</Button>
</template>
</Dropdown>
</div>
</div>
<div class="bg-gray-100 h-px my-3" />
<div class="flex justify-between items-center text-base text-gray-900">
<div class="flex flex-col gap-1.5">
<div class="font-medium">{{ 'Credit balance' }}</div>
<div class="text-gray-700">
{{ availableCredits || currency + ' 0.00' }}
</div>
</div>
<div class="shrink-0">
<Button
:label="'Add credit'"
@click="
() => {
showMessage = false
if (!billingDetailsSummary) {
showMessage = true
showBillingDetailsDialog = true
return
}
showAddPrepaidCreditsModal = true
}
"
>
<template #prefix>
<FeatherIcon class="h-4" name="plus" />
</template>
</Button>
</div>
</div>
<div class="bg-gray-100 h-px my-3" />
<div class="flex justify-between items-center text-base text-gray-900">
<div class="flex flex-col gap-1.5">
<div class="font-medium">{{ 'Billing address' }}</div>
<div v-if="billingDetailsSummary" class="text-gray-700 leading-5">
{{ billingDetailsSummary }}
</div>
<div v-else class="text-gray-700">No address</div>
</div>
<div class="shrink-0">
<Button
:label="billingDetailsSummary ? 'Edit information' : 'Add billing address'"
@click="
() => {
showMessage = false
showBillingDetailsDialog = true
}
"
>
<template v-if="!billingDetailsSummary" #prefix>
<FeatherIcon class="h-4" name="plus" />
</template>
</Button>
</div>
</div>
</div>
</div>
<BillingDetailsModal
v-if="showBillingDetailsDialog"
v-model="showBillingDetailsDialog"
:showMessage="showMessage"
@success="billingDetails.reload()"
/>
<AddPrepaidCreditsModal
v-if="showAddPrepaidCreditsModal"
v-model="showAddPrepaidCreditsModal"
:showMessage="showMessage"
@success="reloadUpcomingInvoice()"
/>
<AddCardModal
v-if="showAddCardModal"
v-model="showAddCardModal"
:showMessage="showMessage"
@success="
() => {
showMessage = false
showAddCardModal = false
reloadTeam()
}
"
/>
<ChangeCardModal
v-if="showChangeCardModal"
v-model="showChangeCardModal"
@addCard="
() => {
showChangeCardModal = false
showAddCardModal = true
}
"
@success="() => reloadTeam()"
/>
</template>
<script setup>
import DropdownItem from './DropdownItem.vue'
import BillingDetailsModal from './BillingDetailsModal.vue'
import AddPrepaidCreditsModal from './AddPrepaidCreditsModal.vue'
import AddCardModal from './AddCardModal.vue'
import ChangeCardModal from './ChangeCardModal.vue'
import { Dropdown, Button, FeatherIcon, createResource } from 'frappe-ui'
import { cardBrandIcon } from '../utils.js'
import { computed, ref, inject, h } from 'vue'
const { team, reloadTeam, availableCredits, reloadUpcomingInvoice } = inject('billing')
const showBillingDetailsDialog = ref(false)
const showAddPrepaidCreditsModal = ref(false)
const showAddCardModal = ref(false)
const showChangeCardModal = ref(false)
const currency = computed(() => (team.value.currency == 'INR' ? '₹' : '$'))
const billingDetails = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.get_information' },
cache: 'billingDetails',
auto: true,
})
const billingDetailsSummary = computed(() => {
let _billingDetails = billingDetails.data
if (!_billingDetails) return ''
const { billing_name, address_line1, city, state, country, pincode, gstin } =
_billingDetails || {}
return [billing_name, address_line1, city, state, country, pincode, gstin]
.filter(Boolean)
.join(', ')
})
const paymentModeOptions = [
{
label: 'Card',
value: 'Card',
description: 'Your card will be charged for monthly subscription',
component: () =>
h(DropdownItem, {
label: 'Card',
active: team.value.payment_mode === 'Card',
onClick: () => updatePaymentMode('Card'),
}),
},
{
label: 'Prepaid credits',
value: 'Prepaid Credits',
description: 'You will be charged from your credit balance for monthly subscription',
component: () =>
h(DropdownItem, {
label: 'Prepaid credits',
active: team.value.payment_mode === 'Prepaid Credits',
onClick: () => updatePaymentMode('Prepaid Credits'),
}),
},
]
const showMessage = ref(false)
function updatePaymentMode(mode) {
showMessage.value = false
if (!billingDetailsSummary.value) {
showMessage.value = true
showBillingDetailsDialog.value = true
return
}
if (mode === 'Prepaid Credits' && team.value.balance === 0) {
showMessage.value = true
showAddPrepaidCreditsModal.value = true
return
} else if (mode === 'Card' && !team.value.payment_method) {
showMessage.value = true
showAddCardModal.value = true
}
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: {
method: 'billing.change_payment_mode',
data: { mode },
},
auto: true,
onSuccess: () => reloadTeam(),
})
}
function changeMethod() {
if (team.value.payment_method) {
showChangeCardModal.value = true
} else {
showMessage.value = false
showAddCardModal.value = true
}
}
</script>

View file

@ -0,0 +1,26 @@
<template>
<ul
class="rounded px-7 py-1.5 bg-gray-900 text-white text-base leading-6 list-disc"
>
<li class="list-disc">{{ plan.cpu_time_per_day }} compute hours/day</li>
<li>{{ parseSize(plan.max_database_usage) }} Database</li>
<li>{{ parseSize(plan.max_storage_usage) }} Disk</li>
<li>Product Waranty</li>
<li v-if="plan.support_included">Support Included</li>
<li v-if="plan.database_access">Database Access</li>
<li v-if="plan.offsite_backups">Offsite Backups</li>
<li v-if="plan.private_benches">Private Benches</li>
<li v-if="plan.monitor_access">Advanced Monitoring</li>
</ul>
</template>
<script setup>
import { parseSize } from '../utils.js'
const props = defineProps({
plan: {
type: Object,
required: true,
},
})
</script>

View file

@ -0,0 +1,122 @@
<template>
<div>
<!-- Amount -->
<div>
<FormControl
:label="`Amount (Minimum Amount: ${minimumAmount})`"
class="mb-3"
v-model.number="creditsToBuy"
name="amount"
autocomplete="off"
type="number"
:min="minimumAmount"
>
<template #prefix>
<div class="grid w-4 place-items-center text-sm text-gray-700">
{{ team.currency === 'INR' ? '₹' : '$' }}
</div>
</template>
</FormControl>
<FormControl
v-if="team.currency === 'INR'"
:label="`Total Amount + GST (${team?.billing_info.gst_percentage * 100}%)`"
disabled
:modelValue="totalAmount"
name="total"
autocomplete="off"
type="number"
>
<template #prefix>
<div class="grid w-4 place-items-center text-sm text-gray-700">
{{ team.currency === 'INR' ? '₹' : '$' }}
</div>
</template>
</FormControl>
</div>
<!-- Payment Gateway -->
<div class="mt-4">
<div class="text-xs text-gray-600">Select Payment Gateway</div>
<div class="mt-1.5 grid grid-cols-1 gap-2 sm:grid-cols-2">
<Button
v-if="team.currency === 'INR' || team.razorpay_enabled"
size="lg"
:class="{
'border-gray-700 border-[1.5px]': paymentGateway === 'Razorpay',
}"
@click="paymentGateway = 'Razorpay'"
>
<RazorpayLogo class="w-24" />
</Button>
<Button
size="lg"
:class="{
'border-gray-700 border-[1.5px]': paymentGateway === 'Stripe',
}"
@click="paymentGateway = 'Stripe'"
>
<StripeLogo class="h-7 w-24" />
</Button>
</div>
</div>
<!-- Payment Button -->
<BuyCreditsStripe
v-if="paymentGateway === 'Stripe'"
:amount="creditsToBuy"
:minimumAmount="minimumAmount"
@success="() => emit('success')"
@cancel="show = false"
/>
<BuyCreditsRazorpay
v-if="paymentGateway === 'Razorpay'"
:amount="creditsToBuy"
:minimumAmount="minimumAmount"
@success="() => emit('success')"
@cancel="show = false"
/>
</div>
</template>
<script setup>
import BuyCreditsStripe from './BuyCreditsStripe.vue'
import BuyCreditsRazorpay from './BuyCreditsRazorpay.vue'
import RazorpayLogo from '../logo/RazorpayLogo.vue'
import StripeLogo from '../logo/StripeLogo.vue'
import { FormControl, Button, createResource } from 'frappe-ui'
import { ref, computed, inject } from 'vue'
const emit = defineEmits(['success'])
const { team } = inject('billing')
const totalUnpaidAmount = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.total_unpaid_amount' },
cache: 'totalUnpaidAmount',
auto: true,
})
const minimumAmount = computed(() => {
if (!team.value) return 0
const unpaidAmount = totalUnpaidAmount.data || 0
const minimumDefault = team.value?.currency == 'INR' ? 410 : 5
return Math.ceil(unpaidAmount && unpaidAmount > 0 ? unpaidAmount : minimumDefault)
})
const creditsToBuy = ref(minimumAmount.value)
const paymentGateway = ref('')
const totalAmount = computed(() => {
let _creditsToBuy = creditsToBuy.value || 0
if (team.value?.currency === 'INR') {
return (
_creditsToBuy +
_creditsToBuy * (team.value.billing_info.gst_percentage || 0)
).toFixed(2)
} else {
return _creditsToBuy
}
})
</script>

View file

@ -0,0 +1,87 @@
<template>
<button
class="flex h-7 cursor-pointer items-center rounded text-gray-700 duration-300 ease-in-out focus:outline-none focus:transition-none focus-visible:rounded focus-visible:ring-2 focus-visible:ring-gray-400"
:class="isActive ? 'bg-white shadow-sm' : 'hover:bg-gray-100'"
@click="handleClick"
>
<div
class="flex w-full items-center justify-between duration-300 ease-in-out"
:class="isCollapsed ? 'ml-[3px] p-1' : 'px-2 py-1'"
>
<div class="flex items-center truncate">
<Tooltip :text="label" placement="right" :disabled="!isCollapsed">
<slot name="icon">
<span class="grid flex-shrink-0 place-items-center">
<FeatherIcon
v-if="typeof icon == 'string'"
:name="icon"
class="size-4 text-gray-700"
/>
<component v-else :is="icon" class="size-4 text-gray-700" />
</span>
</slot>
</Tooltip>
<Tooltip :text="label" placement="right" :disabled="isCollapsed" :hoverDelay="1.5">
<span
class="flex-1 flex-shrink-0 truncate text-sm duration-300 ease-in-out"
:class="
isCollapsed
? 'ml-0 w-0 overflow-hidden opacity-0'
: 'ml-2 w-auto opacity-100'
"
>
{{ label }}
</span>
</Tooltip>
</div>
<slot name="right" />
</div>
</button>
</template>
<script setup>
import { Tooltip, FeatherIcon } from 'frappe-ui'
import { computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
// import { isMobileView, mobileSidebarOpened } from '@/composables/settings'
const router = useRouter()
const route = useRoute()
const props = defineProps({
icon: {
type: [Object, String],
},
label: {
type: String,
default: '',
},
to: {
type: [Object, String],
default: '',
},
isCollapsed: {
type: Boolean,
default: false,
},
})
function handleClick() {
if (!props.to) return
if (typeof props.to === 'object') {
router.push(props.to)
} else {
router.push({ name: props.to })
}
// if (isMobileView.value) {
// mobileSidebarOpened.value = false
// }
}
let isActive = computed(() => {
if (route.query.view) {
return route.query.view == props.to?.query?.view
}
return route.name === props.to
})
</script>

View file

@ -0,0 +1,58 @@
<template>
<div
v-if="!isSidebarCollapsed && showBanner"
class="m-2 flex flex-col gap-3 shadow-sm rounded-lg py-2.5 px-3 bg-white text-base"
>
<div class="flex flex-col gap-1">
<div class="inline-flex gap-2 items-center font-medium">
<FeatherIcon class="h-4" name="info" />
{{ trialTitle }}
</div>
<div class="text-gray-700 text-sm font-normal leading-5">
{{ trialMessage }}
</div>
</div>
<Button :label="'Upgrade plan'" theme="red" @click="emit('upgradePlan')">
<template #prefix>
<LightningIcon class="size-4" />
</template>
</Button>
</div>
</template>
<script setup>
import LightningIcon from '../icons/LightningIcon.vue'
import { calculateTrialEndDays } from '../utils.js'
import { FeatherIcon, Button, createResource } from 'frappe-ui'
import { ref, computed } from 'vue'
const props = defineProps({
isSidebarCollapsed: {
type: Boolean,
default: false,
},
})
const emit = defineEmits(['upgradePlan'])
const trialEndDays = ref(0)
const showBanner = ref(false)
const trialTitle = computed(() => {
return trialEndDays.value > 1
? 'Trial ends in ' + trialEndDays.value + ' days'
: 'Trial will end tomorrow'
})
const trialMessage = 'Upgrade to get latest and exclusive features'
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.current_site_info',
cache: 'currentSiteInfo',
auto: true,
onSuccess: (data) => {
trialEndDays.value = calculateTrialEndDays(data.trial_end_date)
showBanner.value =
window.setup_complete && data.plan.is_trial_plan && trialEndDays.value > 0
},
})
</script>

View file

@ -0,0 +1,126 @@
<template>
<Dialog v-model="show">
<template #body-main>
<div class="bg-white px-4 pb-6 pt-5 sm:px-6">
<div class="flex items-center justify-between">
<div class="flex flex-col w-full">
<div class="flex justify-between items-center">
<div class="inline-flex items-center gap-1.5 text-base text-gray-800">
<div
v-if="step > 1"
class="-ml-0.5 cursor-pointer"
@click="step = step - 1"
>
<FeatherIcon class="h-4.5" name="chevron-left" />
</div>
<span>Step {{ step }}/2</span>
</div>
<Button variant="ghost" @click="show = false">
<template #icon>
<FeatherIcon name="x" class="h-4 w-4" />
</template>
</Button>
</div>
<h3 class="text-2xl font-semibold leading-6 text-gray-900">
{{ title || 'Untitled' }}
</h3>
<div
class="my-5 h-px w-full"
:class="[
step === 1
? 'bg-[linear-gradient(to_right,black_0%,black_50%,#ededed_50%,#ededed_100%)]'
: 'bg-[linear-gradient(to_right,#ededed_0%,#ededed_50%,black_50%,black_100%)]',
]"
/>
</div>
</div>
<div v-show="step === 1">
<BillingDetails ref="billingRef" @success="() => (step = step + 1)" />
</div>
<div v-show="step === 2">
<div class="text-sm text-gray-800 mb-7.5">
<div class="mb-1.5">Payment mode</div>
<TabButtons v-model="activeTab" :buttons="paymentModes" />
<div class="flex gap-1.5 mt-2">
<FeatherIcon class="h-4" name="info" />
{{ paymentModes.find((m) => m.value == activeTab).description }}
</div>
</div>
<CardForm v-show="activeTab == 'Card'" @success="updateMode" />
<PrepaidCreditsForm
v-show="activeTab == 'Prepaid Credits'"
@success="updateMode"
/>
</div>
</div>
</template>
</Dialog>
</template>
<script setup>
import BillingDetails from './BillingDetails.vue'
import CardForm from './CardForm.vue'
import PrepaidCreditsForm from './PrepaidCreditsForm.vue'
import { Dialog, Button, FeatherIcon, TabButtons, createResource } from 'frappe-ui'
import { ref, computed, inject } from 'vue'
const props = defineProps({
defaultStep: {
type: Number,
default: 1,
},
planName: {
type: String,
required: true,
},
})
const emit = defineEmits(['success'])
const { reloadPlans, reloadSite } = inject('billing')
const show = defineModel()
const step = ref(props.defaultStep)
const title = computed(() => (step.value === 1 ? 'Billing Details' : 'Payment mode'))
const billingRef = ref(null)
const activeTab = ref('Card')
const paymentModes = [
{
label: 'Card',
value: 'Card',
description: 'Your card will be charged for monthly subscription',
},
{
label: 'Prepaid credits',
value: 'Prepaid Credits',
description: 'You will be charged from your credit balance for monthly subscription',
},
]
function updateMode() {
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: {
method: 'billing.change_payment_mode',
data: { mode: activeTab.value },
},
auto: true,
onSuccess: () => upgradePlan(),
})
}
function upgradePlan() {
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'site.change_plan', data: { plan: props.planName } },
auto: true,
onSuccess: () => {
reloadSite()
reloadPlans()
show.value = false
emit('success')
},
})
}
</script>

43
billing/src/dialogs.js Normal file
View file

@ -0,0 +1,43 @@
import { Dialog, ErrorMessage } from 'frappe-ui'
import { reactive, ref, h } from 'vue'
let dialogs = ref([])
export let Dialogs = {
name: 'Dialogs',
render() {
return dialogs.value.map((dialog) => {
return h(
Dialog,
{
options: dialog,
modelValue: dialog.show,
'onUpdate:modelValue': (val) => (dialog.show = val),
onClose: () => {
let index = dialogs.value.indexOf(dialog)
dialogs.value.splice(index, 1)
dialog.onClose && dialog.onClose()
},
},
{
'body-content': () => {
return [
dialog.message &&
h('p', { class: 'text-p-base text-gray-700' }, dialog.message),
dialog.html && h('div', { innerHTML: dialog.html }),
h(ErrorMessage, { class: 'mt-2', message: dialog.error }),
]
},
},
)
})
},
}
export function createDialog(dialogOptions) {
let dialog = reactive(dialogOptions)
dialog.key = 'dialog-' + dialogs.value.length
dialog.show = false
setTimeout(() => (dialog.show = true), 0)
dialogs.value.push(dialog)
}

View file

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.00516 3.89776C2.05637 3.3935 2.48223 3 3 3H13C13.5523 3 14 3.44772 14 4V5.38867H2V4C2 3.96548 2.00175 3.93137 2.00516 3.89776ZM1 6.38867V5.38867V4C1 3.93096 1.0035 3.86275 1.01033 3.79551C1.11275 2.787 1.96447 2 3 2H13C14.1046 2 15 2.89543 15 4V5.38867V6.38867V12C15 13.1046 14.1046 14 13 14H3C1.89543 14 1 13.1046 1 12V6.38867ZM14 6.38867V12C14 12.5523 13.5523 13 13 13H3C2.44772 13 2 12.5523 2 12V6.38867H14Z"
fill="currentColor"
/>
</svg>
</template>

View file

@ -0,0 +1,10 @@
<template>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M2.00516 3.89776C2.05637 3.3935 2.48223 3 3 3H13C13.5523 3 14 3.44772 14 4V5.38867H2V4C2 3.96548 2.00175 3.93137 2.00516 3.89776ZM1 6.38867V5.38867V4C1 3.93096 1.0035 3.86275 1.01033 3.79551C1.11275 2.787 1.96447 2 3 2H13C14.1046 2 15 2.89543 15 4V5.38867V6.38867V12C15 13.1046 14.1046 14 13 14H3C1.89543 14 1 13.1046 1 12V6.38867ZM14 6.38867V12C14 12.5523 13.5523 13 13 13H3C2.44772 13 2 12.5523 2 12V6.38867H14Z"
fill="currentColor"
/>
</svg>
</template>

View file

@ -0,0 +1,17 @@
<template>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M10.875 9.06223L3 9.06232"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M6.74537 5.31699L3 9.06236L6.74527 12.8076"
stroke="currentColor"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path d="M14.1423 4L14.1423 14.125" stroke="currentColor" stroke-linecap="round" />
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M16 32c8.837 0 16-7.163 16-16S24.837 0 16 0 0 7.163 0 16s7.163 16 16 16z"
fill="#59B179"
/>
<path
d="M9.333 17.227l1.333 1.333 2.667 2.667 5.333-5.333 2.667-2.667 1.333-1.333"
stroke="#fff"
stroke-width="2"
stroke-miterlimit="10"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</template>

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,16 @@
<template>
<svg
width="17"
height="16"
viewBox="0 0 17 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M6.2641 1C5.5758 1 4.97583 1.46845 4.80889 2.1362L3.57555 7.06953C3.33887 8.01625 4.05491 8.93333 5.03077 8.93333H7.50682L6.72168 14.4293C6.68838 14.6624 6.82229 14.8872 7.04319 14.9689C7.26408 15.0507 7.51204 14.9671 7.63849 14.7684L13.2161 6.00354C13.6398 5.33782 13.1616 4.46667 12.3725 4.46667H9.59038L10.3017 1.62127C10.3391 1.4719 10.3055 1.31365 10.2108 1.19229C10.116 1.07094 9.97063 1 9.81666 1H6.2641ZM5.77903 2.37873C5.83468 2.15615 6.03467 2 6.2641 2H9.17627L8.46492 4.8454C8.42758 4.99477 8.46114 5.15302 8.55589 5.27437C8.65064 5.39573 8.79602 5.46667 8.94999 5.46667H12.3725L8.0395 12.2757L8.5783 8.50404C8.5988 8.36056 8.55602 8.21523 8.46105 8.10573C8.36608 7.99623 8.22827 7.93333 8.08332 7.93333H5.03077C4.70548 7.93333 4.4668 7.62764 4.5457 7.31207L5.77903 2.37873Z"
fill="currentColor"
/>
</svg>
</template>

View file

@ -0,0 +1,10 @@
<template>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M10.1787 14.1967C9.57127 14.3936 8.92306 14.5 8.25 14.5C4.79822 14.5 2 11.7018 2 8.25C2 5.85176 3.35077 3.76901 5.33301 2.72104V10.4262L4.25346 9.34664C4.05819 9.15138 3.74161 9.15138 3.54635 9.34664C3.35109 9.5419 3.35109 9.85849 3.54635 10.0537L5.47968 11.9871C5.67494 12.1823 5.99153 12.1823 6.18679 11.9871L8.12012 10.0537C8.31538 9.85849 8.31538 9.5419 8.12012 9.34664C7.92486 9.15138 7.60828 9.15138 7.41302 9.34664L6.33301 10.4267V2.41357C6.33301 2.37572 6.3288 2.33886 6.32083 2.30341C6.92842 2.10644 7.57678 2 8.25 2C11.7018 2 14.5 4.79822 14.5 8.25C14.5 10.6484 13.149 12.7313 11.1665 13.7792V6.07358L12.2465 7.15359C12.4418 7.34885 12.7584 7.34885 12.9536 7.15359C13.1489 6.95832 13.1489 6.64174 12.9536 6.44648L11.0203 4.51315C10.9512 4.44411 10.867 4.39948 10.7784 4.37926C10.7417 4.37089 10.7042 4.3667 10.6667 4.3667H10.6665C10.5239 4.3667 10.3952 4.42643 10.3041 4.52226L8.37985 6.44648C8.18458 6.64174 8.18458 6.95832 8.37985 7.15359C8.57511 7.34885 8.89169 7.34885 9.08695 7.15359L10.1665 6.07403V14.0863C10.1665 14.1242 10.1707 14.1612 10.1787 14.1967ZM8.25 15.5C12.2541 15.5 15.5 12.2541 15.5 8.25C15.5 4.24594 12.2541 1 8.25 1C4.24594 1 1 4.24594 1 8.25C1 12.2541 4.24594 15.5 8.25 15.5Z"
fill="currentColor"
/>
</svg>
</template>

View file

@ -0,0 +1,16 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M4 1.43311C3.17157 1.43311 2.5 2.10468 2.5 2.93311V13.9522L3.99324 12.7444C4.19295 12.5828 4.48257 12.5989 4.66319 12.7815L6.15385 14.2887L7.6445 12.7815C7.73842 12.6865 7.86643 12.6331 8 12.6331C8.13357 12.6331 8.26158 12.6865 8.3555 12.7815L9.84615 14.2887L11.3368 12.7815C11.5174 12.5989 11.807 12.5828 12.0068 12.7444L13.5 13.9522V2.9331C13.5 2.10468 12.8284 1.43311 12 1.43311H4ZM1.5 2.93311C1.5 1.55239 2.61929 0.433105 4 0.433105H12C13.3807 0.433105 14.5 1.55239 14.5 2.9331V14.9998C14.5 15.1925 14.3893 15.368 14.2154 15.451C14.0415 15.534 13.8354 15.5097 13.6855 15.3885L11.7296 13.8064L10.2017 15.3514C10.1077 15.4463 9.97972 15.4998 9.84615 15.4998C9.71259 15.4998 9.58457 15.4463 9.49065 15.3514L8 13.8442L6.50935 15.3514C6.41543 15.4463 6.28741 15.4998 6.15385 15.4998C6.02028 15.4998 5.89227 15.4463 5.79834 15.3514L4.27036 13.8064L2.31445 15.3885C2.16464 15.5097 1.95852 15.534 1.78462 15.451C1.61073 15.368 1.5 15.1925 1.5 14.9998V2.93311ZM4 5.5C4 5.22386 4.22386 5 4.5 5H11.5C11.7761 5 12 5.22386 12 5.5C12 5.77614 11.7761 6 11.5 6H4.5C4.22386 6 4 5.77614 4 5.5ZM4.5 9C4.22386 9 4 9.22386 4 9.5C4 9.77614 4.22386 10 4.5 10H11.5C11.7761 10 12 9.77614 12 9.5C12 9.22386 11.7761 9 11.5 9H4.5Z"
fill="currentColor"
/>
</svg>
</template>

70
billing/src/logo/Amex.vue Normal file
View file

@ -0,0 +1,70 @@
<template>
<svg
width="18"
height="17"
viewBox="0 0 18 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_dd_12615_14778)">
<rect x="2" y="2" width="14" height="12" rx="2" fill="#007AC6" />
<path
d="M4.51133 8.14316H5.28799L4.89966 7.19083L4.51133 8.14316ZM15.3337 6.3335H13.344L12.8587 6.85716L12.4217 6.3335H8.19966L7.81133 7.1905L7.42299 6.3335H5.72433V6.7145L5.53033 6.3335H4.07433L2.66699 9.66683H4.36566L4.55966 9.14316H5.04499L5.23899 9.66683H7.13166V9.28583L7.27733 9.66683H8.24799L8.39366 9.23816V9.66683H12.2763L12.7617 9.14316L13.1983 9.66683H15.188L13.9263 8.00016L15.3337 6.3335ZM9.46133 9.1905H8.92733V7.3335L8.10233 9.1905H7.61699L6.79199 7.3335V9.1905H5.67566L5.48166 8.66683H4.31699L4.12299 9.1905H3.49199L4.51099 6.8095H5.33599L6.25799 9.0475V6.8095H7.17999L7.90799 8.4285L8.58733 6.8095H9.50933V9.1905H9.46133ZM14.266 9.1905H13.538L12.907 8.38083L12.179 9.1905H9.99499V6.8095H12.2273L12.9067 7.5715L13.6347 6.8095H14.314L13.2467 8.00016L14.266 9.1905ZM10.529 7.28583V7.7145H11.7423V8.19083H10.529V8.66683H11.888L12.519 7.9525L11.9367 7.28583H10.529Z"
fill="white"
/>
</g>
<defs>
<filter
id="filter0_dd_12615_14778"
x="0"
y="1"
width="18"
height="16"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_12615_14778"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="0.5" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.45 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_12615_14778"
result="effect2_dropShadow_12615_14778"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_12615_14778"
result="shape"
/>
</filter>
</defs>
</svg>
</template>

View file

@ -0,0 +1,24 @@
<template>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
opacity="0.85"
d="M6.33366 10.6668C7.80642 10.6668 9.00033 9.47292 9.00033 8.00016C9.00033 6.5274 7.80642 5.3335 6.33366 5.3335C4.8609 5.3335 3.66699 6.5274 3.66699 8.00016C3.66699 9.47292 4.8609 10.6668 6.33366 10.6668Z"
fill="#171717"
/>
<path
d="M10.3335 2.00006H4.66683C4.31321 2.00006 3.65094 2.00002 3 2.00006C1.89544 2.00012 1 2.8955 1 4.00006V10.3333C1 10.49 1 11.2272 1 11.9999C1 13.1045 1.8955 14 3.00008 14C3.31377 14 3.56754 14 3.66683 14H13.3335C14.2539 14 15 13.254 15 12.3336C15 11.451 15 10.5125 15 10.3333V3.9998C15 2.89533 14.1047 1.99998 13.0002 2.00001C12.3097 2.00002 11.7416 2.00006 11.7416 2.00006C11.7416 2.00006 10.6871 2.00006 10.3335 2.00006ZM9.66683 11.3333C9.08115 11.3331 8.50602 11.1775 8.00016 10.8823C7.49356 11.1766 6.91835 11.3322 6.33248 11.3333C5.74661 11.3345 5.17078 11.1812 4.66302 10.889C4.15525 10.5967 3.73349 10.1758 3.44022 9.66858C3.14695 9.16139 2.99253 8.58587 2.99253 8C2.99253 7.41413 3.14695 6.83861 3.44022 6.33143C3.73349 5.82424 4.15525 5.40332 4.66302 5.11105C5.17078 4.81879 5.74661 4.66552 6.33248 4.66668C6.91835 4.66784 7.49356 4.8234 8.00016 5.11767C8.44288 4.86051 8.93888 4.70878 9.4497 4.67427C9.96052 4.63975 10.4724 4.72336 10.9457 4.91862C11.419 5.11388 11.841 5.41554 12.1788 5.80021C12.5167 6.18487 12.7614 6.64219 12.894 7.13671C13.0266 7.63123 13.0435 8.14964 12.9434 8.65174C12.8433 9.15385 12.6289 9.62615 12.3168 10.032C12.0047 10.4379 11.6033 10.7664 11.1437 10.992C10.6842 11.2177 10.1788 11.3345 9.66683 11.3333Z"
fill="#171717"
/>
<path
opacity="0.5"
d="M9.66667 10.6668C11.1394 10.6668 12.3333 9.47292 12.3333 8.00016C12.3333 6.5274 11.1394 5.3335 9.66667 5.3335C8.19391 5.3335 7 6.5274 7 8.00016C7 9.47292 8.19391 10.6668 9.66667 10.6668Z"
fill="#171717"
/>
</svg>
</template>

96
billing/src/logo/JCB.vue Normal file

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,78 @@
<template>
<svg
width="18"
height="17"
viewBox="0 0 18 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_dd_12545_15826)">
<rect x="2" y="2" width="14" height="12" rx="2" fill="white" />
<path
d="M7.33366 10.6668C8.80642 10.6668 10.0003 9.47292 10.0003 8.00016C10.0003 6.5274 8.80642 5.3335 7.33366 5.3335C5.8609 5.3335 4.66699 6.5274 4.66699 8.00016C4.66699 9.47292 5.8609 10.6668 7.33366 10.6668Z"
fill="#E61C24"
/>
<path
d="M10.6447 10.6668C12.1175 10.6668 13.3114 9.47292 13.3114 8.00016C13.3114 6.5274 12.1175 5.3335 10.6447 5.3335C9.17193 5.3335 7.97803 6.5274 7.97803 8.00016C7.97803 9.47292 9.17193 10.6668 10.6447 10.6668Z"
fill="#F99F1B"
/>
<path
d="M8.98902 5.91211C8.67384 6.16127 8.41916 6.47856 8.24407 6.84017C8.06898 7.20178 7.97803 7.59834 7.97803 8.00011C7.97803 8.40188 8.06898 8.79844 8.24407 9.16005C8.41916 9.52166 8.67384 9.83895 8.98902 10.0881C9.3042 9.83895 9.55888 9.52166 9.73397 9.16005C9.90906 8.79844 10 8.40188 10 8.00011C10 7.59834 9.90906 7.20178 9.73397 6.84017C9.55888 6.47856 9.3042 6.16127 8.98902 5.91211Z"
fill="#F26622"
/>
</g>
<defs>
<filter
id="filter0_dd_12545_15826"
x="0"
y="1"
width="18"
height="16"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_12545_15826"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="0.5" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.45 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_12545_15826"
result="effect2_dropShadow_12545_15826"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_12545_15826"
result="shape"
/>
</filter>
</defs>
</svg>
</template>

View file

@ -0,0 +1,27 @@
<template>
<svg
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
width="119px"
height="26px"
>
<path
fill-rule="evenodd"
opacity="0.349"
fill="rgb(66, 71, 112)"
d="M113.000,26.000 L6.000,26.000 C2.686,26.000 -0.000,23.314 -0.000,20.000 L-0.000,6.000 C-0.000,2.686 2.686,-0.000 6.000,-0.000 L113.000,-0.000 C116.314,-0.000 119.000,2.686 119.000,6.000 L119.000,20.000 C119.000,23.314 116.314,26.000 113.000,26.000 ZM118.000,6.000 C118.000,3.239 115.761,1.000 113.000,1.000 L6.000,1.000 C3.239,1.000 1.000,3.239 1.000,6.000 L1.000,20.000 C1.000,22.761 3.239,25.000 6.000,25.000 L113.000,25.000 C115.761,25.000 118.000,22.761 118.000,20.000 L118.000,6.000 Z"
/>
<path
fill-rule="evenodd"
opacity="0.502"
fill="rgb(66, 71, 112)"
d="M60.700,18.437 L59.395,18.437 L60.405,15.943 L58.395,10.871 L59.774,10.871 L61.037,14.323 L62.310,10.871 L63.689,10.871 L60.700,18.437 ZM55.690,16.259 C55.238,16.259 54.774,16.091 54.354,15.764 L54.354,16.133 L53.007,16.133 L53.007,8.566 L54.354,8.566 L54.354,11.229 C54.774,10.913 55.238,10.745 55.690,10.745 C57.100,10.745 58.068,11.881 58.068,13.502 C58.068,15.122 57.100,16.259 55.690,16.259 ZM55.406,11.902 C55.038,11.902 54.669,12.060 54.354,12.376 L54.354,14.628 C54.669,14.943 55.038,15.101 55.406,15.101 C56.164,15.101 56.690,14.449 56.690,13.502 C56.690,12.555 56.164,11.902 55.406,11.902 ZM47.554,15.764 C47.144,16.091 46.681,16.259 46.218,16.259 C44.818,16.259 43.840,15.122 43.840,13.502 C43.840,11.881 44.818,10.745 46.218,10.745 C46.681,10.745 47.144,10.913 47.554,11.229 L47.554,8.566 L48.912,8.566 L48.912,16.133 L47.554,16.133 L47.554,15.764 ZM47.554,12.376 C47.249,12.060 46.881,11.902 46.513,11.902 C45.744,11.902 45.218,12.555 45.218,13.502 C45.218,14.449 45.744,15.101 46.513,15.101 C46.881,15.101 47.249,14.943 47.554,14.628 L47.554,12.376 ZM39.535,13.870 C39.619,14.670 40.251,15.217 41.134,15.217 C41.619,15.217 42.155,15.038 42.702,14.722 L42.702,15.849 C42.103,16.122 41.503,16.259 40.913,16.259 C39.324,16.259 38.209,15.101 38.209,13.460 C38.209,11.871 39.303,10.745 40.808,10.745 C42.187,10.745 43.123,11.829 43.123,13.375 C43.123,13.523 43.123,13.691 43.102,13.870 L39.535,13.870 ZM40.756,11.786 C40.103,11.786 39.598,12.271 39.535,12.997 L41.829,12.997 C41.787,12.281 41.356,11.786 40.756,11.786 ZM35.988,12.618 L35.988,16.133 L34.641,16.133 L34.641,10.871 L35.988,10.871 L35.988,11.397 C36.367,10.976 36.830,10.745 37.282,10.745 C37.430,10.745 37.577,10.755 37.724,10.797 L37.724,11.997 C37.577,11.955 37.409,11.934 37.251,11.934 C36.809,11.934 36.335,12.176 35.988,12.618 ZM29.979,13.870 C30.063,14.670 30.694,15.217 31.578,15.217 C32.062,15.217 32.599,15.038 33.146,14.722 L33.146,15.849 C32.546,16.122 31.946,16.259 31.357,16.259 C29.768,16.259 28.653,15.101 28.653,13.460 C28.653,11.871 29.747,10.745 31.252,10.745 C32.630,10.745 33.567,11.829 33.567,13.375 C33.567,13.523 33.567,13.691 33.546,13.870 L29.979,13.870 ZM31.199,11.786 C30.547,11.786 30.042,12.271 29.979,12.997 L32.273,12.997 C32.231,12.281 31.799,11.786 31.199,11.786 ZM25.274,16.133 L24.200,12.555 L23.137,16.133 L21.927,16.133 L20.117,10.871 L21.464,10.871 L22.527,14.449 L23.590,10.871 L24.810,10.871 L25.873,14.449 L26.936,10.871 L28.283,10.871 L26.484,16.133 L25.274,16.133 ZM17.043,16.259 C15.454,16.259 14.328,15.112 14.328,13.502 C14.328,11.881 15.454,10.745 17.043,10.745 C18.632,10.745 19.748,11.881 19.748,13.502 C19.748,15.112 18.632,16.259 17.043,16.259 ZM17.043,11.871 C16.254,11.871 15.707,12.534 15.707,13.502 C15.707,14.470 16.254,15.133 17.043,15.133 C17.822,15.133 18.369,14.470 18.369,13.502 C18.369,12.534 17.822,11.871 17.043,11.871 ZM11.128,13.533 L9.918,13.533 L9.918,16.133 L8.571,16.133 L8.571,8.892 L11.128,8.892 C12.602,8.892 13.654,9.850 13.654,11.218 C13.654,12.586 12.602,13.533 11.128,13.533 ZM10.939,9.987 L9.918,9.987 L9.918,12.439 L10.939,12.439 C11.718,12.439 12.265,11.944 12.265,11.218 C12.265,10.482 11.718,9.987 10.939,9.987 Z"
/>
<path
fill-rule="evenodd"
opacity="0.502"
fill="rgb(66, 71, 112)"
d="M111.116,14.051 L105.557,14.051 C105.684,15.382 106.659,15.774 107.766,15.774 C108.893,15.774 109.781,15.536 110.555,15.146 L110.555,17.433 C109.784,17.861 108.765,18.169 107.408,18.169 C104.642,18.169 102.704,16.437 102.704,13.013 C102.704,10.121 104.348,7.825 107.049,7.825 C109.746,7.825 111.154,10.120 111.154,13.028 C111.154,13.303 111.129,13.898 111.116,14.051 ZM107.031,10.140 C106.321,10.140 105.532,10.676 105.532,11.955 L108.468,11.955 C108.468,10.677 107.728,10.140 107.031,10.140 ZM98.108,18.169 C97.114,18.169 96.507,17.750 96.099,17.451 L96.093,20.664 L93.254,21.268 L93.253,8.014 L95.753,8.014 L95.901,8.715 C96.293,8.349 97.012,7.825 98.125,7.825 C100.119,7.825 101.997,9.621 101.997,12.927 C101.997,16.535 100.139,18.169 98.108,18.169 ZM97.446,10.340 C96.795,10.340 96.386,10.578 96.090,10.903 L96.107,15.122 C96.383,15.421 96.780,15.661 97.446,15.661 C98.496,15.661 99.200,14.518 99.200,12.989 C99.200,11.504 98.485,10.340 97.446,10.340 ZM89.149,8.014 L91.999,8.014 L91.999,17.966 L89.149,17.966 L89.149,8.014 ZM89.149,4.836 L91.999,4.230 L91.999,6.543 L89.149,7.149 L89.149,4.836 ZM86.110,11.219 L86.110,17.966 L83.272,17.966 L83.272,8.014 L85.727,8.014 L85.905,8.853 C86.570,7.631 87.897,7.879 88.275,8.015 L88.275,10.625 C87.914,10.508 86.781,10.338 86.110,11.219 ZM80.024,14.475 C80.024,16.148 81.816,15.627 82.179,15.482 L82.179,17.793 C81.801,18.001 81.115,18.169 80.187,18.169 C78.502,18.169 77.237,16.928 77.237,15.247 L77.250,6.138 L80.022,5.548 L80.024,8.014 L82.180,8.014 L82.180,10.435 L80.024,10.435 L80.024,14.475 ZM76.485,14.959 C76.485,17.003 74.858,18.169 72.497,18.169 C71.518,18.169 70.448,17.979 69.392,17.525 L69.392,14.814 C70.345,15.332 71.559,15.721 72.500,15.721 C73.133,15.721 73.589,15.551 73.589,15.026 C73.589,13.671 69.273,14.181 69.273,11.038 C69.273,9.028 70.808,7.825 73.111,7.825 C74.052,7.825 74.992,7.969 75.933,8.344 L75.933,11.019 C75.069,10.552 73.972,10.288 73.109,10.288 C72.514,10.288 72.144,10.460 72.144,10.903 C72.144,12.181 76.485,11.573 76.485,14.959 Z"
/>
</svg>
</template>

View file

@ -0,0 +1,50 @@
<template>
<svg
width="847"
height="180"
viewBox="0 0 847 180"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M223.833 67.1063C226.646 56.5688 225.484 48.5063 220.345 42.9188C215.207 37.3313 206.224 34.5188 193.398 34.4813H144.247L114.617 145.65H138.414L150.397 100.65H166.112C168.987 100.506 171.852 101.093 174.438 102.356C175.488 103.013 176.363 103.913 176.99 104.981C177.617 106.048 177.977 107.251 178.039 108.488L182.333 145.65H207.837L203.674 111.131C202.849 103.425 199.305 98.8876 193.154 97.5189C200.596 95.4659 207.387 91.5412 212.882 86.1189C218.191 80.893 221.977 74.3204 223.833 67.1063ZM201.049 67.5001C200.023 72.0579 197.231 76.0227 193.285 78.5251C189.534 80.8876 184.059 82.0501 177.101 82.0501H155.011L162.774 53.0063H184.884C191.822 53.0063 196.586 54.1876 199.211 56.5313C201.836 58.8751 202.399 62.5876 201.049 67.5001Z"
fill="#0C2651"
/>
<path
d="M292.544 66.1875L289.524 77.4375C288.266 73.4169 285.604 69.9828 282.023 67.7625C277.847 65.2563 273.032 64.0185 268.165 64.2C261.337 64.2018 254.627 65.9856 248.7 69.375C242.248 73.0744 236.676 78.1279 232.366 84.1875C227.616 90.7391 224.137 98.1228 222.108 105.956C219.98 112.928 219.53 120.303 220.795 127.481C221.761 133.151 224.795 138.261 229.309 141.825C234.216 145.333 240.161 147.09 246.187 146.813C251.568 146.825 256.888 145.673 261.782 143.437C266.676 141.2 271.028 137.932 274.541 133.856L271.428 145.594H294.625L315.778 66.1875H292.544ZM282.005 105.563C280.57 112.028 277.377 117.972 272.778 122.738C270.798 124.676 268.451 126.201 265.876 127.225C263.3 128.248 260.547 128.75 257.776 128.7C251.869 128.7 247.893 126.825 245.755 122.831C243.618 118.838 243.561 113.269 245.512 105.956C246.995 99.433 250.238 93.441 254.888 88.6313C256.875 86.6345 259.24 85.0539 261.845 83.9819C264.45 82.9098 267.242 82.3678 270.059 82.3875C275.835 82.3875 279.773 84.3938 281.798 88.3875C283.824 92.3813 284.03 98.025 282.042 105.544L282.005 105.563Z"
fill="#0C2651"
/>
<path
d="M398.534 66.2437H331.718L326.786 84.7687H365.679L314.54 129.375L310.171 145.744H379.181L384.113 127.219H342.388L394.371 81.9749L398.534 66.2437Z"
fill="#0C2651"
/>
<path
d="M471.614 69.1501C465.409 65.605 458.332 63.8766 451.192 64.1626C443.138 64.0929 435.167 65.7956 427.845 69.1501C420.723 72.4705 414.465 77.3925 409.561 83.5314C404.438 89.9708 400.738 97.4217 398.703 105.394C396.514 112.484 396.243 120.027 397.915 127.256C399.45 133.257 403.264 138.423 408.548 141.656C413.924 144.969 420.8 146.625 429.176 146.625C437.144 146.695 445.028 144.998 452.261 141.656C459.361 138.335 465.589 133.405 470.451 127.256C475.584 120.821 479.291 113.369 481.328 105.394C483.521 98.3039 483.786 90.7579 482.097 83.5314C480.62 77.5504 476.856 72.387 471.614 69.1501ZM458.074 105.356C455.993 113.156 452.936 119.006 448.81 122.869C444.829 126.661 439.512 128.73 434.014 128.625C421.888 128.625 417.893 120.869 422.031 105.356C424.069 97.6564 427.195 91.8501 431.408 87.9376C435.44 84.0812 440.831 81.9721 446.41 82.0689C452.186 82.0689 456.124 83.9439 458.149 87.9376C460.175 91.9314 460.118 97.6501 458.074 105.356Z"
fill="#0C2651"
/>
<path
d="M724.213 66.1875L721.194 77.4375C719.936 73.4169 717.274 69.9828 713.693 67.7625C709.524 65.2336 704.708 63.9759 699.835 64.1438C692.995 64.1586 686.278 65.962 680.351 69.375C673.894 73.0736 668.315 78.1271 663.998 84.1875C659.256 90.7434 655.777 98.1257 653.74 105.956C651.631 112.931 651.181 120.302 652.428 127.481C653.394 133.151 656.427 138.261 660.942 141.825C665.913 145.362 671.932 147.119 678.025 146.813C683.286 146.793 688.483 145.655 693.271 143.475C698.212 141.258 702.599 137.973 706.117 133.856L703.004 145.594H726.201L747.373 66.1875H724.213ZM713.618 105.563C712.19 112.026 709.004 117.97 704.41 122.738C702.426 124.67 700.079 126.191 697.504 127.215C694.93 128.238 692.178 128.743 689.408 128.7C683.482 128.7 679.507 126.825 677.369 122.831C675.231 118.838 675.194 113.269 677.125 105.956C678.616 99.4361 681.858 93.4461 686.502 88.6313C688.493 86.6357 690.862 85.056 693.469 83.9841C696.077 82.9122 698.872 82.3695 701.691 82.3875C707.467 82.3875 711.38 84.3875 713.431 88.3875C715.606 92.2688 715.662 98.025 713.618 105.544V105.563Z"
fill="#0C2651"
/>
<path
d="M554.182 87.5628L560.108 65.9441C557.609 64.8129 554.88 64.28 552.138 64.3878C546.778 64.3939 541.509 65.7755 536.836 68.4003C532.574 70.7348 528.875 73.973 525.997 77.8878L529.053 66.2441H505.875L484.534 145.65H508.05L519.096 104.194C520.39 98.6623 523.419 93.6891 527.741 90.0003C532.185 86.5295 537.707 84.7309 543.343 84.9191C547.118 84.9033 550.839 85.8109 554.182 87.5628Z"
fill="#0C2651"
/>
<path
d="M636.132 69.5437C631.334 65.8774 625.4 64.0126 619.367 64.275C613.497 64.2816 607.712 65.6825 602.49 68.3625C597.257 70.9353 592.746 74.7691 589.363 79.5187L589.457 78.975L593.413 66.2437H570.348L564.459 88.3125C564.459 88.5562 564.328 88.8187 564.272 89.0625L540.081 180H563.709L575.917 134.175C577.084 138.218 579.773 141.652 583.418 143.756C587.649 146.186 592.476 147.382 597.352 147.206C604.201 147.225 610.946 145.523 616.967 142.256C623.375 138.725 628.906 133.797 633.151 127.837C637.82 121.319 641.235 113.987 643.221 106.219C645.364 99.143 645.852 91.6692 644.646 84.375C643.659 78.5774 640.641 73.3201 636.132 69.5437ZM619.424 105.844C617.982 112.207 614.809 118.047 610.253 122.719C606.222 126.574 600.829 128.677 595.251 128.569C589.438 128.569 585.5 126.562 583.418 122.606C581.337 118.65 581.224 112.95 583.268 105.431C584.729 98.9159 587.976 92.9362 592.645 88.1625C594.628 86.2258 596.974 84.699 599.548 83.6695C602.123 82.64 604.875 82.1279 607.647 82.1625C613.273 82.1625 617.192 84.2625 619.236 88.4062C621.28 92.55 621.411 98.4187 619.424 105.844Z"
fill="#0C2651"
/>
<path
d="M846.013 63.5999H821.634L817.884 71.4374C817.584 71.8311 817.302 72.2249 816.965 72.7124L816.571 73.3499L786.566 115.312L780.341 66.2436H755.737L768.245 141.506L740.585 180H764.269L771.114 170.231C771.321 169.931 771.527 169.687 771.714 169.369L779.722 157.95L779.984 157.65L815.746 106.631L846.013 63.5999Z"
fill="#0C2651"
/>
<path
d="M54.3828 47.1751L47.2567 73.1251L87.4627 47.0438L61.2088 145.65H87.8565L126.656 6.10352e-05L54.3828 47.1751Z"
fill="#3395FF"
/>
<path
d="M11.0641 104.175L0 145.65H54.6643L77.0364 61.2748L11.0641 104.175Z"
fill="#0C2651"
/>
</svg>
</template>

View file

@ -0,0 +1,48 @@
<template>
<svg
version="1.1"
id="Layer_1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 468 222.5"
style="enable-background: new 0 0 468 222.5"
xml:space="preserve"
>
<g>
<path
fill="#635bff"
d="M414,113.4c0-25.6-12.4-45.8-36.1-45.8c-23.8,0-38.2,20.2-38.2,45.6c0,30.1,17,45.3,41.4,45.3
c11.9,0,20.9-2.7,27.7-6.5v-20c-6.8,3.4-14.6,5.5-24.5,5.5c-9.7,0-18.3-3.4-19.4-15.2h48.9C413.8,121,414,115.8,414,113.4z
M364.6,103.9c0-11.3,6.9-16,13.2-16c6.1,0,12.6,4.7,12.6,16H364.6z"
/>
<path
fill="#635bff"
d="M301.1,67.6c-9.8,0-16.1,4.6-19.6,7.8l-1.3-6.2h-22v116.6l25-5.3l0.1-28.3c3.6,2.6,8.9,6.3,17.7,6.3
c17.9,0,34.2-14.4,34.2-46.1C335.1,83.4,318.6,67.6,301.1,67.6z M295.1,136.5c-5.9,0-9.4-2.1-11.8-4.7l-0.1-37.1
c2.6-2.9,6.2-4.9,11.9-4.9c9.1,0,15.4,10.2,15.4,23.3C310.5,126.5,304.3,136.5,295.1,136.5z"
/>
<polygon
fill="#635bff"
points="223.8,61.7 248.9,56.3 248.9,36 223.8,41.3 "
/>
<rect x="223.8" y="69.3" fill="#635bff" width="25.1" height="87.5" />
<path
fill="#635bff"
d="M196.9,76.7l-1.6-7.4h-21.6v87.5h25V97.5c5.9-7.7,15.9-6.3,19-5.2v-23C214.5,68.1,202.8,65.9,196.9,76.7z"
/>
<path
fill="#635bff"
d="M146.9,47.6l-24.4,5.2l-0.1,80.1c0,14.8,11.1,25.7,25.9,25.7c8.2,0,14.2-1.5,17.5-3.3V135
c-3.2,1.3-19,5.9-19-8.9V90.6h19V69.3h-19L146.9,47.6z"
/>
<path
fill="#635bff"
d="M79.3,94.7c0-3.9,3.2-5.4,8.5-5.4c7.6,0,17.2,2.3,24.8,6.4V72.2c-8.3-3.3-16.5-4.6-24.8-4.6
C67.5,67.6,54,78.2,54,95.9c0,27.6,38,23.2,38,35.1c0,4.6-4,6.1-9.6,6.1c-8.3,0-18.9-3.4-27.3-8v23.8c9.3,4,18.7,5.7,27.3,5.7
c20.8,0,35.1-10.3,35.1-28.2C117.4,100.6,79.3,105.9,79.3,94.7z"
/>
</g>
</svg>
</template>

File diff suppressed because one or more lines are too long

82
billing/src/logo/Visa.vue Normal file
View file

@ -0,0 +1,82 @@
<template>
<svg
width="18"
height="17"
viewBox="0 0 18 17"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g filter="url(#filter0_dd_11161_2434)">
<rect x="2" y="2" width="14" height="12" rx="2" fill="white" />
<path
d="M7.4126 9.6002H8.28226L8.8266 6.4082H7.9566L7.4126 9.6002Z"
fill="#1A1876"
/>
<path
d="M10.5811 7.68991C10.2771 7.54225 10.0908 7.44358 10.0928 7.29425C10.0928 7.16091 10.2504 7.01958 10.5928 7.01958C10.845 7.01327 11.0951 7.06781 11.3218 7.17858L11.4398 6.48691C11.1897 6.3967 10.9256 6.35156 10.6598 6.35358C9.8001 6.35358 9.1931 6.78691 9.18944 7.40691C9.18477 7.86558 9.62277 8.12158 9.95177 8.27358C10.2904 8.42991 10.4044 8.52958 10.4028 8.66925C10.4004 8.88325 10.1324 8.98125 9.8821 8.98125C9.55825 8.98881 9.23782 8.91366 8.9511 8.76291L8.8291 9.47725C9.13922 9.59185 9.46748 9.64956 9.7981 9.64758C10.7128 9.64758 11.3064 9.21958 11.3131 8.55725C11.3148 8.19391 11.0831 7.91758 10.5811 7.68991Z"
fill="#1A1876"
/>
<path
d="M13.669 6.41123H12.9967C12.9022 6.39913 12.8064 6.4185 12.724 6.46634C12.6416 6.51418 12.5773 6.58783 12.541 6.67589L11.249 9.60023H12.1627C12.1627 9.60023 12.312 9.20689 12.3457 9.12056H13.46C13.486 9.23256 13.566 9.59889 13.566 9.59889H14.3734L13.669 6.41123ZM12.5964 8.46889C12.6324 8.37656 12.9874 7.46489 13.0584 7.27189C13.1757 7.82189 13.0614 7.28823 13.3184 8.46889H12.5964Z"
fill="#1A1876"
/>
<path
d="M6.68329 6.4107L5.83129 8.58737L5.74062 8.14504L5.43562 6.67837C5.41339 6.5934 5.36121 6.51932 5.28869 6.46977C5.21617 6.42022 5.12819 6.39853 5.04095 6.4087H3.63795L3.62695 6.47537C3.94595 6.55063 4.25337 6.66847 4.54095 6.8257L5.31429 9.59804H6.23495L7.60462 6.41204L6.68329 6.4107Z"
fill="#1A1876"
/>
</g>
<defs>
<filter
id="filter0_dd_11161_2434"
x="0"
y="1"
width="18"
height="16"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB"
>
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"
/>
<feBlend
mode="normal"
in2="BackgroundImageFix"
result="effect1_dropShadow_11161_2434"
/>
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset />
<feGaussianBlur stdDeviation="0.5" />
<feColorMatrix
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.45 0"
/>
<feBlend
mode="normal"
in2="effect1_dropShadow_11161_2434"
result="effect2_dropShadow_11161_2434"
/>
<feBlend
mode="normal"
in="SourceGraphic"
in2="effect2_dropShadow_11161_2434"
result="shape"
/>
</filter>
</defs>
</svg>
</template>

View file

@ -1,8 +1,14 @@
import './index.css'
import { createApp } from 'vue'
import { FrappeUI, setConfig, frappeRequest } from 'frappe-ui'
import router from './router'
import App from './App.vue'
let app = createApp(App)
setConfig('resourceFetcher', frappeRequest)
app.use(FrappeUI)
app.use(router)
app.mount('#app')

View file

@ -0,0 +1,46 @@
<template>
<div class="flex h-full flex-col py-11 px-[68px] gap-8 overflow-y-auto">
<h2 class="flex gap-2 text-xl font-semibold leading-5">
{{ 'Billing' }}
</h2>
<div v-if="team.data">
<CurrentPlan @changePlan="router.push({ name: 'Plans' })" />
<div class="bg-gray-100 h-px my-7" />
<PaymentDetails />
</div>
<div v-else class="flex flex-1 items-center justify-center">
<Spinner class="size-8" />
</div>
</div>
</template>
<script setup>
import CurrentPlan from '@/components/CurrentPlan.vue'
import PaymentDetails from '@/components/PaymentDetails.vue'
import { Spinner, createResource } from 'frappe-ui'
import { useRouter } from 'vue-router'
import { computed, provide } from 'vue'
const router = useRouter()
const team = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'team.info' },
cache: 'team',
auto: true,
})
const upcomingInvoice = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.upcoming_invoice' },
cache: 'upcomingInvoice',
auto: true,
})
provide('billing', {
team: computed(() => team.data),
reloadTeam: team.reload,
availableCredits: computed(() => upcomingInvoice.data?.available_credits),
currentBillingAmount: computed(() => upcomingInvoice.data?.upcoming_invoice.total),
reloadUpcomingInvoice: upcomingInvoice.reload,
})
</script>

View file

@ -0,0 +1,208 @@
<template>
<div class="flex h-full flex-col py-11 px-[68px] gap-8 overflow-y-auto">
<!-- <div class="text-lg font-semibold text-gray-900">
{{ 'Billing history' }}
</div> -->
<h2 class="flex gap-2 text-xl font-semibold leading-5">
{{ 'Billing history' }}
</h2>
<div>
<ListView
:columns="columns"
:rows="rows"
row-key="name"
:options="{
selectable: false,
}"
>
<ListHeader />
<ListRows>
<ListRow
v-for="row in rows"
:key="row.id"
v-slot="{ column, item }"
:row="row"
>
<ListRowItem :item="item" :align="column.align">
<template #prefix>
<InvoiceIcon v-if="column.key == 'name'" class="h-4" />
</template>
<Badge
v-if="column.key == 'status'"
:label="item.label"
variant="subtle"
:theme="item.color"
size="md"
/>
<Button
v-if="column.key == 'download' && item.url"
variant="ghost"
icon="download"
@click.stop="item.onClick"
/>
</ListRowItem>
</ListRow>
</ListRows>
</ListView>
</div>
</div>
</template>
<script setup>
import InvoiceIcon from '@/icons/InvoiceIcon.vue'
import {
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Button,
Badge,
createResource,
} from 'frappe-ui'
import { computed } from 'vue'
const team = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'team.info' },
cache: 'team',
auto: true,
})
const invoices = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.get_invoices' },
cache: 'invoices',
auto: true,
})
const columns = [
{
label: 'Invoice',
key: 'name',
},
{
label: 'Status',
key: 'status',
width: 0.8,
},
{
label: 'Period',
key: 'due_date',
width: 1.5,
},
{
label: 'Total',
key: 'total',
width: 1.2,
},
{
label: '',
key: 'download',
width: 0.5,
},
]
const rows = computed(() => {
if (!team.data) return []
return invoices.data?.map((invoice) => {
// Set name based on invoice type
let name = 'Prepaid Credits'
if (invoice.type == 'Subscription') {
name = new Date(invoice.period_end).toLocaleString('en-US', {
month: 'long',
year: 'numeric',
})
}
// Set due date based on invoice type
let due_date = new Date(invoice.due_date).toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
if (invoice.type == 'Subscription') {
let start = new Date(invoice.period_start)
let end = new Date(invoice.period_end)
let sameYear = start.getFullYear() === end.getFullYear()
let formattedStart = sameYear
? start.toLocaleString('en-US', { month: 'short', day: 'numeric' })
: start.toLocaleString('en-US', { dateStyle: 'short' })
let formattedEnd = end.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
due_date = `${formattedStart} - ${formattedEnd}`
}
return {
id: invoice.name,
name: name,
status: {
label: invoice.status,
color:
invoice.status === 'Paid'
? 'green'
: invoice.status == 'Unpaid'
? 'yellow'
: 'gray',
},
due_date: due_date,
total: formatCurrency(invoice.total),
download: {
url: invoice.invoice_pdf,
onClick: () => downloadInvoice(invoice.name),
},
}
})
})
function downloadInvoice(invoice) {
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.get_token_and_base_url',
auto: true,
onSuccess: (data) => {
fetch(`${data.base_url}/api/method/press.saas.api.billing.download_invoice`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Site-Access-Token': data.token,
},
body: JSON.stringify({ name: invoice }),
})
.then((response) => {
if (!response.ok) {
throw new Error('Network response was not ok ' + response.statusText)
}
return response.blob()
})
.then((blob) => {
const url = window.URL.createObjectURL(blob)
window.open(url, '_blank')
})
.catch((error) => {
console.error('There was a problem with the fetch operation:', error)
})
},
})
}
function formatCurrency(value) {
if (value === 0) {
return ''
}
return userCurrency(value)
}
function currency(value, currency, fractions = 2) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: fractions,
}).format(value)
}
function userCurrency(value, fractions = 2) {
return currency(value, team.data?.currency, fractions)
}
</script>

View file

@ -0,0 +1,7 @@
<template>
<div class="flex h-full flex-col py-11 px-[68px] gap-8 overflow-y-auto">
<h2 class="flex gap-2 text-xl font-semibold leading-5">
{{ 'Cards' }}
</h2>
</div>
</template>

225
billing/src/pages/Plans.vue Normal file
View file

@ -0,0 +1,225 @@
<template>
<div class="flex h-full flex-col py-11 px-[68px] gap-8 overflow-y-auto">
<h2 class="flex gap-2 text-xl font-semibold leading-5">
{{ 'Plans' }}
</h2>
<div v-if="rows.length">
<ListView
:columns="columns"
:rows="rows"
row-key="name"
:options="{
selectable: false,
showTooltip: false,
}"
>
<ListHeader />
<ListRows>
<ListRow
v-for="row in rows"
:key="row.name"
v-slot="{ column, item }"
:row="row"
:class="{ 'bg-gray-50 rounded': row.isCurrent }"
>
<ListRowItem :item="item" :align="column.align">
<Badge
v-if="column.key == 'upgrade' && row.isCurrent"
class="shrink-0 bg-white"
label="Current plan"
variant="outline"
size="lg"
/>
<Button
v-else-if="column.key == 'upgrade' && !row.isCurrent"
:label="row.downgrade ? 'Downgrade' : 'Upgrade'"
@click="row.onClick"
:disabled="!row.downgradable && row.downgrade"
/>
<div
v-if="column.key == 'price'"
class="text-base text-gray-900 font-semibold"
>
<span v-if="item.isTrial" class=""> Free trial </span>
<span v-else>
<span>{{ item.currency }} {{ item.label }}</span>
<span class="text-gray-700 font-normal">/mo</span>
</span>
</div>
<Tooltip v-if="column.key == 'info'">
<template #body>
<PlanDetails :plan="item" />
</template>
<FeatherIcon class="h-4 cursor-pointer" name="info" />
</Tooltip>
</ListRowItem>
</ListRow>
</ListRows>
</ListView>
</div>
<div v-else class="flex flex-1 items-center justify-center">
<Spinner class="size-8" />
</div>
<UpgradePlanStepsModal
v-if="showUpgradePlanStepsModal"
v-model="showUpgradePlanStepsModal"
:defaultStep="defaultStep"
:planName="planName"
@success="() => emit('success')"
/>
</div>
</template>
<script setup>
import PlanDetails from '@/components/PlanDetails.vue'
import UpgradePlanStepsModal from '@/components/UpgradePlanStepsModal.vue'
import {
ListView,
ListHeader,
ListRows,
ListRow,
ListRowItem,
Badge,
Spinner,
Button,
FeatherIcon,
Tooltip,
createResource,
} from 'frappe-ui'
import { parseSize } from '@/utils.js'
import { ref, computed, provide } from 'vue'
const emit = defineEmits(['success'])
const billingDetails = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'billing.get_information' },
cache: 'billingDetails',
auto: true,
})
const team = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'team.info' },
cache: 'team',
auto: true,
})
const plans = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'site.get_plans' },
cache: 'plans',
auto: true,
})
const site = createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'site.info' },
cache: 'site',
auto: true,
})
const currentPlan = computed(() => {
if (!site.data) return null
return site.data.plan?.name || 'Trial'
})
const currency = computed(() => {
if (!team.data) return 'INR'
return team.data.currency || 'INR'
})
const columns = [
{
label: '',
key: 'info',
width: '8px',
},
{
label: 'Cost',
key: 'price',
width: 0.8,
},
{
label: 'CPU',
key: 'cpu',
width: 1.2,
},
{
label: 'Memory',
key: 'memory',
width: 1.2,
},
{
label: 'Disk',
key: 'disk',
width: 0.7,
},
{
label: '',
key: 'upgrade',
width: 0.8,
align: 'right',
},
]
const rows = computed(() => {
if (!currentPlan.value) return []
if (!plans.data) return []
let currentPlanIndex = plans.data.findIndex((plan) => plan.name === currentPlan.value)
return plans.data
.map((plan, i) => {
let cpu = plan.cpu_time_per_day > 1 ? 'compute hrs/day' : 'compute hr/day'
let price = currency.value === 'INR' ? plan.price_inr : plan.price_usd
return {
name: plan.name,
price: {
label: price.toString(),
isTrial: plan.name === 'Trial',
currency: currency.value === 'INR' ? '₹' : '$',
},
cpu: `${plan.cpu_time_per_day} ${cpu}`,
memory: `${parseSize(plan.max_database_usage)} Database`,
disk: `${parseSize(plan.max_storage_usage)} Disk`,
info: plan,
isCurrent: plan.name === currentPlan.value,
downgradable: plan.allow_downgrading_from_other_plan,
downgrade: currentPlanIndex > i,
onClick: () => changePlan(plan.name),
}
})
.filter(
(row) =>
row.name !== 'Trial' || (row.name === 'Trial' && row.name === currentPlan.value),
)
})
const defaultStep = ref(1)
const showUpgradePlanStepsModal = ref(false)
const planName = ref('')
function changePlan(_planName) {
if (!billingDetails.data?.country || !team.data.payment_mode) {
defaultStep.value = billingDetails.data.country ? 2 : 1
showUpgradePlanStepsModal.value = true
planName.value = _planName
return
}
createResource({
url: 'frappe.integrations.frappe_providers.frappecloud_billing.api',
params: { method: 'site.change_plan', data: { plan: _planName } },
auto: true,
onSuccess: () => {
site.reload()
plans.reload()
emit('success')
},
})
}
provide('billing', {
team: computed(() => team.data),
reloadPlans: plans.reload,
reloadSite: site.reload,
})
</script>

31
billing/src/router.js Normal file
View file

@ -0,0 +1,31 @@
import { createRouter, createWebHistory } from 'vue-router'
const routes = [
{
path: '/',
name: 'Billing',
component: () => import('./pages/Billing.vue'),
},
{
path: '/plans',
name: 'Plans',
component: () => import('./pages/Plans.vue'),
},
{
path: '/invoices',
name: 'Invoices',
component: () => import('./pages/BillingHistory.vue'),
},
{
path: '/cards',
name: 'Cards',
component: () => import('./pages/Cards.vue'),
},
]
let router = createRouter({
history: createWebHistory('/billing'),
routes,
})
export default router

51
billing/src/utils.js Normal file
View file

@ -0,0 +1,51 @@
import Visa from './logo/Visa.vue'
import MasterCard from './logo/MasterCard.vue'
import Amex from './logo/Amex.vue'
import JCB from './logo/JCB.vue'
import UnionPay from './logo/UnionPay.vue'
import Generic from './logo/Generic.vue'
import { h } from 'vue'
export function calculateTrialEndDays(trialEndDate) {
if (!trialEndDate) return 0
trialEndDate = new Date(trialEndDate)
const today = new Date()
const diffTime = trialEndDate - today
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24))
return diffDays
}
export function currency(value, currency, fractions = 2) {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
maximumFractionDigits: fractions,
}).format(value)
}
export function cardBrandIcon(brand) {
let component = {
'master-card': MasterCard,
mastercard: MasterCard,
visa: Visa,
amex: Amex,
jcb: JCB,
generic: Generic,
'union-pay': UnionPay,
}[brand || 'generic']
if (!component) {
component = Generic
}
return h(component, { class: 'size-6' })
}
export function parseSize(sizeInMB) {
if (sizeInMB < 1024) {
return `${sizeInMB} MB`
} else {
return `${(sizeInMB / 1024).toFixed(0)} GB`
}
}

View file

@ -39,10 +39,9 @@ export default defineConfig({
commonjsOptions: {
include: [/tailwind.config.js/, /node_modules/],
},
// minify: false,
sourcemap: true,
},
optimizeDeps: {
include: ['tailwind.config.js'],
include: ['feather-icons', 'showdown', 'tailwind.config.js'],
},
})

View file

@ -58,6 +58,7 @@ website_route_rules = [
{"from_route": "/newsletters", "to_route": "Newsletter"},
{"from_route": "/profile", "to_route": "me"},
{"from_route": "/app/<path:app_path>", "to_route": "app"},
{"from_route": "/billing/<path:app_path>", "to_route": "billing"},
]
website_redirects = [